e.currentTarget.querySelector('input')?.focus()}
+ >
+ {list.map(email => (
+
+ {email}
+ setList(prev => prev.filter(e => e !== email))}>×
+
+ ))}
+ setInputVal(e.target.value)}
+ onKeyDown={onKeyDown}
+ onBlur={() => inputVal.trim() && commit(inputVal)}
+ placeholder={list.length === 0 ? placeholder : ''}
+ style={{ flex: '1 1 120px', minWidth: 80, border: 'none', outline: 'none', background: 'transparent', color: 'var(--color-text-primary)', fontSize: 'var(--font-size-sm)', padding: '1px 2px', fontFamily: 'var(--font-family-base)' }}
+ />
+ {onOpenPicker && (
+ { e.stopPropagation(); onOpenPicker() }}
+ title="Search customers"
+ style={{ position: 'absolute', right: 6, top: '50%', transform: 'translateY(-50%)', background: 'none', border: 'none', padding: 2, cursor: 'pointer', color: 'var(--color-text-muted)', display: 'flex', alignItems: 'center', lineHeight: 1, borderRadius: 'var(--radius-sm)', flexShrink: 0 }}
+ onMouseEnter={e => { e.currentTarget.style.color = 'var(--color-text-primary)'; e.currentTarget.style.backgroundColor = 'var(--color-bg-elevated)' }}
+ onMouseLeave={e => { e.currentTarget.style.color = 'var(--color-text-muted)'; e.currentTarget.style.backgroundColor = 'transparent' }}
+ >
+
+
+
+
+
+ )}
+
+ )
+}
+
+// ── Customer email picker ────────────────────────────────────────────────────
+// Loads ALL customers once on open, then filters locally — instant search.
+function CustomerEmailPicker({ open, onClose, onSelect }) {
+ const [query, setQuery] = useState('')
+ const [allCustomers, setAllCustomers] = useState([])
+ const [loading, setLoading] = useState(false)
+ const inputRef = useRef(null)
+
+ // Load all customers once when picker opens
+ useEffect(() => {
+ if (!open) { setQuery(''); return }
+ setTimeout(() => inputRef.current?.focus(), 60)
+ if (allCustomers.length > 0) return // already loaded
+ let cancelled = false
+ setLoading(true)
+ api.get('/crm/customers')
+ .then(data => { if (!cancelled) setAllCustomers(data.customers || data || []) })
+ .catch(() => { if (!cancelled) setAllCustomers([]) })
+ .finally(() => { if (!cancelled) setLoading(false) })
+ return () => { cancelled = true }
+ }, [open]) // intentionally omit allCustomers from deps
+
+ if (!open) return null
+
+ const q = query.trim().toLowerCase()
+ const emailsOf = (c) => (c.contacts || []).filter(ct => ct.type === 'email')
+
+ const filtered = allCustomers.filter(c => {
+ if (!q) return emailsOf(c).length > 0
+ const emails = emailsOf(c)
+ if (emails.length === 0) return false
+ return (
+ (c.name || '').toLowerCase().includes(q) ||
+ (c.surname || '').toLowerCase().includes(q) ||
+ (c.organization || '').toLowerCase().includes(q) ||
+ (c.location?.city || '').toLowerCase().includes(q) ||
+ (c.location?.country || '').toLowerCase().includes(q) ||
+ emails.some(ct => ct.value.toLowerCase().includes(q))
+ )
+ })
+
+ return (
+ setShowCustomerPicker(false)}
+ onSelect={email => setToList(prev => prev.includes(email) ? prev : [...prev, email])}
+ />
+
+ {/* ── Server file picker ── */}
+ {showServerFiles && (
+ setShowServerFiles(false)}
+ >
+
e.stopPropagation()}
+ >
+
+
Attach File from Server
+ setShowServerFiles(false)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--color-text-muted)', fontSize: 20, lineHeight: 1 }}>×
+
+
+ setServerFileSearch(e.target.value)}
+ autoFocus
+ style={{ ...nativeInput, flex: 1 }}
+ />
+ setServerFileType(e.target.value)} style={{ ...nativeInput, width: 'auto' }}>
+ All types
+ Documents
+ Quotations
+ Media
+
+
+
+ {serverFilesLoading && (
+
+ Loading files…
+
+ )}
+ {!serverFilesLoading && serverFiles.length === 0 && (
+
No files found for this customer.
+ )}
+ {!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
No files match your search.
+ }
+ const catColors = {
+ quotation: { bg: '#1a2d1e', color: '#6aab7a' },
+ document: { bg: '#1e1a2d', color: '#a78bfa' },
+ media: { bg: '#2d2a1a', color: '#c9a84c' },
+ }
+ return filtered.map(f => {
+ const alreadyAttached = attachments.some(a => a.name === f.filename)
+ const cat = getFileCategory(f)
+ 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)}`
+
+ const thumb = isImage
+ ?
{ e.stopPropagation(); setPreviewFile(f) }} style={{ width: 40, height: 40, objectFit: 'cover', borderRadius: 5, flexShrink: 0, cursor: 'zoom-in', border: '1px solid var(--color-border)' }} />
+ : (
+
{ 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(--color-border)' }}>
+ {cat === 'quotation' ? '🧾' : cat === 'media' ? '🎵' : '📄'}
+
+ )
+
+ return (
+
!alreadyAttached && attachServerFile(f)}
+ style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '8px 16px', borderBottom: '1px solid var(--color-border)', cursor: alreadyAttached ? 'default' : 'pointer', opacity: alreadyAttached ? 0.5 : 1 }}
+ onMouseEnter={e => { if (!alreadyAttached) e.currentTarget.style.backgroundColor = 'var(--color-bg-elevated)' }}
+ onMouseLeave={e => { e.currentTarget.style.backgroundColor = '' }}
+ >
+ {thumb}
+ {f.filename}
+ {kb}
+ {alreadyAttached
+ ? Attached
+ : {cat}
+ }
+
+ )
+ })
+ })()}
+
+
+
+ )}
+
+ {/* ── File preview ── */}
+ {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 (
+ setPreviewFile(null)}
+ >
+
e.stopPropagation()}
+ >
+
+
{previewFile.filename}
+
+
Download
+
setPreviewFile(null)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--color-text-muted)', fontSize: 20, lineHeight: 1 }}>×
+
+
+
+ {isImage &&
}
+ {isPdf &&
}
+ {!isImage && !isPdf &&
No preview available. }
+
+
+
+ )
+ })()}
+ >
+ )
+}
diff --git a/frontend/src/modals/crm/EditCommsEntryModal.jsx b/frontend/src/modals/crm/EditCommsEntryModal.jsx
new file mode 100644
index 0000000..a5e48e5
--- /dev/null
+++ b/frontend/src/modals/crm/EditCommsEntryModal.jsx
@@ -0,0 +1,208 @@
+// frontend/src/modals/crm/EditCommsEntryModal.jsx
+// Edit a manually-logged comms entry (type, direction, date/time, subject, body, logged_by).
+// System-logged entries (email, etc.) are rejected with a friendly error.
+
+import { useState, useEffect } from 'react'
+import api from '@/lib/api'
+import Modal from '@/components/ui/Modal'
+import Button from '@/components/ui/Button'
+import FormField from '@/components/ui/FormField'
+import DateTimePicker from '@/components/ui/DateTimePicker'
+import { useToast } from '@/components/ui/Toast'
+
+const SYSTEM_LOGGED_TYPES = ['email']
+
+const TYPE_OPTIONS = [
+ { value: 'email', label: 'Email' },
+ { value: 'whatsapp', label: 'WhatsApp' },
+ { value: 'call', label: 'Phone call' },
+ { value: 'sms', label: 'SMS' },
+ { value: 'note', label: 'Note' },
+ { value: 'in_person', label: 'In person' },
+]
+
+const DIRECTION_OPTIONS = [
+ { value: 'inbound', label: 'Inbound' },
+ { value: 'outbound', label: 'Outbound' },
+ { value: 'internal', label: 'Internal' },
+]
+
+export default function EditCommsEntryModal({ open, entry, onClose, onSaved }) {
+ const { toast } = useToast()
+
+ const [form, setForm] = useState({})
+ const [saving, setSaving] = useState(false)
+ const [staffList, setStaffList] = useState([])
+ const [staffLoading, setStaffLoading] = useState(false)
+ const isSystemEntry = entry && SYSTEM_LOGGED_TYPES.includes(entry.type) && entry.logged_by?.toLowerCase() === 'system'
+
+ useEffect(() => {
+ if (entry) {
+ setForm({
+ type: entry.type || 'note',
+ direction: entry.direction || 'inbound',
+ occurred_at: entry.occurred_at || '',
+ subject: entry.subject || '',
+ body: entry.body || '',
+ logged_by: entry.logged_by || '',
+ })
+ }
+ }, [entry])
+
+ // Fetch staff list when modal opens
+ useEffect(() => {
+ if (!open || isSystemEntry) return
+ setStaffLoading(true)
+ api.get('/staff')
+ .then((data) => setStaffList(data.staff || []))
+ .catch(() => setStaffList([]))
+ .finally(() => setStaffLoading(false))
+ }, [open])
+
+ const set = (key) => (e) => setForm((f) => ({ ...f, [key]: e.target.value }))
+ const setIso = (key) => (iso) => setForm((f) => ({ ...f, [key]: iso }))
+
+ const handleSave = async () => {
+ setSaving(true)
+ try {
+ const payload = {
+ type: form.type,
+ direction: form.direction,
+ subject: form.subject || null,
+ body: form.body || null,
+ logged_by: form.logged_by || null,
+ }
+ if (form.occurred_at) payload.occurred_at = form.occurred_at
+ await api.put(`/crm/comms/${entry.id}`, payload)
+ toast.success('Entry updated', 'The communication entry was saved successfully.')
+ onSaved?.()
+ onClose()
+ } catch (err) {
+ toast.danger('Save failed', err.message || 'Could not update entry.')
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ return (
+
+ Close
+
+ ) : (
+
+ Cancel
+ Save changes
+
+ )
+ }
+ >
+ {isSystemEntry ? (
+ /* ── System-entry error state ── */
+
+
+
+
+ System-logged entry
+
+
+ This entry was logged automatically by the system and cannot be edited. Only manually-created entries can be modified.
+
+
+
+ ) : (
+ /* ── Edit form ── */
+
+
+ {/* Row 1: type + direction */}
+
+
+ {TYPE_OPTIONS.map((o) => {o.label} )}
+
+
+ {DIRECTION_OPTIONS.map((o) => {o.label} )}
+
+
+
+ {/* Row 2: date/time + logged_by */}
+
+
+ {staffList.length > 0 ? (
+
+ — select staff —
+ {staffList.map((s) => (
+ {s.name}
+ ))}
+
+ ) : (
+
+ )}
+
+
+ {/* Subject */}
+
+
+ {/* Body */}
+
+
+
+ )}
+
+ )
+}
diff --git a/frontend/src/modals/crm/LegacyQuotationModal.jsx b/frontend/src/modals/crm/LegacyQuotationModal.jsx
new file mode 100644
index 0000000..136eb67
--- /dev/null
+++ b/frontend/src/modals/crm/LegacyQuotationModal.jsx
@@ -0,0 +1,160 @@
+// frontend/src/modals/crm/LegacyQuotationModal.jsx
+// Add / Edit a legacy (pre-system) quotation with optional PDF upload
+
+import { useState } from 'react'
+import api from '@/lib/api'
+import Modal from '@/components/ui/Modal'
+import Button from '@/components/ui/Button'
+import FormField from '@/components/ui/FormField'
+import { toDateInput } from '@/lib/formatters'
+
+const STATUSES = ['draft', 'sent', 'accepted', 'rejected']
+
+export default function LegacyQuotationModal({ customerId, existing, onClose, onSaved }) {
+ const isEdit = !!existing
+
+ const [form, setForm] = useState({
+ title: existing?.title || '',
+ legacy_date: existing?.legacy_date || toDateInput(new Date()),
+ status: existing?.status || 'sent',
+ final_total: existing?.final_total != null ? String(existing.final_total) : '',
+ })
+ const [pdfFile, setPdfFile] = useState(null)
+ const [saving, setSaving] = useState(false)
+ const [error, setError] = useState('')
+
+ function set(k, v) { setForm(f => ({ ...f, [k]: v })) }
+
+ async function handleSave() {
+ if (!form.title.trim()) { setError('Title is required'); return }
+ setSaving(true)
+ setError('')
+ try {
+ let quotation
+ if (isEdit) {
+ quotation = await api.put(`/crm/quotations/${existing.id}`, {
+ title: form.title,
+ status: form.status,
+ legacy_date: form.legacy_date || null,
+ extras_cost: parseFloat(form.final_total) || 0,
+ })
+ } else {
+ quotation = await api.post('/crm/quotations', {
+ customer_id: customerId,
+ title: form.title,
+ status: form.status,
+ is_legacy: true,
+ legacy_date: form.legacy_date || null,
+ extras_cost: parseFloat(form.final_total) || 0,
+ })
+ }
+
+ if (pdfFile) {
+ await api.upload(`/crm/quotations/${quotation.id}/legacy-pdf`, pdfFile)
+ }
+
+ onSaved()
+ onClose()
+ } catch (e) {
+ setError(e?.message || 'Failed to save')
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ return (
+
+
+ Cancel
+
+
+ {isEdit ? 'Save Changes' : 'Add Legacy'}
+
+
+ }
+ >
+
+
set('title', e.target.value)}
+ placeholder="e.g. Church Sound System Offer"
+ required
+ />
+
+
+ set('legacy_date', e.target.value)}
+ inputProps={{ type: 'date' }}
+ />
+ set('status', e.target.value)}
+ >
+ {STATUSES.map(s => (
+ {s.charAt(0).toUpperCase() + s.slice(1)}
+ ))}
+
+
+
+ set('final_total', e.target.value)}
+ placeholder="0.00"
+ inputProps={{ min: 0, step: '0.01' }}
+ />
+
+
+
+ PDF File (optional)
+
+ setPdfFile(e.target.files?.[0] || null)}
+ className="input"
+ style={{ paddingTop: 'var(--space-2)', paddingBottom: 'var(--space-2)' }}
+ />
+ {existing?.legacy_pdf_path && !pdfFile && (
+
+ Current: {existing.legacy_pdf_path.split('/').pop()}
+
+ )}
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+ )
+}
diff --git a/frontend/src/modals/crm/LogCommsEntryModal.jsx b/frontend/src/modals/crm/LogCommsEntryModal.jsx
new file mode 100644
index 0000000..d6e9811
--- /dev/null
+++ b/frontend/src/modals/crm/LogCommsEntryModal.jsx
@@ -0,0 +1,164 @@
+// frontend/src/modals/crm/LogCommsEntryModal.jsx
+// Log a new manual communication entry for a customer.
+
+import { useState, useEffect } from 'react'
+import api from '@/lib/api'
+import Modal from '@/components/ui/Modal'
+import Button from '@/components/ui/Button'
+import FormField from '@/components/ui/FormField'
+import DateTimePicker from '@/components/ui/DateTimePicker'
+import { useToast } from '@/components/ui/Toast'
+import { useAuth } from '@/hooks/useAuth'
+
+const TYPE_OPTIONS = [
+ { value: 'whatsapp', label: 'WhatsApp' },
+ { value: 'call', label: 'Phone call' },
+ { value: 'sms', label: 'SMS' },
+ { value: 'note', label: 'Note' },
+ { value: 'in_person', label: 'In person' },
+]
+
+const DIRECTION_OPTIONS = [
+ { value: 'inbound', label: 'Inbound' },
+ { value: 'outbound', label: 'Outbound' },
+ { value: 'internal', label: 'Internal' },
+]
+
+const DEFAULT_FORM = (currentUserName = '') => ({
+ type: 'note',
+ direction: 'internal',
+ occurred_at: new Date().toISOString(),
+ subject: '',
+ body: '',
+ logged_by: currentUserName,
+})
+
+export default function LogCommsEntryModal({ open, customerId, onClose, onSaved }) {
+ const { toast } = useToast()
+ const { user } = useAuth()
+ const [form, setForm] = useState(DEFAULT_FORM())
+ const [saving, setSaving] = useState(false)
+ const [staffList, setStaffList] = useState([])
+ const [staffLoading, setStaffLoading] = useState(false)
+
+ // Fetch staff list when modal opens
+ useEffect(() => {
+ if (!open) return
+ const currentName = user?.name || ''
+ setForm(DEFAULT_FORM(currentName))
+ setStaffLoading(true)
+ api.get('/staff')
+ .then((data) => setStaffList(data.staff || []))
+ .catch(() => setStaffList([]))
+ .finally(() => setStaffLoading(false))
+ }, [open])
+
+ const set = (key) => (e) => setForm((f) => ({ ...f, [key]: e.target.value }))
+ const setIso = (key) => (iso) => setForm((f) => ({ ...f, [key]: iso }))
+
+ const handleSave = async () => {
+ setSaving(true)
+ try {
+ await api.post('/crm/comms', {
+ customer_id: customerId,
+ type: form.type,
+ direction: form.direction,
+ subject: form.subject || null,
+ body: form.body || null,
+ logged_by: form.logged_by || null,
+ occurred_at: form.occurred_at || new Date().toISOString(),
+ })
+ toast.success('Entry logged', 'Communication entry saved.')
+ onSaved?.()
+ onClose()
+ } catch (err) {
+ toast.danger('Save failed', err.message || 'Could not log entry.')
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ return (
+
+ Cancel
+ Log entry
+
+ }
+ >
+
+
+ {/* Row 1: type + direction */}
+
+
+ {TYPE_OPTIONS.map((o) => {o.label} )}
+
+
+ {DIRECTION_OPTIONS.map((o) => {o.label} )}
+
+
+
+ {/* Row 2: date/time + logged_by */}
+
+
+ {staffList.length > 0 ? (
+
+ — select staff —
+ {staffList.map((s) => (
+ {s.name}
+ ))}
+
+ ) : (
+
+ )}
+
+
+ {/* Subject */}
+
+
+ {/* Notes */}
+
+
+
+
+ )
+}
diff --git a/frontend/src/modals/crm/MailSettingsModal.jsx b/frontend/src/modals/crm/MailSettingsModal.jsx
new file mode 100644
index 0000000..83700b7
--- /dev/null
+++ b/frontend/src/modals/crm/MailSettingsModal.jsx
@@ -0,0 +1,227 @@
+// frontend/src/modals/crm/MailSettingsModal.jsx
+// Mail settings: auto-refresh interval + email signature editor (Quill rich text).
+
+import { useState, useEffect, useRef } from 'react'
+import Modal from '@/components/ui/Modal'
+import Button from '@/components/ui/Button'
+import FormField from '@/components/ui/FormField'
+import { loadQuill } from '@/lib/loadQuill'
+
+const DEFAULT_POLL_INTERVAL = 30
+
+function getPollInterval() {
+ const v = parseInt(localStorage.getItem('mail_poll_interval'), 10)
+ return !isNaN(v) && v >= 15 && v <= 300 ? v : DEFAULT_POLL_INTERVAL
+}
+
+export default function MailSettingsModal({ open, onClose, onSaved }) {
+ const [pollInterval, setPollInterval] = useState(String(getPollInterval()))
+ const [saved, setSaved] = useState(false)
+
+ const editorRef = useRef(null)
+ const quillRef = useRef(null)
+ const [quillReady, setQuillReady] = useState(() => !!window.Quill)
+
+ // Reset poll interval from storage each time modal opens
+ useEffect(() => {
+ if (open) {
+ setPollInterval(String(getPollInterval()))
+ setSaved(false)
+ }
+ }, [open])
+
+ // Load Quill via shared loader (no-op if already loaded)
+ useEffect(() => {
+ if (!open) return
+ loadQuill(() => setQuillReady(true))
+ }, [open])
+
+ // Mount Quill into the editor div after it's ready and modal is open
+ useEffect(() => {
+ if (!open || !quillReady || !editorRef.current || quillRef.current) return
+ const q = new window.Quill(editorRef.current, {
+ theme: 'snow',
+ placeholder: 'Type your signature here…',
+ modules: {
+ toolbar: [
+ ['bold', 'italic', 'underline'],
+ [{ color: [] }],
+ ['link'],
+ ['clean'],
+ ],
+ },
+ })
+ const stored = localStorage.getItem('mail_signature') || ''
+ if (stored) q.clipboard.dangerouslyPasteHTML(stored)
+ quillRef.current = q
+ }, [open, quillReady])
+
+ // Destroy Quill instance when modal closes
+ useEffect(() => {
+ if (!open) { quillRef.current = null }
+ }, [open])
+
+ const handleSave = () => {
+ const html = quillRef.current ? quillRef.current.root.innerHTML : ''
+ const interval = Math.min(300, Math.max(15, parseInt(pollInterval, 10) || DEFAULT_POLL_INTERVAL))
+ localStorage.setItem('mail_signature', html)
+ localStorage.setItem('mail_poll_interval', String(interval))
+ setSaved(true)
+ setTimeout(() => {
+ setSaved(false)
+ onSaved?.()
+ onClose()
+ }, 700)
+ }
+
+ return (
+ <>
+ {/* Quill dark-theme overrides — scoped to modal body */}
+
+
+
+ {saved && (
+
+ Saved!
+
+ )}
+ Cancel
+ Save settings
+
+ }
+ >
+
+
+ {/* ── Auto-refresh interval ── */}
+
+
+
+ Auto-check interval
+
+
+
+
+ setPollInterval(e.target.value)}
+ inputProps={{ min: 15, max: 300 }}
+ />
+
+
+ seconds (min 15 · max 300)
+
+
+
+ How often the mailbox silently checks for new emails. A banner appears when new mail is detected.
+
+
+
+ {/* ── Divider ── */}
+
+
+ {/* ── Email signature ── */}
+
+
+ Email signature
+
+
+ Added to outgoing emails when you click "Add Signature" in the compose window.
+
+
+
+ {quillReady ? (
+
+ ) : (
+
+ Loading editor…
+
+ )}
+
+
+
+
+
+ >
+ )
+}
diff --git a/frontend/src/modals/crm/MailViewModal.jsx b/frontend/src/modals/crm/MailViewModal.jsx
new file mode 100644
index 0000000..a46501f
--- /dev/null
+++ b/frontend/src/modals/crm/MailViewModal.jsx
@@ -0,0 +1,576 @@
+// frontend/src/modals/crm/MailViewModal.jsx
+// Full email reading modal — iframe body, dark/light toggle, attachments, add-customer banner.
+
+import { useState, useEffect } from 'react'
+import { Link } from 'react-router-dom'
+import Modal from '@/components/ui/Modal'
+import Button from '@/components/ui/Button'
+import api from '@/lib/api'
+import { fmtDateTimeFull } from '@/lib/formatters'
+
+const MEDIA_CATEGORIES = [
+ { value: 'received_media', label: 'Received Media' },
+ { value: 'media', label: 'General' },
+ { value: 'documents', label: 'Documents' },
+ { value: 'invoices', label: 'Invoices' },
+ { value: 'quotations', label: 'Quotations' },
+ { value: 'sent_media', label: 'Sent Media' },
+]
+
+function fullDate(dateStr) { return fmtDateTimeFull(dateStr) }
+
+// ─── Save Inline Image mini-modal ────────────────────────────────────────────
+
+function SaveInlineModal({ image, commId, onClose }) {
+ // image: { filename, mime_type, dataUri }
+ const [filename, setFilename] = useState(image.filename)
+ const [subfolder, setSubfolder] = useState('received_media')
+ const [saving, setSaving] = useState(false)
+ const [done, setDone] = useState(false)
+ const [err, setErr] = useState('')
+
+ const handleSave = async () => {
+ if (!filename.trim()) { setErr('Please enter a filename.'); return }
+ setSaving(true); setErr('')
+ try {
+ await api.post(`/crm/comms/email/${commId}/save-inline`, {
+ data_uri: image.dataUri,
+ filename: filename.trim(),
+ subfolder,
+ mime_type: image.mime_type,
+ })
+ setDone(true)
+ } catch (e) {
+ setErr(e.message)
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ const inputStyle = {
+ width: '100%',
+ padding: 'var(--space-2) var(--space-3)',
+ fontSize: 'var(--font-size-sm)',
+ backgroundColor: 'var(--color-bg-elevated)',
+ border: '1px solid var(--color-border-strong)',
+ borderRadius: 'var(--radius-md)',
+ color: 'var(--color-text-primary)',
+ outline: 'none',
+ fontFamily: 'var(--font-family-base)',
+ }
+
+ return (
+
+
e.stopPropagation()}
+ >
+ {done ? (
+
+
✓
+
Saved successfully
+
+ Stored in {MEDIA_CATEGORIES.find(c => c.value === subfolder)?.label ?? subfolder}
+
+
Done
+
+ ) : (
+ <>
+
+ Save to Customer Media
+
+
+ This image will be saved to the customer's media folder in Nextcloud.
+
+
+
+
+ Filename
+
+ setFilename(e.target.value)} autoFocus />
+
+
+
+
+ Media Type
+
+ setSubfolder(e.target.value)}
+ >
+ {MEDIA_CATEGORIES.map((c) => (
+ {c.label}
+ ))}
+
+
+
+ {err &&
{err}
}
+
+
+ Cancel
+ Save
+
+ >
+ )}
+
+
+ )
+}
+
+// ─── 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('')
+
+ const handleSave = async () => {
+ if (!name.trim()) { setErr('Please enter a first name.'); return }
+ setSaving(true); setErr('')
+ try {
+ const data = await api.post('/crm/customers', {
+ name: name.trim(),
+ surname: surname.trim(),
+ organization: organization.trim(),
+ language: 'en',
+ contacts: [{ type: 'email', label: 'Email', value: email, primary: true }],
+ })
+ onCreated(data)
+ } catch (e) {
+ setErr(e.message)
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ const inputStyle = {
+ width: '100%',
+ padding: 'var(--space-2) var(--space-3)',
+ fontSize: 'var(--font-size-sm)',
+ backgroundColor: 'var(--color-bg-elevated)',
+ border: '1px solid var(--color-border-strong)',
+ borderRadius: 'var(--radius-md)',
+ color: 'var(--color-text-primary)',
+ outline: 'none',
+ }
+
+ return (
+
+
e.stopPropagation()}
+ >
+
+ Add Customer
+
+
+ Adding {email} as a new customer.
+
+
+
+
+
+ Organization
+ setOrganization(e.target.value)} placeholder="Church, school, etc." />
+
+
+
+ Email
+
+
+
+ {err &&
{err}
}
+
+
+ Cancel
+ Add Customer
+
+
+
+ )
+}
+
+// ─── Main modal ───────────────────────────────────────────────────────────────
+
+export default function MailViewModal({ entry, customers, resolveCustomer, onClose, onReply }) {
+ const [bodyLight, setBodyLight] = useState(false)
+ const [showAddCustomer, setShowAddCustomer] = useState(false)
+ const [addedCustomer, setAddedCustomer] = useState(null)
+ const [inlineImages, setInlineImages] = useState([])
+ const [saveImage, setSaveImage] = useState(null) // image to save
+
+ // Reset per-message state when entry changes
+ useEffect(() => {
+ setBodyLight(false)
+ setAddedCustomer(null)
+ setShowAddCustomer(false)
+ setSaveImage(null)
+ }, [entry])
+
+ // Extract inline images from HTML body
+ useEffect(() => {
+ const html = entry?.body_html
+ if (!html) { setInlineImages([]); return }
+ const parser = new DOMParser()
+ const doc = parser.parseFromString(html, 'text/html')
+ const imgs = Array.from(doc.querySelectorAll('img[src^="data:"]'))
+ setInlineImages(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 { filename: `inline-image-${i + 1}.${ext}`, mime_type: mime }
+ }))
+ }, [entry])
+
+ if (!entry) return null
+
+ const customer = resolveCustomer
+ ? resolveCustomer(entry)
+ : (entry.customer_id ? customers?.[entry.customer_id] : null)
+
+ const isInbound = entry.direction === 'inbound'
+ const replyAddr = isInbound
+ ? (entry.from_addr || '')
+ : (Array.isArray(entry.to_addrs) ? entry.to_addrs[0] : entry.to_addrs || '')
+
+ const toAddrs = Array.isArray(entry.to_addrs)
+ ? entry.to_addrs.join(', ')
+ : (entry.to_addrs || '')
+
+ const attachments = Array.isArray(entry.attachments) ? entry.attachments : []
+ const unknownSenderEmail = isInbound && !customer && !addedCustomer ? (entry.from_addr || null) : null
+
+ // Prefer body_html (rich HTML from IMAP), fall back to plain body
+ const iframeContent = entry.body_html
+ ? entry.body_html
+ : `${(entry.body || '').replace(/`
+
+ const iframeSrcDoc = `
+
+
+ ${iframeContent}`
+
+ return (
+ <>
+ {/*
+ We use className="mail-view-modal" to override .modal-body so it doesn't
+ scroll — only the iframe scrolls. The style tag below is injected once.
+ */}
+
+
+
+ {replyAddr && (
+ onReply(replyAddr, entry.mailbox_account)}>
+ Reply
+
+ )}
+ Close
+
+ }
+ >
+
+ {/* ── Meta row (inline From / To / Date) — fixed ────────────── */}
+
+ {/* From */}
+
+ From{' '}
+ {customer ? (
+
+ {customer.name}{customer.surname ? ` ${customer.surname}` : ''}
+ {customer.organization ? ` · ${customer.organization}` : ''}
+
+ ) : null}
+ {entry.from_addr && (
+
+ {customer ? `<${entry.from_addr}>` : entry.from_addr}
+
+ )}
+
+
+ {/* Separator */}
+ {toAddrs && · }
+
+ {/* To */}
+ {toAddrs && (
+
+ To {toAddrs}
+
+ )}
+
+ {/* Separator */}
+ {entry.occurred_at && · }
+
+ {/* Date */}
+ {entry.occurred_at && (
+
+ {fullDate(entry.occurred_at)}
+
+ )}
+
+
+ {/* ── Body — flex:1, only this scrolls (via iframe) ─────────── */}
+
+ {/* Dark / Light toggle */}
+
+ setBodyLight((v) => !v)}
+ title={bodyLight ? 'Switch to dark mode' : 'Switch to light mode (better for images)'}
+ style={{
+ padding: '3px 10px',
+ fontSize: 'var(--font-size-xs)',
+ borderRadius: 'var(--radius-md)',
+ cursor: 'pointer',
+ border: '1px solid rgba(128,128,128,0.35)',
+ backgroundColor: bodyLight ? 'rgba(255,255,255,0.85)' : 'rgba(0,0,0,0.55)',
+ color: bodyLight ? '#333' : '#e0e0e0',
+ backdropFilter: 'blur(4px)',
+ transition: 'all 0.15s',
+ lineHeight: 1.5,
+ }}
+ >
+ {bodyLight ? '🌙 Dark' : '☀ Light'}
+
+
+
+
+
+
+ {/* ── Inline images — fixed ─────────────────────────────────── */}
+ {inlineImages.length > 0 && (
+
+
+ Inline Images
+
+ {inlineImages.map((img, i) => {
+ const canSave = !!customer
+ return (
+ setSaveImage(img) : undefined}
+ title={canSave ? 'Click to save to customer media folder' : 'Not linked to a customer'}
+ style={{
+ display: 'flex', alignItems: 'center', gap: 'var(--space-1)',
+ padding: '2px var(--space-3)',
+ backgroundColor: 'var(--color-bg-elevated)',
+ border: '1px solid var(--color-border)',
+ borderRadius: 'var(--radius-md)',
+ fontSize: 'var(--font-size-xs)',
+ color: 'var(--color-text-primary)',
+ cursor: canSave ? 'pointer' : 'default',
+ transition: 'border-color 0.12s, background-color 0.12s',
+ fontFamily: 'var(--font-family-base)',
+ }}
+ onMouseEnter={canSave ? (e) => { e.currentTarget.style.borderColor = 'var(--color-primary)'; e.currentTarget.style.backgroundColor = 'var(--color-primary-subtle)' } : undefined}
+ onMouseLeave={canSave ? (e) => { e.currentTarget.style.borderColor = 'var(--color-border)'; e.currentTarget.style.backgroundColor = 'var(--color-bg-elevated)' } : undefined}
+ >
+ 🖼
+ {img.filename}
+ {canSave && ↓ }
+
+ )
+ })}
+ {!customer && (
+
+ Link to a customer to save images
+
+ )}
+
+ )}
+
+ {/* ── Attachments — fixed ───────────────────────────────────── */}
+ {attachments.length > 0 && (
+
+
+ Attachments
+
+ {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 (
+
+ {icon}
+ {name}{kb}
+
+ )
+ })}
+
+ )}
+
+ {/* ── Unknown sender banner — fixed ────────────────────────── */}
+ {unknownSenderEmail && (
+
+ {unknownSenderEmail}
+ {' '}is not in your Customer's list.{' '}
+ setShowAddCustomer(true)}
+ style={{
+ background: 'none', border: 'none', padding: 0,
+ color: 'var(--color-text-accent)',
+ fontWeight: 'var(--font-weight-medium)',
+ fontSize: 'inherit',
+ cursor: 'pointer',
+ textDecoration: 'underline',
+ }}
+ >
+ Click here to add them.
+
+
+ )}
+
+ {/* ── Added-customer confirmation — fixed ───────────────────── */}
+ {addedCustomer && (
+
+ ✓ {addedCustomer.name} has been added as a customer.
+
+ )}
+
+
+
+ {/* Save inline image sub-modal */}
+ {saveImage && entry && (
+ setSaveImage(null)}
+ />
+ )}
+
+ {/* Add Customer sub-modal */}
+ {showAddCustomer && entry?.from_addr && (
+ setShowAddCustomer(false)}
+ onCreated={(newCustomer) => {
+ setAddedCustomer(newCustomer)
+ setShowAddCustomer(false)
+ }}
+ />
+ )}
+ >
+ )
+}
diff --git a/frontend/src/modals/crm/PdfViewModal.jsx b/frontend/src/modals/crm/PdfViewModal.jsx
new file mode 100644
index 0000000..206bb71
--- /dev/null
+++ b/frontend/src/modals/crm/PdfViewModal.jsx
@@ -0,0 +1,147 @@
+// frontend/src/modals/crm/PdfViewModal.jsx
+// Fullscreen PDF viewer for quotations — used from both QuotationList and QuotationForm
+
+import { useState, useEffect } from 'react'
+import { createPortal } from 'react-dom'
+import Button from '@/components/ui/Button'
+import Spinner from '@/components/ui/Spinner'
+
+export default function PdfViewModal({ open, quotationId, quotationNumber, onClose }) {
+ const [blobUrl, setBlobUrl] = useState(null)
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState(false)
+
+ useEffect(() => {
+ if (!open || !quotationId) return
+ setBlobUrl(null)
+ setLoading(true)
+ setError(false)
+ 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(() => setLoading(false))
+
+ return () => { if (objectUrl) URL.revokeObjectURL(objectUrl) }
+ }, [quotationId])
+
+ useEffect(() => {
+ if (!open) return
+ function onKey(e) { if (e.key === 'Escape') onClose() }
+ document.addEventListener('keydown', onKey)
+ return () => document.removeEventListener('keydown', onKey)
+ }, [open, onClose])
+
+ if (!open) return null
+
+ return createPortal(
+
+
e.stopPropagation()}
+ style={{
+ backgroundColor: 'var(--color-bg-surface)',
+ borderRadius: 'var(--radius-xl)',
+ overflow: 'hidden',
+ width: 'min(82vw, 1100px)',
+ height: '88vh',
+ display: 'flex',
+ flexDirection: 'column',
+ boxShadow: 'var(--shadow-lg)',
+ border: '1px solid var(--color-border)',
+ }}
+ >
+ {/* Header */}
+
+
+ {quotationNumber}
+
+
+ {blobUrl && (
+
+ Download PDF
+
+ )}
+
+
+
+
+
+
+
+
+ {/* Body */}
+
+ {loading && }
+ {error && (
+
+ Failed to load PDF.
+
+ )}
+ {blobUrl && (
+
+ )}
+
+
+
,
+ document.body,
+ )
+}
diff --git a/frontend/src/modals/crm/ProductSearchModal.jsx b/frontend/src/modals/crm/ProductSearchModal.jsx
new file mode 100644
index 0000000..df6b3d7
--- /dev/null
+++ b/frontend/src/modals/crm/ProductSearchModal.jsx
@@ -0,0 +1,138 @@
+// frontend/src/modals/crm/ProductSearchModal.jsx
+// Search & select a product from the catalogue to add to a quotation
+
+import { useState, useEffect } from 'react'
+import api from '@/lib/api'
+import Modal from '@/components/ui/Modal'
+import Button from '@/components/ui/Button'
+import SearchBar from '@/components/ui/SearchBar'
+import Spinner from '@/components/ui/Spinner'
+import { fmtEuro } from '@/lib/formatters'
+
+function fmt(n) { return fmtEuro(n) }
+
+export default function ProductSearchModal({ onSelect, onClose }) {
+ const [search, setSearch] = useState('')
+ const [allProducts, setAllProducts] = useState([])
+ const [loading, setLoading] = useState(true)
+
+ useEffect(() => {
+ api.get('/crm/products')
+ .then(res => setAllProducts(res.products || []))
+ .catch(() => setAllProducts([]))
+ .finally(() => setLoading(false))
+ }, [])
+
+ const filtered = search.trim()
+ ? allProducts.filter(p =>
+ (p.name_en || p.name || '').toLowerCase().includes(search.toLowerCase()) ||
+ (p.name_gr || '').toLowerCase().includes(search.toLowerCase()) ||
+ (p.sku || '').toLowerCase().includes(search.toLowerCase()),
+ )
+ : allProducts
+
+ return (
+
+ Cancel
+
+ }
+ >
+
+
+
+
+ {loading && (
+
+
+
+ )}
+
+ {!loading && filtered.length === 0 && (
+
+ {search.trim() ? 'No products match your search' : 'No products in catalogue'}
+
+ )}
+
+ {filtered.map((p, i) => (
+
onSelect(p)}
+ onKeyDown={e => e.key === 'Enter' && onSelect(p)}
+ style={{
+ padding: 'var(--space-3) var(--space-4)',
+ cursor: 'pointer',
+ borderBottom: i < filtered.length - 1 ? '1px solid var(--color-border)' : 'none',
+ transition: 'background-color 0.1s',
+ display: 'flex',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ gap: 'var(--space-3)',
+ }}
+ onMouseEnter={e => e.currentTarget.style.backgroundColor = 'var(--color-bg-elevated)'}
+ onMouseLeave={e => e.currentTarget.style.backgroundColor = ''}
+ >
+
+
+ {p.name_en || p.name}
+ {p.name_gr && p.name_gr !== (p.name_en || p.name) && (
+
+ / {p.name_gr}
+
+ )}
+
+ {p.sku && (
+
+ {p.sku}
+
+ )}
+
+
+ {fmt(p.price)}
+
+
+ ))}
+
+
+
+ )
+}
diff --git a/frontend/src/modals/crm/QuickEntryModals.jsx b/frontend/src/modals/crm/QuickEntryModals.jsx
new file mode 100644
index 0000000..7971c77
--- /dev/null
+++ b/frontend/src/modals/crm/QuickEntryModals.jsx
@@ -0,0 +1,523 @@
+// frontend/src/modals/crm/QuickEntryModals.jsx
+// Quick-entry modals for the Customer Detail page:
+// InitNegotiationsModal — create a new order at negotiating stage
+// SendMessageModal — compose and send an email
+// RecordIssueModal — log a tech issue or install-support ticket
+// RecordPaymentModal — record a payment / invoice transaction
+// AddFilesModal — placeholder for file upload flow (to be wired to Nextcloud)
+
+import { useState, useEffect } from 'react'
+import Modal from '@/components/ui/Modal'
+import Button from '@/components/ui/Button'
+import FormField from '@/components/ui/FormField'
+import DateTimePicker from '@/components/ui/DateTimePicker'
+import { useToast } from '@/components/ui/Toast'
+import { useAuth } from '@/hooks/useAuth'
+import api from '@/lib/api'
+
+// ─── Init Negotiations Modal ──────────────────────────────────────────────────
+
+export function InitNegotiationsModal({ open, customerId, onClose, onSuccess }) {
+ const { toast } = useToast()
+ const { user } = useAuth()
+ const [form, setForm] = useState({ date: new Date().toISOString(), title: '', note: '' })
+ const [saving, setSaving] = useState(false)
+ const set = (k, v) => setForm(f => ({ ...f, [k]: v }))
+
+ useEffect(() => { if (open) setForm({ date: new Date().toISOString(), title: '', note: '' }) }, [open])
+
+ const handleConfirm = async () => {
+ if (!form.title.trim()) { toast.warning('Required', 'Title is required.'); return }
+ setSaving(true)
+ try {
+ await api.post(`/crm/customers/${customerId}/orders/init-negotiations`, {
+ title: form.title.trim(),
+ note: form.note.trim(),
+ date: form.date ? new Date(form.date).toISOString() : new Date().toISOString(),
+ created_by: user?.display_name || user?.name || 'Staff',
+ })
+ // Promote customer to Active when a new order is initiated
+ try { await api.patch(`/crm/customers/${customerId}/relationship-status`, { status: 'active' }) } catch { /* non-critical */ }
+ toast.success('Order created', 'Negotiations started successfully.')
+ onSuccess?.()
+ onClose()
+ } catch (err) {
+ toast.danger('Failed', err.message || 'Could not create order.')
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ return (
+
+ Cancel
+ Create Order
+
+ }
+ >
+
+ set('date', iso)}
+ />
+ set('title', e.target.value)}
+ placeholder="e.g. 3× Wall Mount Units — Athens Office"
+ required
+ inputProps={{ autoFocus: true }}
+ />
+ set('note', e.target.value)}
+ placeholder="Initial context or customer request..."
+ rows={3}
+ />
+
+
+ )
+}
+
+// ─── Send Message Modal ───────────────────────────────────────────────────────
+
+export function SendMessageModal({ open, customer, onClose, onSent }) {
+ const { toast } = useToast()
+
+ // Pre-fill "to" from the customer's primary email contact
+ const primaryEmail = customer?.contact_details?.find(c => c.type === 'email')?.value || ''
+
+ const [form, setForm] = useState({ to: '', subject: '', body: '' })
+ const [sending, setSending] = useState(false)
+ const set = (k, v) => setForm(f => ({ ...f, [k]: v }))
+
+ useEffect(() => {
+ if (open) setForm({ to: primaryEmail, subject: '', body: '' })
+ }, [open, primaryEmail])
+
+ const canSend = form.to.trim() && form.subject.trim() && form.body.trim()
+
+ const handleSend = async () => {
+ if (!canSend) return
+ setSending(true)
+ try {
+ await api.post('/crm/comms/email/send', {
+ to: form.to.trim(),
+ subject: form.subject.trim(),
+ body: form.body.trim(),
+ })
+ toast.success('Email sent', `Message delivered to ${form.to.trim()}.`)
+ onSent?.()
+ onClose()
+ } catch (err) {
+ toast.danger('Failed to send', err.message || 'Could not send email.')
+ } finally {
+ setSending(false)
+ }
+ }
+
+ return (
+
+ Cancel
+ Send
+
+ }
+ >
+
+ set('to', e.target.value)}
+ placeholder="recipient@example.com"
+ required
+ inputProps={{ autoFocus: true }}
+ />
+ set('subject', e.target.value)}
+ placeholder="Email subject..."
+ required
+ />
+ set('body', e.target.value)}
+ placeholder="Write your message here..."
+ rows={6}
+ required
+ />
+
+
+ )
+}
+
+// ─── Record Issue Modal ───────────────────────────────────────────────────────
+
+export function RecordIssueModal({ open, customerId, onClose, onSuccess }) {
+ const { toast } = useToast()
+ const { user } = useAuth()
+ const [type, setType] = useState('issue') // 'issue' | 'support'
+ const [form, setForm] = useState({ date: new Date().toISOString(), note: '' })
+ const [saving, setSaving] = useState(false)
+ const set = (k, v) => setForm(f => ({ ...f, [k]: v }))
+
+ useEffect(() => { if (open) { setType('issue'); setForm({ date: new Date().toISOString(), note: '' }) } }, [open])
+
+ const handleConfirm = async () => {
+ if (!form.note.trim()) { toast.warning('Required', 'A note is required.'); return }
+ setSaving(true)
+ const endpoint = type === 'issue' ? 'technical-issues' : 'install-support'
+ try {
+ await api.post(`/crm/customers/${customerId}/${endpoint}`, {
+ note: form.note.trim(),
+ opened_by: user?.display_name || user?.name || 'Staff',
+ date: form.date ? new Date(form.date).toISOString() : new Date().toISOString(),
+ })
+ toast.success('Recorded', type === 'issue' ? 'Tech issue logged.' : 'Support request logged.')
+ onSuccess?.()
+ onClose()
+ } catch (err) {
+ toast.danger('Failed', err.message || 'Could not record issue.')
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ const isIssue = type === 'issue'
+ const isSupport = type === 'support'
+
+ return (
+
+ Cancel
+ Submit
+
+ }
+ >
+
+ {/* Type segmented toggle */}
+
+ {[['issue', 'Technical Issue'], ['support', 'Install Support']].map(([v, label]) => (
+ setType(v)}
+ style={{
+ flex: 1,
+ padding: 'var(--space-2) 0',
+ fontSize: 'var(--font-size-sm)',
+ fontWeight: 'var(--font-weight-semibold)',
+ cursor: 'pointer',
+ border: 'none',
+ transition: 'background-color var(--transition-fast), color var(--transition-fast)',
+ backgroundColor: type === v
+ ? v === 'issue' ? 'var(--color-danger-bg)' : 'var(--color-warning-bg)'
+ : 'transparent',
+ color: type === v
+ ? v === 'issue' ? 'var(--color-danger)' : 'var(--color-warning)'
+ : 'var(--color-text-muted)',
+ }}
+ >
+ {label}
+
+ ))}
+
+
+
set('date', iso)}
+ />
+ set('note', e.target.value)}
+ placeholder={isIssue ? 'Describe the technical issue...' : 'Describe the support request...'}
+ rows={4}
+ required
+ inputProps={{ autoFocus: true }}
+ />
+
+
+ )
+}
+
+// ─── Record Payment Modal ─────────────────────────────────────────────────────
+
+const FLOW_LABELS = {
+ payment: 'Payment',
+ invoice: 'Invoice',
+ refund: 'Refund',
+ credit: 'Credit Note',
+}
+
+const PAYMENT_TYPE_LABELS = {
+ cash: 'Cash',
+ bank_transfer: 'Bank Transfer',
+ card: 'Card',
+ cheque: 'Cheque',
+ crypto: 'Crypto',
+ other: 'Other',
+}
+
+const TRANSACTION_CATEGORY_LABELS = {
+ deposit: 'Deposit',
+ full_payment: 'Full Payment',
+ partial: 'Partial Payment',
+ advance: 'Advance',
+ other: 'Other',
+}
+
+export function RecordPaymentModal({ open, customerId, orders = [], onClose, onSuccess }) {
+ const { toast } = useToast()
+ const { user } = useAuth()
+
+ const emptyForm = () => ({
+ date: new Date().toISOString(),
+ flow: 'payment',
+ payment_type: 'bank_transfer',
+ category: 'full_payment',
+ amount: '',
+ currency: 'EUR',
+ invoice_ref: '',
+ order_ref: '',
+ note: '',
+ })
+
+ const [form, setForm] = useState(emptyForm)
+ const [saving, setSaving] = useState(false)
+ const set = (k, v) => setForm(f => ({ ...f, [k]: v }))
+
+ useEffect(() => { if (open) setForm(emptyForm()) }, [open])
+
+ const handleConfirm = async () => {
+ if (!form.amount) { toast.warning('Required', 'Amount is required.'); return }
+ setSaving(true)
+ try {
+ await api.post(`/crm/customers/${customerId}/transactions`, {
+ ...form,
+ amount: parseFloat(form.amount) || 0,
+ date: form.date ? new Date(form.date).toISOString() : new Date().toISOString(),
+ invoice_ref: form.invoice_ref || null,
+ order_ref: form.order_ref || null,
+ payment_type: form.flow === 'invoice' ? null : (form.payment_type || null),
+ recorded_by: user?.display_name || user?.name || 'Staff',
+ note: form.note || '',
+ })
+ toast.success('Recorded', 'Transaction saved successfully.')
+ onSuccess?.()
+ onClose()
+ } catch (err) {
+ toast.danger('Failed', err.message || 'Could not record payment.')
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ return (
+
+ Cancel
+ Record
+
+ }
+ >
+
+
+ set('date', iso)}
+ />
+ set('flow', e.target.value)}
+ >
+ {Object.entries(FLOW_LABELS).map(([v, l]) => {l} )}
+
+
+ {form.flow !== 'invoice' && (
+ set('payment_type', e.target.value)}
+ >
+ {Object.entries(PAYMENT_TYPE_LABELS).map(([v, l]) => {l} )}
+
+ )}
+
+ set('category', e.target.value)}
+ >
+ {Object.entries(TRANSACTION_CATEGORY_LABELS).map(([v, l]) => {l} )}
+
+
+ set('amount', e.target.value)}
+ placeholder="0.00"
+ required
+ inputProps={{ min: '0', step: '0.01', autoFocus: true }}
+ />
+ set('currency', e.target.value)}
+ >
+ {['EUR', 'USD', 'GBP', 'CHF'].map(c => {c} )}
+
+
+ set('invoice_ref', e.target.value)}
+ placeholder="INV-2026-001"
+ />
+ set('order_ref', e.target.value)}
+ >
+ — None —
+ {orders.map(o => (
+
+ {o.order_number || o.id.slice(0, 8)}{o.title ? ` — ${o.title}` : ''}
+
+ ))}
+
+
+
+
set('note', e.target.value)}
+ placeholder="Optional note..."
+ rows={2}
+ />
+
+
+ )
+}
+
+// ─── Add Files Modal ──────────────────────────────────────────────────────────
+
+export function AddFilesModal({ open, customerId, onClose, onSuccess }) {
+ const { toast } = useToast()
+ // Placeholder — full implementation will hook into Nextcloud upload flow
+ // The actual file upload UI is built in the Files & Media tab
+
+ return (
+
+ Close
+
+ }
+ >
+
+
+
+
+ File Upload
+
+
+ Open the Files & Media tab to manage and upload files for this customer.
+
+
+
{ onClose(); onSuccess?.() }}>
+ Go to Files & Media
+
+
+
+ )
+}
diff --git a/frontend/src/modals/crm/SelectCustomerModal.jsx b/frontend/src/modals/crm/SelectCustomerModal.jsx
new file mode 100644
index 0000000..341d841
--- /dev/null
+++ b/frontend/src/modals/crm/SelectCustomerModal.jsx
@@ -0,0 +1,144 @@
+// frontend/src/modals/crm/SelectCustomerModal.jsx
+// Pick a customer before creating a new quotation
+
+import { useState, useEffect } from 'react'
+import api from '@/lib/api'
+import Modal from '@/components/ui/Modal'
+import Button from '@/components/ui/Button'
+import SearchBar from '@/components/ui/SearchBar'
+import Spinner from '@/components/ui/Spinner'
+
+export default function SelectCustomerModal({ onSelect, onClose }) {
+ const [search, setSearch] = useState('')
+ const [allCustomers, setAll] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [selected, setSelected] = useState(null)
+
+ useEffect(() => {
+ api.get('/crm/customers')
+ .then(res => setAll(Array.isArray(res) ? res : (res.customers || [])))
+ .catch(() => setAll([]))
+ .finally(() => setLoading(false))
+ }, [])
+
+ const filtered = search.trim()
+ ? allCustomers.filter(c => {
+ const s = search.toLowerCase()
+ const name = [c.title, c.name, c.surname].filter(Boolean).join(' ').toLowerCase()
+ const org = (c.organization || '').toLowerCase()
+ return name.includes(s) || org.includes(s)
+ })
+ : allCustomers
+
+ function handleConfirm() {
+ if (selected) onSelect(selected)
+ }
+
+ return (
+
+
+ Cancel
+
+
+ Continue
+
+
+ }
+ >
+
+
+
+
+ {loading && (
+
+
+
+ )}
+
+ {!loading && filtered.length === 0 && (
+
+ {search.trim() ? 'No customers match your search' : 'No customers found'}
+
+ )}
+
+ {!loading && filtered.map((c, i) => {
+ const name = [c.title, c.name, c.surname].filter(Boolean).join(' ')
+ const isActive = selected?.id === c.id
+ return (
+
setSelected(c)}
+ onKeyDown={e => e.key === 'Enter' && setSelected(c)}
+ onDoubleClick={() => { setSelected(c); onSelect(c) }}
+ style={{
+ padding: 'var(--space-3) var(--space-4)',
+ cursor: 'pointer',
+ borderBottom: i < filtered.length - 1 ? '1px solid var(--color-border)' : 'none',
+ backgroundColor: isActive ? 'var(--color-primary-subtle)' : 'transparent',
+ borderLeft: isActive ? '2px solid var(--color-primary)' : '2px solid transparent',
+ transition: 'background-color 0.1s',
+ display: 'flex',
+ flexDirection: 'column',
+ gap: 'var(--space-1)',
+ }}
+ onMouseEnter={e => { if (!isActive) e.currentTarget.style.backgroundColor = 'var(--color-bg-elevated)' }}
+ onMouseLeave={e => { if (!isActive) e.currentTarget.style.backgroundColor = 'transparent' }}
+ >
+
+ {c.organization || name || '—'}
+
+ {c.organization && name && (
+
+ {name}
+
+ )}
+
+ )
+ })}
+
+
+ {selected && (
+
+ Selected:
+ {selected.organization || [selected.title, selected.name, selected.surname].filter(Boolean).join(' ')}
+
+
+ )}
+
+
+ )
+}
diff --git a/frontend/src/modals/crm/customers/IssueModal.jsx b/frontend/src/modals/crm/customers/IssueModal.jsx
new file mode 100644
index 0000000..7b3160f
--- /dev/null
+++ b/frontend/src/modals/crm/customers/IssueModal.jsx
@@ -0,0 +1,89 @@
+// frontend/src/modals/crm/customers/IssueModal.jsx
+// Add or edit a technical issue or install-support entry
+
+import { useState, useEffect } from 'react'
+import Modal from '@/components/ui/Modal'
+import Button from '@/components/ui/Button'
+import FormField from '@/components/ui/FormField'
+import DateTimePicker from '@/components/ui/DateTimePicker'
+import { useToast } from '@/components/ui/Toast'
+import api from '@/lib/api'
+
+// type: 'issue' → /technical-issues, 'support' → /install-support
+const ENDPOINT = { issue: 'technical-issues', support: 'install-support' }
+
+export default function IssueModal({ open, customerId, type, editIndex, initialData, onClose, user }) {
+ const { toast } = useToast()
+ const [form, setForm] = useState({ note: '', date: new Date().toISOString() })
+ const [saving, setSaving] = useState(false)
+
+ const set = (k, v) => setForm(f => ({ ...f, [k]: v }))
+
+ useEffect(() => {
+ if (!open) return
+ if (initialData) {
+ setForm({
+ note: initialData.note || '',
+ date: initialData.opened_date || new Date().toISOString(),
+ })
+ } else {
+ setForm({ note: '', date: new Date().toISOString() })
+ }
+ }, [open, initialData])
+
+ const endpoint = ENDPOINT[type] || 'technical-issues'
+ const isEditing = editIndex != null
+
+ const handleSave = async () => {
+ if (!form.note.trim()) { toast.warning('Required', 'Please describe the issue.'); return }
+ setSaving(true)
+ try {
+ let updated
+ if (isEditing) {
+ updated = await api.patch(`/crm/customers/${customerId}/${endpoint}/${editIndex}`, {
+ note: form.note.trim(),
+ opened_date: new Date(form.date).toISOString(),
+ })
+ } else {
+ updated = await api.post(`/crm/customers/${customerId}/${endpoint}`, {
+ note: form.note.trim(),
+ opened_by: user?.display_name || user?.name || 'Staff',
+ date: new Date(form.date).toISOString(),
+ })
+ }
+ toast.success('Saved', isEditing ? 'Entry updated.' : 'Entry recorded.')
+ onClose(updated)
+ } catch (err) {
+ toast.danger('Error', err.message || 'Failed to save entry.')
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ const titleMap = {
+ issue: isEditing ? 'Edit Technical Issue' : 'Report Technical Issue',
+ support: isEditing ? 'Edit Install Support' : 'Report Install Support',
+ }
+
+ return (
+
+ Cancel
+
+ {isEditing ? 'Update' : 'Submit'}
+
+ >
+ }
+ >
+
+ set('date', iso)} />
+ set('note', e.target.value)} placeholder="Describe the issue in detail…" />
+
+
+ )
+}
diff --git a/frontend/src/modals/crm/customers/NoteModal.jsx b/frontend/src/modals/crm/customers/NoteModal.jsx
new file mode 100644
index 0000000..8a2ce20
--- /dev/null
+++ b/frontend/src/modals/crm/customers/NoteModal.jsx
@@ -0,0 +1,80 @@
+// frontend/src/modals/crm/customers/NoteModal.jsx
+// Add or edit a staff note on a customer record
+
+import { useState, useEffect } from 'react'
+import Modal from '@/components/ui/Modal'
+import Button from '@/components/ui/Button'
+import FormField from '@/components/ui/FormField'
+import { useToast } from '@/components/ui/Toast'
+import { useAuth } from '@/hooks/useAuth'
+import api from '@/lib/api'
+
+// onClose() — cancel / close without saving
+// onSaved(updated) — save succeeded, pass the updated customer object
+export default function NoteModal({ open, customer, editIndex, initialData, onClose, onSaved }) {
+ const toast = useToast()
+ const { user } = useAuth()
+ const [text, setText] = useState('')
+ const [saving, setSaving] = useState(false)
+
+ const isEdit = editIndex != null
+
+ useEffect(() => {
+ if (!open) return
+ setText(isEdit ? (initialData?.text || initialData?.content || initialData?.body || '') : '')
+ }, [open, isEdit, initialData])
+
+ const handleSave = async () => {
+ if (!text.trim()) return
+ setSaving(true)
+ try {
+ const existingNotes = customer.notes || []
+ const by = user?.display_name || user?.name || 'Staff'
+ const now = new Date().toISOString()
+
+ let updatedNotes
+ if (isEdit) {
+ updatedNotes = existingNotes.map((n, i) =>
+ i === editIndex ? { ...n, text: text.trim(), by, at: now } : n
+ )
+ } else {
+ updatedNotes = [...existingNotes, { text: text.trim(), by, at: now }]
+ }
+
+ const updated = await api.put(`/crm/customers/${customer.id}`, { notes: updatedNotes })
+ toast.success(isEdit ? 'Note updated' : 'Note added', '')
+ onSaved(updated)
+ } catch (err) {
+ toast.danger('Error', err.message || 'Failed to save note.')
+ setSaving(false)
+ }
+ }
+
+ return (
+
+ onClose(null)} disabled={saving}>
+ Cancel
+
+
+ {saving ? 'Saving…' : isEdit ? 'Save Changes' : 'Add Note'}
+
+ >
+ }
+ >
+ setText(e.target.value)}
+ placeholder="Write your note here… (newlines are preserved)"
+ rows={6}
+ autoFocus
+ />
+
+ )
+}
diff --git a/frontend/src/modals/crm/customers/OrderModal.jsx b/frontend/src/modals/crm/customers/OrderModal.jsx
new file mode 100644
index 0000000..173650f
--- /dev/null
+++ b/frontend/src/modals/crm/customers/OrderModal.jsx
@@ -0,0 +1,91 @@
+// frontend/src/modals/crm/customers/OrderModal.jsx
+// Edit an existing order's basic details (number, title, status, notes)
+
+import { useState, useEffect } from 'react'
+import Modal from '@/components/ui/Modal'
+import Button from '@/components/ui/Button'
+import FormField from '@/components/ui/FormField'
+import { useToast } from '@/components/ui/Toast'
+import api from '@/lib/api'
+
+const ORDER_STATUS_OPTIONS = [
+ { value: 'negotiating', label: 'Negotiating' },
+ { value: 'awaiting_quotation', label: 'Awaiting Quotation' },
+ { value: 'awaiting_customer_confirmation', label: 'Awaiting Confirmation' },
+ { value: 'awaiting_fulfilment', label: 'Accepted - Waiting' },
+ { value: 'awaiting_payment', label: 'Awaiting Payment' },
+ { value: 'manufacturing', label: 'Manufacturing' },
+ { value: 'shipped', label: 'Shipped' },
+ { value: 'installed', label: 'Installed' },
+ { value: 'declined', label: 'Declined' },
+ { value: 'complete', label: 'Complete' },
+]
+
+export default function OrderModal({ open, customerId, order, onClose, onSaved, user }) {
+ const { toast } = useToast()
+ const [form, setForm] = useState({ order_number: '', title: '', status: 'negotiating', notes: '' })
+ const [saving, setSaving] = useState(false)
+
+ const set = (k, v) => setForm(f => ({ ...f, [k]: v }))
+
+ useEffect(() => {
+ if (!open || !order) return
+ setForm({
+ order_number: order.order_number || '',
+ title: order.title || '',
+ status: order.status || 'negotiating',
+ notes: order.notes || '',
+ })
+ }, [open, order])
+
+ const handleSave = async () => {
+ setSaving(true)
+ try {
+ await api.patch(`/crm/customers/${customerId}/orders/${order.id}`, {
+ order_number: form.order_number || null,
+ title: form.title || null,
+ status: form.status,
+ status_updated_date: new Date().toISOString(),
+ status_updated_by: user?.display_name || user?.name || 'Staff',
+ notes: form.notes || null,
+ })
+ toast.success('Saved', 'Order updated successfully.')
+ onSaved?.()
+ onClose()
+ } catch (err) {
+ toast.danger('Error', err.message || 'Failed to save order.')
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ return (
+
+ Cancel
+ Save Changes
+ >
+ }
+ >
+
+
+
set('order_number', e.target.value)} placeholder="e.g. ORD-2026-001" />
+ set('title', e.target.value)} placeholder="Order title…" />
+
+ set('status', e.target.value)}>
+ {ORDER_STATUS_OPTIONS.map(o => {o.label} )}
+
+
+
+ set('notes', e.target.value)} placeholder="Internal notes…" />
+
+
+
+
+ )
+}
diff --git a/frontend/src/modals/crm/customers/OrderStatusModal.jsx b/frontend/src/modals/crm/customers/OrderStatusModal.jsx
new file mode 100644
index 0000000..4e913ad
--- /dev/null
+++ b/frontend/src/modals/crm/customers/OrderStatusModal.jsx
@@ -0,0 +1,176 @@
+// frontend/src/modals/crm/customers/OrderStatusModal.jsx
+// Update an order's status — archives current status as timeline event, sets new one
+
+import { useState, useEffect } from 'react'
+import Modal from '@/components/ui/Modal'
+import Button from '@/components/ui/Button'
+import FormField from '@/components/ui/FormField'
+import DateTimePicker from '@/components/ui/DateTimePicker'
+import StatusBadge from '@/components/ui/StatusBadge'
+import { useToast } from '@/components/ui/Toast'
+import api from '@/lib/api'
+
+const ORDER_STATUS_OPTIONS = [
+ { value: 'negotiating', label: 'Negotiating' },
+ { value: 'awaiting_quotation', label: 'Awaiting Quotation' },
+ { value: 'awaiting_customer_confirmation', label: 'Awaiting Confirmation' },
+ { value: 'awaiting_fulfilment', label: 'Accepted - Waiting' },
+ { value: 'awaiting_payment', label: 'Awaiting Payment' },
+ { value: 'manufacturing', label: 'Manufacturing' },
+ { value: 'shipped', label: 'Shipped' },
+ { value: 'installed', label: 'Installed' },
+ { value: 'declined', label: 'Declined' },
+ { value: 'complete', label: 'Complete' },
+]
+
+const STATUS_DEFAULT_NOTES = {
+ negotiating: 'Just started Negotiating with the customer on a possible new order',
+ awaiting_quotation: 'We agreed on what the customer needs, and currently drafting a Quote for them',
+ awaiting_customer_confirmation: 'The Quotation has been sent to the Customer. Awaiting their Confirmation',
+ awaiting_fulfilment: 'Customer has accepted the Quotation, and no further action is needed from them. First Chance possible we are going to build their device',
+ awaiting_payment: 'Customer has accepted the Quotation, but a payment/advance is due before we proceed',
+ manufacturing: 'We have begun manufacturing the Customer\'s Device',
+ shipped: 'The order has been Shipped ! Awaiting Customer Feedback',
+ installed: 'Customer has informed us that the device has been successfully Installed !',
+ declined: 'Customer sadly declined our offer',
+ complete: 'Customer has successfully installed, and operated their product. No further action needed ! The order is complete !',
+}
+
+const STATUS_TO_TIMELINE_TYPE = {
+ negotiating: 'negotiations_started',
+ awaiting_quotation: 'quote_request',
+ awaiting_customer_confirmation: 'quote_sent',
+ awaiting_fulfilment: 'quote_accepted',
+ awaiting_payment: 'invoice_sent',
+ manufacturing: 'mfg_started',
+ shipped: 'order_shipped',
+ installed: 'installed',
+ declined: 'note',
+ complete: 'payment_received',
+}
+
+const ORDER_STATUS_VARIANT = {
+ negotiating: 'neutral', awaiting_quotation: 'warning',
+ awaiting_customer_confirmation: 'info', awaiting_fulfilment: 'info',
+ awaiting_payment: 'warning', manufacturing: 'warning',
+ shipped: 'info', installed: 'success', declined: 'danger', complete: 'success',
+}
+
+export default function OrderStatusModal({ open, customerId, order, onClose, onSaved, user }) {
+ const { toast } = useToast()
+ const [form, setForm] = useState({ newStatus: 'negotiating', title: '', note: '', datetime: new Date().toISOString() })
+ const [saving, setSaving] = useState(false)
+
+ const set = (k, v) => setForm(f => ({ ...f, [k]: v }))
+
+ useEffect(() => {
+ if (!open || !order) return
+ const next = order.status || 'negotiating'
+ setForm({
+ newStatus: next,
+ title: order.title || '',
+ note: STATUS_DEFAULT_NOTES[next] || '',
+ datetime: new Date().toISOString(),
+ })
+ }, [open, order])
+
+ const handleStatusChange = (newStatus) => {
+ setForm(f => ({ ...f, newStatus, note: STATUS_DEFAULT_NOTES[newStatus] || '' }))
+ }
+
+ const handleSave = async () => {
+ setSaving(true)
+ try {
+ // Archive current status as timeline event (if not already archived)
+ const existingMatch = (order.timeline || []).some(
+ e => e.archived_status === order.status && e.date === (order.status_updated_date || order.created_at)
+ )
+ if (!existingMatch) {
+ const tlType = STATUS_TO_TIMELINE_TYPE[order.status] || 'note'
+ await api.post(`/crm/customers/${customerId}/orders/${order.id}/timeline`, {
+ type: tlType,
+ note: order.notes || '',
+ date: order.status_updated_date || order.created_at || new Date().toISOString(),
+ updated_by: order.status_updated_by || order.created_by || 'System',
+ archived_status: order.status,
+ })
+ }
+
+ await api.patch(`/crm/customers/${customerId}/orders/${order.id}`, {
+ status: form.newStatus,
+ title: form.title || order.title || null,
+ status_updated_date: new Date(form.datetime).toISOString(),
+ status_updated_by: user?.display_name || user?.name || 'Staff',
+ notes: form.note,
+ })
+
+ toast.success('Status updated', `Order moved to "${ORDER_STATUS_OPTIONS.find(o => o.value === form.newStatus)?.label}".`)
+ onSaved?.()
+ onClose()
+ } catch (err) {
+ toast.danger('Error', err.message || 'Failed to update status.')
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ if (!order) return null
+
+ const currentVariant = ORDER_STATUS_VARIANT[order.status] || 'neutral'
+ const nextVariant = ORDER_STATUS_VARIANT[form.newStatus] || 'neutral'
+
+ return (
+
+ Cancel
+ Update Status
+ >
+ }
+ >
+
+ {/* Current → New status indicator */}
+
+
+ {ORDER_STATUS_OPTIONS.find(o => o.value === order.status)?.label || order.status}
+
+
+
+
+
+ {ORDER_STATUS_OPTIONS.find(o => o.value === form.newStatus)?.label || form.newStatus}
+
+
+
+
+
handleStatusChange(e.target.value)}>
+ {ORDER_STATUS_OPTIONS.map(o => {o.label} )}
+
+
set('datetime', iso)} />
+
+ set('title', e.target.value)} placeholder="Update title (optional)…" />
+
+
+ set('note', e.target.value)} placeholder="Note about this status change…" />
+
+
+
+
+ The current status will be archived as a timeline event before being replaced.
+
+
+
+ )
+}
diff --git a/frontend/src/modals/crm/customers/TimelineEventModal.jsx b/frontend/src/modals/crm/customers/TimelineEventModal.jsx
new file mode 100644
index 0000000..39d90e7
--- /dev/null
+++ b/frontend/src/modals/crm/customers/TimelineEventModal.jsx
@@ -0,0 +1,99 @@
+// frontend/src/modals/crm/customers/TimelineEventModal.jsx
+// Add or edit a timeline event on an order
+
+import { useState, useEffect } from 'react'
+import Modal from '@/components/ui/Modal'
+import Button from '@/components/ui/Button'
+import FormField from '@/components/ui/FormField'
+import DateTimePicker from '@/components/ui/DateTimePicker'
+import { useToast } from '@/components/ui/Toast'
+import api from '@/lib/api'
+
+const TIMELINE_TYPE_OPTIONS = [
+ { value: 'negotiations_started', label: 'Started Negotiations' },
+ { value: 'quote_request', label: 'Quote Requested' },
+ { value: 'quote_sent', label: 'Quote Sent' },
+ { value: 'quote_accepted', label: 'Quote Accepted' },
+ { value: 'quote_declined', label: 'Quote Declined' },
+ { value: 'mfg_started', label: 'Manufacturing Started' },
+ { value: 'mfg_complete', label: 'Manufacturing Complete' },
+ { value: 'order_shipped', label: 'Order Shipped' },
+ { value: 'installed', label: 'Installed' },
+ { value: 'payment_received', label: 'Payment Received' },
+ { value: 'invoice_sent', label: 'Invoice Sent' },
+ { value: 'note', label: 'Note' },
+]
+
+export default function TimelineEventModal({ open, customerId, orderId, editIndex, initialData, onClose, onSaved, user }) {
+ const { toast } = useToast()
+ const [form, setForm] = useState({ type: 'note', note: '', date: new Date().toISOString() })
+ const [saving, setSaving] = useState(false)
+
+ const set = (k, v) => setForm(f => ({ ...f, [k]: v }))
+
+ useEffect(() => {
+ if (!open) return
+ if (initialData) {
+ setForm({
+ type: initialData.type || 'note',
+ note: initialData.note || '',
+ date: initialData.date || new Date().toISOString(),
+ })
+ } else {
+ setForm({ type: 'note', note: '', date: new Date().toISOString() })
+ }
+ }, [open, initialData])
+
+ const handleSave = async () => {
+ if (!form.type) { toast.warning('Required', 'Event type is required.'); return }
+ setSaving(true)
+ try {
+ const payload = {
+ type: form.type,
+ note: form.note,
+ date: form.date ? new Date(form.date).toISOString() : new Date().toISOString(),
+ updated_by: user?.display_name || user?.name || 'Staff',
+ }
+ if (editIndex !== undefined && editIndex !== null) {
+ await api.patch(`/crm/customers/${customerId}/orders/${orderId}/timeline/${editIndex}`, payload)
+ toast.success('Updated', 'Timeline event updated.')
+ } else {
+ await api.post(`/crm/customers/${customerId}/orders/${orderId}/timeline`, payload)
+ toast.success('Added', 'Timeline event recorded.')
+ }
+ onSaved?.()
+ onClose()
+ } catch (err) {
+ toast.danger('Error', err.message || 'Failed to save event.')
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ const isEditing = editIndex != null
+
+ return (
+
+ Cancel
+
+ {isEditing ? 'Update Event' : 'Add Event'}
+
+ >
+ }
+ >
+
+ set('type', e.target.value)}>
+ {TIMELINE_TYPE_OPTIONS.map(o => {o.label} )}
+
+ set('date', iso)} />
+ set('note', e.target.value)} placeholder="Describe what happened…" />
+
+
+ )
+}
diff --git a/frontend/src/modals/crm/customers/TransactionModal.jsx b/frontend/src/modals/crm/customers/TransactionModal.jsx
new file mode 100644
index 0000000..b2c2d2b
--- /dev/null
+++ b/frontend/src/modals/crm/customers/TransactionModal.jsx
@@ -0,0 +1,190 @@
+// frontend/src/modals/crm/customers/TransactionModal.jsx
+// Record or edit a financial transaction for a customer
+
+import { useState, useEffect } from 'react'
+import Modal from '@/components/ui/Modal'
+import Button from '@/components/ui/Button'
+import FormField from '@/components/ui/FormField'
+import DateTimePicker from '@/components/ui/DateTimePicker'
+import { useToast } from '@/components/ui/Toast'
+import { useAuth } from '@/hooks/useAuth'
+import api from '@/lib/api'
+
+const FLOW_OPTIONS = [
+ { value: 'payment', label: 'Payment' },
+ { value: 'invoice', label: 'Invoice' },
+ { value: 'refund', label: 'Refund' },
+ { value: 'credit', label: 'Credit' },
+]
+
+const PAYMENT_TYPE_OPTIONS = [
+ { value: 'cash', label: 'Cash' },
+ { value: 'bank_transfer', label: 'Bank Transfer' },
+ { value: 'card', label: 'Card' },
+ { value: 'paypal', label: 'PayPal' },
+]
+
+const CATEGORY_OPTIONS = [
+ { value: 'full_payment', label: 'Full Payment' },
+ { value: 'advance', label: 'Advance' },
+ { value: 'installment', label: 'Installment' },
+]
+
+
+function emptyForm(user) {
+ return {
+ date: new Date().toISOString(),
+ flow: 'payment',
+ payment_type: 'cash',
+ category: 'full_payment',
+ amount: '',
+ currency: 'EUR',
+ invoice_ref: '',
+ order_ref: '',
+ recorded_by: user?.display_name || user?.name || '',
+ note: '',
+ }
+}
+
+export default function TransactionModal({ open, customerId, orders, initialData, editIndex, outstandingBalance, onClose, onSaved }) {
+ const { toast } = useToast()
+ const { user } = useAuth()
+ const [form, setForm] = useState(() => emptyForm(user))
+ const [saving, setSaving] = useState(false)
+
+ const set = (k, v) => setForm(f => ({ ...f, [k]: v }))
+
+ useEffect(() => {
+ if (!open) return
+ if (initialData) {
+ setForm({
+ ...emptyForm(user),
+ ...initialData,
+ date: initialData.date || new Date().toISOString(),
+ })
+ } else {
+ setForm(emptyForm(user))
+ }
+ }, [open, initialData])
+
+ const isInvoice = form.flow === 'invoice'
+ const isFullPayment = form.flow === 'payment' && form.category === 'full_payment'
+
+ // Auto-fill amount for full payment
+ useEffect(() => {
+ if (initialData) return
+ if (!isFullPayment) { set('amount', ''); return }
+ if (form.order_ref) {
+ const order = (orders || []).find(o => o.id === form.order_ref)
+ const due = order?.payment_status?.balance_due ?? 0
+ if (due > 0) set('amount', Number(due).toFixed(2))
+ } else if (outstandingBalance != null && outstandingBalance > 0) {
+ set('amount', outstandingBalance.toFixed(2))
+ }
+ }, [isFullPayment, form.order_ref])
+
+ const handleSave = async () => {
+ const parsedAmount = parseFloat(form.amount)
+ if (!form.amount || !form.flow) {
+ toast.warning('Required', 'Amount and flow type are required.')
+ return
+ }
+ if (isNaN(parsedAmount) || parsedAmount < 0) {
+ toast.warning('Invalid Amount', 'Amount must be 0 or greater.')
+ return
+ }
+ if (!isInvoice && !form.category) {
+ toast.warning('Required', 'Category is required.')
+ return
+ }
+
+ setSaving(true)
+ try {
+ const payload = {
+ ...form,
+ amount: parsedAmount,
+ date: form.date ? new Date(form.date).toISOString() : new Date().toISOString(),
+ invoice_ref: form.invoice_ref || null,
+ order_ref: form.order_ref || null,
+ payment_type: isInvoice ? null : (form.payment_type || null),
+ category: isInvoice ? 'full_payment' : (form.category || 'full_payment'),
+ }
+ let updated
+ if (editIndex !== undefined && editIndex !== null) {
+ updated = await api.patch(`/crm/customers/${customerId}/transactions/${editIndex}`, payload)
+ } else {
+ updated = await api.post(`/crm/customers/${customerId}/transactions`, payload)
+ }
+ toast.success('Saved', editIndex != null ? 'Transaction updated.' : 'Transaction recorded.')
+ onSaved?.(updated)
+ onClose()
+ } catch (err) {
+ toast.danger('Error', err.message || 'Failed to save transaction.')
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ const isEditing = editIndex != null
+
+ return (
+
+ Cancel
+
+ {isEditing ? 'Update' : 'Save Transaction'}
+
+ >
+ }
+ >
+
+
+
set('date', iso)} />
+ set('flow', e.target.value)}>
+ {FLOW_OPTIONS.map(o => {o.label} )}
+
+
+ {!isInvoice && (
+ set('payment_type', e.target.value)}>
+ —
+ {PAYMENT_TYPE_OPTIONS.map(o => {o.label} )}
+
+ )}
+
+ {!isInvoice && (
+ set('category', e.target.value)}>
+ {CATEGORY_OPTIONS.map(o => {o.label} )}
+
+ )}
+
+ set('amount', e.target.value)} placeholder="0.00" inputProps={{ min: 0, step: '0.01' }} />
+ set('currency', e.target.value)}>
+ {['EUR', 'USD', 'GBP'].map(c => {c} )}
+
+
+ set('invoice_ref', e.target.value)} placeholder="e.g. INV-2026-001" />
+ set('order_ref', e.target.value)}>
+ — None —
+ {(orders || []).map(o => (
+
+ {o.order_number || o.id.slice(0, 8)}{o.title ? ` — ${o.title}` : ''}
+
+ ))}
+
+
+
+ set('recorded_by', e.target.value)} />
+
+
+ set('note', e.target.value)} placeholder="Optional note…" />
+
+
+
+
+ )
+}
diff --git a/frontend/src/modals/crm/helpdesk/CreateTicketModal.jsx b/frontend/src/modals/crm/helpdesk/CreateTicketModal.jsx
new file mode 100644
index 0000000..a3fdb25
--- /dev/null
+++ b/frontend/src/modals/crm/helpdesk/CreateTicketModal.jsx
@@ -0,0 +1,726 @@
+// frontend/src/modals/crm/helpdesk/CreateTicketModal.jsx
+// Create a new support ticket. Staff can open on behalf of a customer.
+
+import { useState, useEffect, useRef, useCallback } from 'react'
+import { createPortal } from 'react-dom'
+import api from '@/lib/api'
+import { useAuth } from '@/hooks/useAuth'
+import { useToast } from '@/components/ui/Toast'
+import Modal from '@/components/ui/Modal'
+import Button from '@/components/ui/Button'
+import FormField from '@/components/ui/FormField'
+import Spinner from '@/components/ui/Spinner'
+
+const PRIORITY_OPTIONS = [
+ { value: '', label: 'No priority' },
+ { value: 'low', label: 'Low' },
+ { value: 'medium', label: 'Medium' },
+ { value: 'high', label: 'High' },
+ { value: 'urgent', label: 'Urgent' },
+]
+
+const OPENED_VIA_OPTIONS = [
+ { value: 'staff', label: 'Staff (opened by us)' },
+ { value: 'phone', label: 'Phone' },
+ { value: 'email', label: 'Email' },
+ { value: 'app', label: 'App' },
+]
+
+// ─── Magnifying glass SVG ─────────────────────────────────────────────────────
+
+const IconSearch = () => (
+
+
+
+
+)
+
+// ─── BrowseModal ──────────────────────────────────────────────────────────────
+// Generic browse-all list modal for any entity type.
+
+function BrowseModal({ open, entityType, onPick, onClose }) {
+ const [items, setItems] = useState([])
+ const [loading, setLoading] = useState(false)
+ const [search, setSearch] = useState('')
+ const debounceRef = useRef(null)
+
+ const load = useCallback(async (q = '') => {
+ setLoading(true)
+ try {
+ let data
+ const qs = q ? `?search=${encodeURIComponent(q)}&limit=50` : '?limit=50'
+ if (entityType === 'customer') {
+ data = await api.get(`/crm/customers${qs}`)
+ setItems(data.customers || data.data || [])
+ } else if (entityType === 'device') {
+ data = await api.get(`/devices${qs}`)
+ setItems(data.devices || data.data || [])
+ } else if (entityType === 'app_user') {
+ data = await api.get(`/users${qs}`)
+ setItems(data.users || data.data || [])
+ }
+ } catch { /* silent */ } finally {
+ setLoading(false)
+ }
+ }, [entityType])
+
+ useEffect(() => { if (open) { setSearch(''); load() } }, [open, load])
+
+ const handleSearch = (e) => {
+ const q = e.target.value
+ setSearch(q)
+ clearTimeout(debounceRef.current)
+ debounceRef.current = setTimeout(() => load(q), 300)
+ }
+
+ const typeLabel = { customer: 'Customers', device: 'Devices', app_user: 'Users' }[entityType] || entityType
+
+ const getLines = (item) => {
+ if (entityType === 'customer') {
+ const city = item.location?.city || item.city || ''
+ return {
+ primary: [item.surname, item.name].filter(Boolean).join(' ') || item.id,
+ secondary: [item.organization, city].filter(Boolean).join(' · '),
+ }
+ }
+ if (entityType === 'device') {
+ return {
+ primary: item.device_name || item.id,
+ secondary: item.serial_number || '',
+ }
+ }
+ // app_user
+ return {
+ primary: item.display_name || item.email || item.id,
+ secondary: item.email || '',
+ }
+ }
+
+ const getPickData = (item) => {
+ const lines = getLines(item)
+ return { id: item.id, name: lines.primary, sub: lines.secondary }
+ }
+
+ return (
+
+
+
+
+ {loading ? (
+
+ ) : items.length === 0 ? (
+
+ No {typeLabel.toLowerCase()} found.
+
+ ) : (
+ items.map((item, i) => {
+ const lines = getLines(item)
+ return (
+
{ onPick(getPickData(item)); onClose() }}
+ style={{
+ display: 'flex', width: '100%', textAlign: 'left',
+ padding: 'var(--space-3) var(--space-4)',
+ background: 'none', border: 'none', cursor: 'pointer',
+ borderBottom: i < items.length - 1 ? '1px solid var(--color-border)' : 'none',
+ transition: 'background 0.1s',
+ }}
+ onMouseEnter={e => e.currentTarget.style.background = 'var(--color-bg-island)'}
+ onMouseLeave={e => e.currentTarget.style.background = 'none'}
+ >
+
+
+ {lines.primary || '—'}
+
+ {lines.secondary && (
+
+ {lines.secondary}
+
+ )}
+
+
+ )
+ })
+ )}
+
+
+
+ )
+}
+
+// ─── CustomerSearchInput ──────────────────────────────────────────────────────
+// Searchable customer picker with 2-line display + browse button.
+
+function CustomerSearchInput({ value, displayName, displaySub, onChange, onBrowse }) {
+ const [query, setQuery] = useState('')
+ const [results, setResults] = useState([])
+ const [searching, setSearching] = useState(false)
+ const [open, setOpen] = useState(false)
+ const [dropPos, setDropPos] = useState({ top: 0, left: 0, width: 0 })
+ const debounceRef = useRef(null)
+ const wrapRef = useRef(null)
+ const inputWrapRef = useRef(null)
+
+ useEffect(() => {
+ const handler = (e) => {
+ if (wrapRef.current && !wrapRef.current.contains(e.target)) setOpen(false)
+ }
+ document.addEventListener('mousedown', handler)
+ return () => document.removeEventListener('mousedown', handler)
+ }, [])
+
+ useEffect(() => { setQuery('') }, [value])
+
+ const updateDropPos = () => {
+ if (inputWrapRef.current) {
+ const r = inputWrapRef.current.getBoundingClientRect()
+ setDropPos({ top: r.bottom + 4, left: r.left, width: r.width })
+ }
+ }
+
+ const search = useCallback(async (q) => {
+ if (!q.trim()) { setResults([]); setOpen(false); return }
+ setSearching(true)
+ try {
+ const data = await api.get(`/crm/customers?search=${encodeURIComponent(q)}&limit=10`)
+ setResults(data.customers || data.data || [])
+ updateDropPos()
+ setOpen(true)
+ } catch { /* silent */ } finally {
+ setSearching(false)
+ }
+ }, [])
+
+ const handleInput = (e) => {
+ const q = e.target.value
+ setQuery(q)
+ if (!q.trim()) { onChange(null); setResults([]); setOpen(false); return }
+ clearTimeout(debounceRef.current)
+ debounceRef.current = setTimeout(() => search(q), 280)
+ }
+
+ const pick = (c) => {
+ const name = [c.surname, c.name].filter(Boolean).join(' ') || c.id
+ const city = c.location?.city || c.city || ''
+ const subLine = [c.organization, city].filter(Boolean).join(' · ')
+ onChange({ id: c.id, name, subLine })
+ setQuery('')
+ setResults([])
+ setOpen(false)
+ }
+
+ const clear = () => { onChange(null); setQuery(''); setResults([]); setOpen(false) }
+
+ return (
+
+
+ Customer *
+
+
+
e.currentTarget.style.borderColor = 'var(--color-primary)'}
+ onBlurCapture={e => e.currentTarget.style.borderColor = ''}
+ >
+ {value && displayName ? (
+
+
+
+ {displayName}
+
+ {displaySub && (
+
+ {displaySub}
+
+ )}
+
+
×
+
+ ) : (
+ <>
+
+ {searching && (
+
+
+
+ )}
+ >
+ )}
+
+ {/* Browse magnifying glass */}
+
{ e.currentTarget.style.color = 'var(--color-primary)'; e.currentTarget.style.background = 'var(--color-primary-subtle)' }}
+ onMouseLeave={e => { e.currentTarget.style.color = 'var(--color-text-muted)'; e.currentTarget.style.background = 'none' }}
+ >
+
+
+
+
+ {/* Portal dropdown */}
+ {open && results.length > 0 && createPortal(
+
+ {results.map((c, i) => {
+ const name = [c.surname, c.name].filter(Boolean).join(' ') || '—'
+ const city = c.location?.city || c.city || ''
+ const subLine = [c.organization, city].filter(Boolean).join(' · ')
+ return (
+
pick(c)}
+ style={{
+ display: 'block', width: '100%', textAlign: 'left',
+ padding: 'var(--space-3) var(--space-4)',
+ background: 'none', border: 'none', cursor: 'pointer',
+ borderBottom: i < results.length - 1 ? '1px solid var(--color-border)' : 'none',
+ transition: 'background 0.1s',
+ }}
+ onMouseEnter={e => e.currentTarget.style.background = 'var(--color-bg-island)'}
+ onMouseLeave={e => e.currentTarget.style.background = 'none'}
+ >
+
+ {name}
+
+ {subLine && (
+
+ {subLine}
+
+ )}
+
+ )
+ })}
+
,
+ document.body
+ )}
+
+ )
+}
+
+// ─── EntitySearchInput (device / user) ────────────────────────────────────────
+// Reusable inline search for devices or app users, with browse button + portal dropdown.
+
+function EntitySearchInput({ label, entityType, value, displayName, onChange, onBrowse, required }) {
+ const [query, setQuery] = useState('')
+ const [results, setResults] = useState([])
+ const [searching, setSearching] = useState(false)
+ const [open, setOpen] = useState(false)
+ const [dropPos, setDropPos] = useState({ top: 0, left: 0, width: 0 })
+ const debounceRef = useRef(null)
+ const wrapRef = useRef(null)
+ const inputWrapRef = useRef(null)
+
+ useEffect(() => {
+ const handler = (e) => {
+ if (wrapRef.current && !wrapRef.current.contains(e.target)) setOpen(false)
+ }
+ document.addEventListener('mousedown', handler)
+ return () => document.removeEventListener('mousedown', handler)
+ }, [])
+
+ useEffect(() => { setQuery('') }, [value])
+
+ const updateDropPos = () => {
+ if (inputWrapRef.current) {
+ const r = inputWrapRef.current.getBoundingClientRect()
+ setDropPos({ top: r.bottom + 4, left: r.left, width: r.width })
+ }
+ }
+
+ const placeholder = entityType === 'device'
+ ? 'Search by name or serial…'
+ : 'Search by name or email…'
+
+ const getLines = (item) => {
+ if (entityType === 'device') {
+ return {
+ primary: item.device_name || item.id,
+ secondary: item.serial_number || '',
+ }
+ }
+ // app_user
+ return {
+ primary: item.display_name || item.email || item.id,
+ secondary: item.email || '',
+ }
+ }
+
+ const toResult = (item) => {
+ const lines = getLines(item)
+ return { id: item.id, name: lines.primary, sub: lines.secondary }
+ }
+
+ const search = useCallback(async (q) => {
+ if (!q.trim()) { setResults([]); setOpen(false); return }
+ setSearching(true)
+ try {
+ let data
+ if (entityType === 'device') {
+ data = await api.get(`/devices?search=${encodeURIComponent(q)}&limit=8`)
+ setResults((data.devices || data.data || []).map(toResult))
+ } else {
+ data = await api.get(`/users?search=${encodeURIComponent(q)}&limit=8`)
+ setResults((data.users || data.data || []).map(toResult))
+ }
+ updateDropPos()
+ setOpen(true)
+ } catch { /* silent */ } finally {
+ setSearching(false)
+ }
+ }, [entityType]) // eslint-disable-line react-hooks/exhaustive-deps
+
+ const handleInput = (e) => {
+ const q = e.target.value
+ setQuery(q)
+ if (!q.trim()) { onChange(null); setResults([]); setOpen(false); return }
+ clearTimeout(debounceRef.current)
+ debounceRef.current = setTimeout(() => search(q), 280)
+ }
+
+ const pick = (item) => {
+ onChange(item)
+ setQuery('')
+ setResults([])
+ setOpen(false)
+ }
+
+ const clear = () => { onChange(null); setQuery(''); setResults([]); setOpen(false) }
+
+ return (
+
+
+ {label}{required && * }
+
+
+
e.currentTarget.style.borderColor = 'var(--color-primary)'}
+ onBlurCapture={e => e.currentTarget.style.borderColor = ''}
+ >
+ {value && displayName ? (
+
+
+ {displayName}
+
+ ×
+
+ ) : (
+ <>
+
+ {searching && (
+
+
+
+ )}
+ >
+ )}
+
+ {/* Browse magnifying glass */}
+
{ e.currentTarget.style.color = 'var(--color-primary)'; e.currentTarget.style.background = 'var(--color-primary-subtle)' }}
+ onMouseLeave={e => { e.currentTarget.style.color = 'var(--color-text-muted)'; e.currentTarget.style.background = 'none' }}
+ >
+
+
+
+
+ {/* Portal dropdown */}
+ {open && results.length > 0 && createPortal(
+
+ {results.map((item, i) => (
+
pick(item)}
+ style={{
+ display: 'block', width: '100%', textAlign: 'left',
+ padding: 'var(--space-3) var(--space-4)',
+ background: 'none', border: 'none', cursor: 'pointer',
+ borderBottom: i < results.length - 1 ? '1px solid var(--color-border)' : 'none',
+ transition: 'background 0.1s',
+ }}
+ onMouseEnter={e => e.currentTarget.style.background = 'var(--color-bg-island)'}
+ onMouseLeave={e => e.currentTarget.style.background = 'none'}
+ >
+ {item.name}
+ {item.sub && {item.sub}
}
+
+ ))}
+
,
+ document.body
+ )}
+
+ )
+}
+
+// ─── CreateTicketModal ────────────────────────────────────────────────────────
+
+export default function CreateTicketModal({
+ open,
+ onClose,
+ onCreated,
+ prefillCustomerId,
+ prefillCustomerName,
+ prefillDeviceId,
+ prefillDeviceSerial,
+}) {
+ const { user } = useAuth()
+ const { toast } = useToast()
+
+ const [form, setForm] = useState({
+ subject: '',
+ priority: '',
+ opened_via: 'staff',
+ })
+
+ // Picker state
+ const [customer, setCustomer] = useState(null) // { id, name, subLine }
+ const [device, setDevice] = useState(null) // { id, name, sub }
+ const [linked_user, setLinkedUser] = useState(null) // { id, name, sub }
+
+ // Browse modal
+ const [browseType, setBrowseType] = useState(null) // 'customer' | 'device' | 'app_user'
+
+ const [saving, setSaving] = useState(false)
+ const [error, setError] = useState('')
+
+ // Seed prefills on open
+ useEffect(() => {
+ if (!open) return
+ setForm({ subject: '', priority: '', opened_via: 'staff' })
+ setError('')
+ setBrowseType(null)
+
+ if (prefillCustomerId) {
+ setCustomer({ id: prefillCustomerId, name: prefillCustomerName || prefillCustomerId, subLine: '' })
+ } else {
+ setCustomer(null)
+ }
+
+ if (prefillDeviceId) {
+ setDevice({ id: prefillDeviceId, name: prefillDeviceSerial || prefillDeviceId, sub: '' })
+ } else {
+ setDevice(null)
+ }
+
+ setLinkedUser(null)
+ }, [open, prefillCustomerId, prefillCustomerName, prefillDeviceId, prefillDeviceSerial])
+
+ const set = (field) => (e) => setForm(prev => ({ ...prev, [field]: e.target.value }))
+
+ const save = async () => {
+ if (!form.subject.trim()) { setError('Subject is required.'); return }
+ if (!customer?.id) { setError('Customer is required.'); return }
+
+ setSaving(true)
+ setError('')
+ try {
+ const payload = {
+ customer_id: customer.id,
+ customer_name: customer.name || null,
+ device_id: device?.id || null,
+ device_serial: device?.name || null, // name holds the serial for devices
+ user_id: linked_user?.id || null,
+ user_name: linked_user?.name || null,
+ subject: form.subject.trim(),
+ priority: form.priority || null,
+ opened_via: form.opened_via || null,
+ }
+ const created = await api.post('/tickets', payload)
+ toast.success('Created', 'Support ticket opened.')
+ onCreated(created)
+ } catch (err) {
+ setError(err.message || 'Failed to create ticket.')
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ return (
+ <>
+
+ Cancel
+ Open Ticket
+
+ }
+ >
+
+ {error && (
+
+ {error}
+
+ )}
+
+ {/* Customer — searchable with 2-line display */}
+
setBrowseType('customer')}
+ />
+
+ {/* Subject */}
+
+
+ {/* Linked Device + Linked User — search-based */}
+
+ setBrowseType('device')}
+ />
+ setBrowseType('app_user')}
+ />
+
+
+ {/* Priority + opened via */}
+
+
+ {PRIORITY_OPTIONS.map(o => {o.label} )}
+
+
+
+ {OPENED_VIA_OPTIONS.map(o => {o.label} )}
+
+
+
+
+
+ {/* Browse modals — rendered outside main modal to avoid z-index stacking */}
+ {browseType && (
+ {
+ if (browseType === 'customer') setCustomer({ id: picked.id, name: picked.name, subLine: picked.sub })
+ else if (browseType === 'device') setDevice(picked)
+ else if (browseType === 'app_user') setLinkedUser(picked)
+ setBrowseType(null)
+ }}
+ onClose={() => setBrowseType(null)}
+ />
+ )}
+ >
+ )
+}
diff --git a/frontend/src/modals/crm/helpdesk/EntryFormModal.jsx b/frontend/src/modals/crm/helpdesk/EntryFormModal.jsx
new file mode 100644
index 0000000..cf29de6
--- /dev/null
+++ b/frontend/src/modals/crm/helpdesk/EntryFormModal.jsx
@@ -0,0 +1,739 @@
+// frontend/src/modals/crm/helpdesk/EntryFormModal.jsx
+// Create or edit a Note or Issue entry.
+// When editing, the type is locked. defaultType prop seeds the type on creation.
+
+import { useState, useEffect, useRef, useCallback } from 'react'
+import { createPortal } from 'react-dom'
+import api from '@/lib/api'
+import { useAuth } from '@/hooks/useAuth'
+import { useToast } from '@/components/ui/Toast'
+import Modal from '@/components/ui/Modal'
+import Button from '@/components/ui/Button'
+import FormField from '@/components/ui/FormField'
+import Spinner from '@/components/ui/Spinner'
+import DateTimePicker from '@/components/ui/DateTimePicker'
+
+// ─── Constants ────────────────────────────────────────────────────────────────
+
+const STATUS_OPTIONS = [
+ { value: 'open', label: 'Open' },
+ { value: 'researching', label: 'Researching' },
+ { value: 'resolved', label: 'Resolved' },
+]
+
+const SEVERITY_OPTIONS = [
+ { value: 'low', label: 'Low' },
+ { value: 'medium', label: 'Medium' },
+ { value: 'high', label: 'High' },
+ { value: 'critical', label: 'Critical' },
+]
+
+const CATEGORY_OPTIONS = [
+ { value: 'technical', label: 'Technical Issue' },
+ { value: 'install_support', label: 'Install Support' },
+ { value: 'general', label: 'General' },
+]
+
+const ENTITY_TYPES = [
+ { value: 'device', label: 'Device' },
+ { value: 'app_user', label: 'App User' },
+ { value: 'customer', label: 'Customer' },
+]
+
+// ─── Entity search helpers ────────────────────────────────────────────────────
+
+// Returns { primary, secondary } lines for a result item
+// Field names from actual API responses:
+// device: device_name, serial_number, hw_family/hw_revision
+// app_user: display_name, email
+// customer: name, surname, organization, location.city
+function entityLines(type, item) {
+ if (type === 'device') {
+ return {
+ primary: item.device_name || item.id,
+ secondary: item.serial_number || '',
+ }
+ }
+ if (type === 'app_user') {
+ return {
+ primary: item.display_name || item.email || item.id,
+ secondary: item.email || '',
+ }
+ }
+ if (type === 'customer') {
+ const primary = [item.surname, item.name].filter(Boolean).join(' ') || item.id
+ const city = item.location?.city || item.city || ''
+ const secondary = [item.organization, city].filter(Boolean).join(' · ')
+ return { primary, secondary }
+ }
+ return { primary: item.id, secondary: '' }
+}
+
+// Returns the chip display text for a picked entity
+function entityDisplayName(type, item) {
+ if (type === 'device') return item.device_name || item.id
+ if (type === 'app_user') return item.display_name || item.email || item.id
+ if (type === 'customer') return [item.surname, item.name].filter(Boolean).join(' ') || item.id
+ return item.id
+}
+
+// ─── EntitySearchInput ────────────────────────────────────────────────────────
+// Inline search box + dropdown for a single entity type.
+// Dropdown is rendered via portal so it's never clipped by a parent modal.
+
+function EntitySearchInput({ entityType, value, displayName, onChange, onBrowse, locked }) {
+ const [query, setQuery] = useState('')
+ const [results, setResults] = useState([])
+ const [searching, setSearching] = useState(false)
+ const [open, setOpen] = useState(false)
+ const [dropPos, setDropPos] = useState({ top: 0, left: 0, width: 0 })
+ const debounceRef = useRef(null)
+ const wrapRef = useRef(null)
+ const inputWrapRef = useRef(null)
+ const dropRef = useRef(null)
+
+ // Close dropdown on outside click — must check both the input wrapper AND the portal dropdown
+ useEffect(() => {
+ const handler = (e) => {
+ const inWrap = wrapRef.current && wrapRef.current.contains(e.target)
+ const inDrop = dropRef.current && dropRef.current.contains(e.target)
+ if (!inWrap && !inDrop) setOpen(false)
+ }
+ document.addEventListener('mousedown', handler)
+ return () => document.removeEventListener('mousedown', handler)
+ }, [])
+
+ // When the selected value changes externally, clear the query
+ useEffect(() => { setQuery('') }, [value])
+
+ const updateDropPos = () => {
+ if (inputWrapRef.current) {
+ const r = inputWrapRef.current.getBoundingClientRect()
+ setDropPos({ top: r.bottom + 4, left: r.left, width: r.width })
+ }
+ }
+
+ const search = useCallback(async (q) => {
+ if (!q.trim()) { setResults([]); setOpen(false); return }
+ setSearching(true)
+ try {
+ let data
+ if (entityType === 'device') {
+ data = await api.get(`/devices?search=${encodeURIComponent(q)}&limit=8`)
+ setResults(data.devices || data.data || [])
+ } else if (entityType === 'app_user') {
+ data = await api.get(`/users?search=${encodeURIComponent(q)}&limit=8`)
+ setResults(data.users || data.data || [])
+ } else if (entityType === 'customer') {
+ data = await api.get(`/crm/customers?search=${encodeURIComponent(q)}&limit=8`)
+ setResults(data.customers || data.data || [])
+ }
+ updateDropPos()
+ setOpen(true)
+ } catch { /* silent */ } finally {
+ setSearching(false)
+ }
+ }, [entityType])
+
+ const handleInput = (e) => {
+ const q = e.target.value
+ setQuery(q)
+ if (!q.trim()) { onChange(null); setResults([]); setOpen(false); return }
+ clearTimeout(debounceRef.current)
+ debounceRef.current = setTimeout(() => search(q), 280)
+ }
+
+ const pick = (item) => {
+ onChange({ id: item.id, displayName: entityDisplayName(entityType, item) })
+ setQuery('')
+ setResults([])
+ setOpen(false)
+ }
+
+ const clear = () => { onChange(null); setQuery(''); setResults([]); setOpen(false) }
+
+ const placeholder = {
+ device: 'Search by name or serial…',
+ app_user: 'Search by name or email…',
+ customer: 'Search by name, email, phone, org…',
+ }[entityType] || 'Search…'
+
+ return (
+
+ {/* Input row — styled like .input */}
+
e.currentTarget.style.borderColor = 'var(--color-primary)'}
+ onBlurCapture={e => e.currentTarget.style.borderColor = ''}
+ >
+ {/* Selected chip OR text input */}
+ {value && displayName ? (
+
+
+ {displayName}
+
+ {!locked && (
+
+ ×
+
+ )}
+
+ ) : locked ? (
+ /* locked but no value yet — show empty disabled state */
+
+ —
+
+ ) : (
+
+ )}
+
+ {/* Spinner */}
+ {searching && (
+
+
+
+ )}
+
+ {/* Browse magnifying glass — hidden when locked */}
+ {!locked && (
+
{ e.currentTarget.style.color = 'var(--color-primary)'; e.currentTarget.style.background = 'var(--color-primary-subtle)' }}
+ onMouseLeave={e => { e.currentTarget.style.color = 'var(--color-text-muted)'; e.currentTarget.style.background = 'none' }}
+ >
+
+
+
+
+
+ )}
+
+
+ {/* Dropdown — rendered via portal so it's never clipped by the modal */}
+ {open && results.length > 0 && createPortal(
+
+ {results.map((item, i) => {
+ const lines = entityLines(entityType, item)
+ return (
+
pick(item)}
+ style={{
+ display: 'block', width: '100%', textAlign: 'left',
+ padding: 'var(--space-3) var(--space-4)',
+ background: 'none', border: 'none', cursor: 'pointer',
+ borderBottom: i < results.length - 1 ? '1px solid var(--color-border)' : 'none',
+ transition: 'background 0.1s',
+ }}
+ onMouseEnter={e => e.currentTarget.style.background = 'var(--color-bg-island)'}
+ onMouseLeave={e => e.currentTarget.style.background = 'none'}
+ >
+
+ {lines.primary}
+
+ {lines.secondary && (
+
+ {lines.secondary}
+
+ )}
+
+ )
+ })}
+
,
+ document.body
+ )}
+
+ )
+}
+
+// ─── EntityBrowseModal ────────────────────────────────────────────────────────
+// Full-list picker modal opened by the magnifying glass button.
+
+function EntityBrowseModal({ open, entityType, onPick, onClose }) {
+ const [items, setItems] = useState([])
+ const [loading, setLoading] = useState(false)
+ const [search, setSearch] = useState('')
+ const debounceRef = useRef(null)
+
+ const load = useCallback(async (q = '') => {
+ setLoading(true)
+ try {
+ let data
+ const qs = q ? `?search=${encodeURIComponent(q)}&limit=50` : '?limit=50'
+ if (entityType === 'device') {
+ data = await api.get(`/devices${qs}`)
+ setItems(data.devices || data.data || [])
+ } else if (entityType === 'app_user') {
+ data = await api.get(`/users${qs}`)
+ setItems(data.users || data.data || [])
+ } else if (entityType === 'customer') {
+ data = await api.get(`/crm/customers${qs}`)
+ setItems(data.customers || data.data || [])
+ }
+ } catch { /* silent */ } finally {
+ setLoading(false)
+ }
+ }, [entityType])
+
+ useEffect(() => { if (open) { setSearch(''); load() } }, [open, load])
+
+ const handleSearch = (e) => {
+ const q = e.target.value
+ setSearch(q)
+ clearTimeout(debounceRef.current)
+ debounceRef.current = setTimeout(() => load(q), 300)
+ }
+
+ const typeLabel = { device: 'Devices', app_user: 'Users', customer: 'Customers' }[entityType] || entityType
+
+ // Two-line row renderer consistent with inline dropdown
+ const renderRow = (item) => {
+ const lines = entityLines(entityType, item)
+ return (
+
+
+ {lines.primary || '—'}
+
+ {lines.secondary && (
+
+ {lines.secondary}
+
+ )}
+
+ )
+ }
+
+ return (
+
+
+ {/* Search */}
+
+
+ {/* List */}
+
+ {loading ? (
+
+ ) : items.length === 0 ? (
+
+ No {typeLabel.toLowerCase()} found.
+
+ ) : (
+ items.map((item, i) => (
+
{ onPick({ id: item.id, displayName: entityDisplayName(entityType, item) }); onClose() }}
+ style={{
+ display: 'flex', width: '100%', textAlign: 'left',
+ padding: 'var(--space-3) var(--space-4)',
+ background: 'none', border: 'none', cursor: 'pointer',
+ borderBottom: i < items.length - 1 ? '1px solid var(--color-border)' : 'none',
+ transition: 'background 0.1s',
+ }}
+ onMouseEnter={e => e.currentTarget.style.background = 'var(--color-bg-island)'}
+ onMouseLeave={e => e.currentTarget.style.background = 'none'}
+ >
+ {renderRow(item)}
+
+ ))
+ )}
+
+
+
+ )
+}
+
+// ─── Empty link row ───────────────────────────────────────────────────────────
+
+const emptyLink = () => ({ _key: Math.random(), entity_type: 'device', entity_id: '', _displayName: '', _locked: false })
+
+// ─── EntryFormModal ───────────────────────────────────────────────────────────
+
+export default function EntryFormModal({
+ open,
+ entry, // null = create mode, object = edit mode
+ defaultType, // 'note' | 'issue' — used in create mode only
+ prefilledLinks, // [{entity_type, entity_id, display_name, locked}] — used when opened from an entity detail page
+ knownEntities, // {[entity_id]: display_name} — used to resolve display names in edit mode
+ onClose,
+ onSaved,
+ onDelete,
+}) {
+ const { user } = useAuth()
+ const { toast } = useToast()
+
+ const isEdit = !!entry
+ const entryType = isEdit ? entry.type : (defaultType || 'issue')
+
+ const [form, setForm] = useState({
+ type: entryType,
+ title: '',
+ body: '',
+ status: 'open',
+ severity: '',
+ category: '',
+ occurred_at: new Date().toISOString(),
+ })
+ const [links, setLinks] = useState([emptyLink()])
+ const [saving, setSaving] = useState(false)
+ const [error, setError] = useState('')
+
+ // Browse modal state
+ const [browseLink, setBrowseLink] = useState(null) // { key, entityType }
+
+ // Seed form when modal opens
+ useEffect(() => {
+ if (!open) return
+ if (isEdit) {
+ setForm({
+ type: entry.type,
+ title: entry.title || '',
+ body: entry.body || '',
+ status: entry.status || 'open',
+ severity: entry.severity || '',
+ category: entry.category || '',
+ occurred_at: entry.occurred_at || new Date().toISOString(),
+ })
+ setLinks(
+ (entry.links || []).length > 0
+ ? entry.links.map(l => {
+ // Resolve display name: prefer API-returned name, then caller-supplied knownEntities lookup, then fall back to raw ID
+ const resolved = l.display_name || (knownEntities && knownEntities[l.entity_id]) || l.entity_id
+ // Mark as locked if it matches a prefilled entity
+ const isLocked = prefilledLinks
+ ? prefilledLinks.some(p => p.entity_id === l.entity_id && p.locked)
+ : false
+ return {
+ _key: Math.random(),
+ entity_type: l.entity_type,
+ entity_id: l.entity_id,
+ _displayName: resolved,
+ _locked: isLocked,
+ }
+ })
+ : [emptyLink()]
+ )
+ } else {
+ setForm({
+ type: entryType,
+ title: '',
+ body: '',
+ status: 'open',
+ severity: '',
+ category: '',
+ occurred_at: new Date().toISOString(),
+ })
+ setLinks(
+ prefilledLinks && prefilledLinks.length > 0
+ ? prefilledLinks.map(l => ({ _key: Math.random(), ...l, _displayName: l.display_name || l.entity_id || '', _locked: !!l.locked }))
+ : [emptyLink()]
+ )
+ }
+ setError('')
+ setBrowseLink(null)
+ }, [open, entry, entryType, isEdit, prefilledLinks, knownEntities])
+
+ const set = (field) => (e) => setForm(prev => ({ ...prev, [field]: e.target.value }))
+
+ // ── Links management ──
+ const addLink = () => setLinks(prev => [...prev, emptyLink()])
+ const removeLink = (key) => setLinks(prev => prev.filter(l => l._key !== key))
+ const updateLinkType = (key, newType) =>
+ setLinks(prev => prev.map(l => l._key === key ? { ...l, entity_type: newType, entity_id: '', _displayName: '' } : l))
+ const updateLinkEntity = (key, picked) =>
+ setLinks(prev => prev.map(l => l._key === key
+ ? { ...l, entity_id: picked ? picked.id : '', _displayName: picked ? picked.displayName : '' }
+ : l
+ ))
+
+ const isIssue = form.type === 'issue'
+
+ // ── Save ──
+ const save = async () => {
+ if (!form.title.trim()) { setError('Title is required.'); return }
+ if (isIssue && !form.status) { setError('Status is required for issues.'); return }
+
+ const validLinks = links.filter(l => l.entity_id.trim())
+
+ const payload = {
+ type: form.type,
+ title: form.title.trim(),
+ body: form.body.trim() || null,
+ status: isIssue ? form.status : null,
+ severity: isIssue ? form.severity || null : null,
+ category: isIssue ? form.category || null : null,
+ occurred_at: form.occurred_at || null,
+ links: validLinks.map(l => ({ entity_type: l.entity_type, entity_id: l.entity_id.trim() })),
+ }
+
+ setSaving(true)
+ setError('')
+ try {
+ if (isEdit) {
+ await api.patch(`/notes/${entry.id}`, {
+ title: payload.title,
+ body: payload.body,
+ status: payload.status,
+ severity: payload.severity,
+ category: payload.category,
+ occurred_at: payload.occurred_at,
+ })
+ await api.patch(`/notes/${entry.id}/links`, { links: payload.links })
+ } else {
+ await api.post('/notes', payload)
+ }
+ toast.success(isEdit ? 'Updated' : 'Created', `${isIssue ? 'Issue' : 'Note'} saved successfully.`)
+ onSaved()
+ } catch (err) {
+ setError(err.message || 'Save failed.')
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ const title = isEdit
+ ? `Edit ${isIssue ? 'Issue' : 'Note'}`
+ : `New ${entryType === 'issue' ? 'Issue' : 'Note'}`
+
+ return (
+ <>
+
+
+ {isEdit && onDelete && (
+ onDelete(entry.id)}>
+ Delete
+
+ )}
+
+
+ Cancel
+
+ {isEdit ? 'Save Changes' : `Create ${isIssue ? 'Issue' : 'Note'}`}
+
+
+
+ }
+ >
+
+ {error && (
+
+ {error}
+
+ )}
+
+ {/* Title (70%) + DateTime (30%) on the same row */}
+
+
+ setForm(prev => ({ ...prev, occurred_at: iso }))}
+ />
+
+
+ {/* Issue-only fields */}
+ {isIssue && (
+
+
+ {STATUS_OPTIONS.map(o => {o.label} )}
+
+
+
+ No severity
+ {SEVERITY_OPTIONS.map(o => {o.label} )}
+
+
+
+ No category
+ {CATEGORY_OPTIONS.map(o => {o.label} )}
+
+
+ )}
+
+ {/* Body */}
+
+
+ {/* Entity links — reworked */}
+
+
+
+ Linked Entities
+
+ (optional)
+
+
+ + Add link
+
+
+
+ {links.map((link) => (
+
+ {/* Type selector — locked when the link is a prefilled entity */}
+ {link._locked ? (
+
+ {ENTITY_TYPES.find(t => t.value === link.entity_type)?.label || link.entity_type}
+
+ ) : (
+
updateLinkType(link._key, e.target.value)}
+ label=""
+ >
+ {ENTITY_TYPES.map(t => {t.label} )}
+
+ )}
+
+ {/* Entity search input */}
+
updateLinkEntity(link._key, picked)}
+ onBrowse={() => setBrowseLink({ key: link._key, entityType: link.entity_type })}
+ locked={link._locked}
+ />
+
+ {/* Remove — hidden for locked links */}
+ {link._locked ? (
+
+ ) : (
+ removeLink(link._key)}
+ style={{
+ width: 28, height: 28,
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
+ borderRadius: 'var(--radius-md)',
+ border: 'none',
+ backgroundColor: 'transparent',
+ color: 'var(--color-text-muted)',
+ cursor: 'pointer',
+ fontSize: 16,
+ lineHeight: 1,
+ flexShrink: 0,
+ }}
+ title="Remove link"
+ >
+ ×
+
+ )}
+
+ ))}
+
+
+
+
+
+ {/* Browse-all picker — rendered outside the main modal to avoid z-index stacking */}
+ {browseLink && (
+ { updateLinkEntity(browseLink.key, picked); setBrowseLink(null) }}
+ onClose={() => setBrowseLink(null)}
+ />
+ )}
+ >
+ )
+}
diff --git a/frontend/src/modals/crm/products/DeleteProductModal.jsx b/frontend/src/modals/crm/products/DeleteProductModal.jsx
new file mode 100644
index 0000000..48f15d1
--- /dev/null
+++ b/frontend/src/modals/crm/products/DeleteProductModal.jsx
@@ -0,0 +1,24 @@
+// frontend/src/modals/products/DeleteProductModal.jsx
+
+import ConfirmDialog from '@/components/ui/ConfirmDialog'
+
+export default function DeleteProductModal({ product, onConfirm, onCancel, loading }) {
+ return (
+
+ )
+}
diff --git a/frontend/src/modals/engineering/manufacturing/DeleteDeviceModal.jsx b/frontend/src/modals/engineering/manufacturing/DeleteDeviceModal.jsx
new file mode 100644
index 0000000..233b494
--- /dev/null
+++ b/frontend/src/modals/engineering/manufacturing/DeleteDeviceModal.jsx
@@ -0,0 +1,95 @@
+// frontend/src/modals/manufacturing/DeleteDeviceModal.jsx
+import { useState } from 'react'
+import Modal from '@/components/ui/Modal'
+import Button from '@/components/ui/Button'
+import FormField from '@/components/ui/FormField'
+import api from '@/lib/api'
+
+const PROTECTED_STATUSES = ['sold', 'claimed']
+
+export default function DeleteDeviceModal({ open, device, onClose, onDeleted }) {
+ const [typed, setTyped] = useState('')
+ const [deleting, setDeleting] = useState(false)
+ const [error, setError] = useState('')
+
+ if (!device) return null
+
+ const isProtected = PROTECTED_STATUSES.includes(device.mfg_status)
+ const confirmed = !isProtected || typed === device.serial_number
+
+ const handleClose = () => { setTyped(''); setError(''); onClose() }
+
+ const handleDelete = async () => {
+ setDeleting(true); setError('')
+ try {
+ await api.request(`/manufacturing/devices/${device.serial_number}`, { method: 'DELETE' })
+ onDeleted(device.id)
+ handleClose()
+ } catch (err) {
+ setError(err.message)
+ setDeleting(false)
+ }
+ }
+
+ return (
+
+ Cancel
+
+ Delete Device
+
+
+ }
+ >
+
+ {isProtected ? (
+ <>
+
+ This device has status {device.mfg_status} and is linked to a customer.
+ Deleting it is irreversible and will remove all associated data.
+
+
+ {device.serial_number}
+
+
setTyped(e.target.value)}
+ onPaste={(e) => e.preventDefault()}
+ placeholder="Type serial number here…"
+ />
+ >
+ ) : (
+
+ Are you sure you want to delete{' '}
+
+ {device.serial_number}
+ ?{' '}
+ This cannot be undone.
+
+ )}
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+ )
+}
diff --git a/frontend/src/modals/engineering/manufacturing/DeleteUnprovisionedModal.jsx b/frontend/src/modals/engineering/manufacturing/DeleteUnprovisionedModal.jsx
new file mode 100644
index 0000000..aa23209
--- /dev/null
+++ b/frontend/src/modals/engineering/manufacturing/DeleteUnprovisionedModal.jsx
@@ -0,0 +1,74 @@
+// frontend/src/modals/manufacturing/DeleteUnprovisionedModal.jsx
+import { useState } from 'react'
+import Modal from '@/components/ui/Modal'
+import Button from '@/components/ui/Button'
+import api from '@/lib/api'
+
+const UNPROVISIONED_STATUSES = ['manufactured', 'flashed']
+
+export default function DeleteUnprovisionedModal({ open, devices = [], onClose, onDeleted }) {
+ const [deleting, setDeleting] = useState(false)
+ const [error, setError] = useState('')
+
+ const targets = devices.filter((d) => UNPROVISIONED_STATUSES.includes(d.mfg_status))
+
+ const handleClose = () => { setError(''); onClose() }
+
+ const handleDelete = async () => {
+ setDeleting(true); setError('')
+ try {
+ await Promise.all(
+ targets.map((d) =>
+ api.request(`/manufacturing/devices/${d.serial_number}`, { method: 'DELETE' })
+ )
+ )
+ onDeleted()
+ handleClose()
+ } catch (err) {
+ setError(err.message)
+ setDeleting(false)
+ }
+ }
+
+ return (
+
+ Cancel
+
+ Delete {targets.length} Device{targets.length !== 1 ? 's' : ''}
+
+
+ }
+ >
+
+ {targets.length === 0 ? (
+
+ No unprovisioned devices found. Only devices with status manufactured or flashed can be bulk-deleted.
+
+ ) : (
+
+ This will permanently delete {targets.length} device{targets.length !== 1 ? 's' : ''} with
+ status manufactured or flashed . Provisioned, sold, and claimed devices are not affected.
+ This cannot be undone.
+
+ )}
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+ )
+}
diff --git a/frontend/src/modals/engineering/manufacturing/NewBatchModal.jsx b/frontend/src/modals/engineering/manufacturing/NewBatchModal.jsx
new file mode 100644
index 0000000..1b6286d
--- /dev/null
+++ b/frontend/src/modals/engineering/manufacturing/NewBatchModal.jsx
@@ -0,0 +1,221 @@
+// frontend/src/modals/manufacturing/NewBatchModal.jsx
+import { useState } from 'react'
+import Modal from '@/components/ui/Modal'
+import Button from '@/components/ui/Button'
+import FormField from '@/components/ui/FormField'
+import StatusBadge from '@/components/ui/StatusBadge'
+import Icon from '@/components/ui/Icon'
+import api from '@/lib/api'
+
+const BOARD_TYPES = [
+ { 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' },
+]
+
+// Family accent tokens (used only as CSS vars via inline style — not raw hex)
+const FAMILY_ACCENT = {
+ vesper: { color: 'var(--color-info)', bg: 'var(--color-info-bg)', border: 'rgba(123,208,255,0.35)' },
+ agnus: { color: 'var(--color-warning)', bg: 'var(--color-warning-bg)', border: 'rgba(251,191,36,0.35)' },
+ chronos: { color: 'var(--color-danger)', bg: 'var(--color-danger-bg)', border: 'rgba(255,92,92,0.35)' },
+}
+
+function BoardTile({ bt, isSelected, onClick }) {
+ const pal = FAMILY_ACCENT[bt.family]
+ const [hovered, setHovered] = useState(false)
+ const active = isSelected || hovered
+ return (
+ setHovered(true)}
+ onMouseLeave={() => setHovered(false)}
+ style={{
+ backgroundColor: isSelected ? pal.bg : hovered ? 'var(--color-bg-surface)' : 'var(--color-bg-abyss)',
+ border: `1px solid ${active ? pal.color : 'var(--color-border-strong)'}`,
+ borderRadius: 'var(--radius-md)',
+ padding: 'var(--space-3)',
+ textAlign: 'left',
+ cursor: 'pointer',
+ transition: 'border-color 0.15s, background-color 0.15s, box-shadow 0.15s',
+ boxShadow: active ? `0 0 0 1px ${pal.border}, 0 0 14px ${pal.border}` : 'none',
+ width: '100%',
+ }}
+ >
+ {bt.name}
+ {bt.codename}
+
+ )
+}
+
+export default function NewBatchModal({ open, onClose, onCreated }) {
+ const [boardType, setBoardType] = useState(null)
+ const [boardVersion, setBoardVersion] = useState('1.0')
+ const [quantity, setQuantity] = useState(1)
+ const [saving, setSaving] = useState(false)
+ const [error, setError] = useState('')
+ const [result, setResult] = useState(null)
+ const [copied, setCopied] = useState(false)
+
+ const handleClose = () => {
+ setResult(null); setError(''); setBoardType(null); setBoardVersion('1.0'); setQuantity(1)
+ onClose()
+ }
+
+ const handleSubmit = async (e) => {
+ e.preventDefault()
+ if (!boardType) return
+ setError(''); setSaving(true)
+ try {
+ const data = await api.post('/manufacturing/batch', {
+ board_type: boardType,
+ board_version: boardVersion.trim(),
+ quantity: Number(quantity),
+ })
+ setResult(data)
+ if (onCreated) onCreated(data)
+ } catch (err) {
+ setError(err.message)
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ const copyAll = () => {
+ if (!result) return
+ navigator.clipboard.writeText(result.serial_numbers.join('\n'))
+ setCopied(true)
+ setTimeout(() => setCopied(false), 2000)
+ }
+
+ const boardName = BOARD_TYPES.find((b) => b.value === result?.board_type)?.name || result?.board_type
+
+ return (
+
+
+ {copied ? 'Copied!' : 'Copy All Serials'}
+
+ Done
+
+ ) : (
+
+ Cancel
+
+ Generate {quantity} Serial{Number(quantity) > 1 ? 's' : ''}
+
+
+ )
+ }
+ >
+ {!result ? (
+
+ ) : (
+
+
+
+
+ {result.serial_numbers.length} device{result.serial_numbers.length !== 1 ? 's' : ''} registered
+
+
+ Batch: {result.batch_id}
+
+
+
{boardName} Rev {result.board_version}
+
+
+
+ {result.serial_numbers.map((sn) => (
+
{sn}
+ ))}
+
+
+ )}
+
+ )
+}
diff --git a/frontend/src/modals/engineering/manufacturing/UploadAssetModal.jsx b/frontend/src/modals/engineering/manufacturing/UploadAssetModal.jsx
new file mode 100644
index 0000000..c9c5b49
--- /dev/null
+++ b/frontend/src/modals/engineering/manufacturing/UploadAssetModal.jsx
@@ -0,0 +1,135 @@
+// frontend/src/modals/manufacturing/UploadAssetModal.jsx
+import { useState, useRef } from 'react'
+import Modal from '@/components/ui/Modal'
+import Button from '@/components/ui/Button'
+import Icon from '@/components/ui/Icon'
+
+function formatBytes(bytes) {
+ if (!bytes) return '—'
+ if (bytes < 1024) return `${bytes} B`
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
+ return `${(bytes / (1024 * 1024)).toFixed(2)} MB`
+}
+
+export default function UploadAssetModal({ open, hwType, assetName, onClose, onSaved }) {
+ const [file, setFile] = useState(null)
+ const [uploading, setUploading] = useState(false)
+ const [error, setError] = useState('')
+ const [dragging, setDragging] = useState(false)
+ const fileInputRef = useRef(null)
+
+ const handleClose = () => { setFile(null); setError(''); onClose() }
+
+ const handleFile = (f) => {
+ if (!f) return
+ if (!f.name.endsWith('.bin')) { setError('Only .bin files are accepted.'); return }
+ setFile(f); setError('')
+ }
+
+ const handleUpload = async () => {
+ if (!file) return
+ setError(''); setUploading(true)
+ try {
+ const formData = new FormData()
+ formData.append('file', file)
+ const token = localStorage.getItem('access_token')
+ const res = await fetch(`/api/manufacturing/flash-assets/${hwType}/${assetName}`, {
+ method: 'POST',
+ headers: { Authorization: `Bearer ${token}` },
+ body: formData,
+ })
+ if (!res.ok) {
+ const err = await res.json().catch(() => ({}))
+ throw new Error(err.detail || 'Upload failed')
+ }
+ onSaved()
+ handleClose()
+ } catch (err) {
+ setError(err.message)
+ } finally {
+ setUploading(false)
+ }
+ }
+
+ return (
+
+ Cancel
+
+ Upload
+
+
+ }
+ >
+
+
+ Board:{' '}
+ {hwType}
+ {' '}— overwrites any existing file.
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+ {/* Drop zone */}
+
fileInputRef.current?.click()}
+ onDragOver={(e) => { e.preventDefault(); setDragging(true) }}
+ onDragLeave={() => setDragging(false)}
+ onDrop={(e) => {
+ e.preventDefault(); setDragging(false)
+ handleFile(e.dataTransfer.files[0])
+ }}
+ onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') fileInputRef.current?.click() }}
+ style={{
+ display: 'flex',
+ flexDirection: 'column',
+ alignItems: 'center',
+ justifyContent: 'center',
+ gap: 'var(--space-2)',
+ padding: 'var(--space-8) var(--space-4)',
+ border: `2px dashed ${file ? 'var(--color-primary)' : dragging ? 'var(--color-primary)' : 'var(--color-border-strong)'}`,
+ borderRadius: 'var(--radius-lg)',
+ backgroundColor: file ? 'var(--color-primary-subtle)' : dragging ? 'var(--color-primary-subtle)' : 'var(--color-bg-abyss)',
+ cursor: 'pointer',
+ transition: 'all 0.15s ease',
+ }}
+ >
+ handleFile(e.target.files[0])}
+ style={{ display: 'none' }}
+ />
+
+ {file ? (
+ <>
+
+ {file.name}
+ {formatBytes(file.size)}
+ >
+ ) : (
+ <>
+
+
+ Click or drop a .bin file
+
+ >
+ )}
+
+
+
+ )
+}
diff --git a/frontend/src/modals/shared/ConfirmDeleteModal.jsx b/frontend/src/modals/shared/ConfirmDeleteModal.jsx
new file mode 100644
index 0000000..5fc5698
--- /dev/null
+++ b/frontend/src/modals/shared/ConfirmDeleteModal.jsx
@@ -0,0 +1,4 @@
+// TODO: implement
+export default function ConfirmDeleteModal() {
+ return null
+}
diff --git a/frontend/src/pages/auth/LoginPage.jsx b/frontend/src/pages/auth/LoginPage.jsx
new file mode 100644
index 0000000..d10dc15
--- /dev/null
+++ b/frontend/src/pages/auth/LoginPage.jsx
@@ -0,0 +1,318 @@
+// frontend/src/pages/auth/LoginPage.jsx
+// Full-bleed background image (left-anchored, vertically fitted), glass panel at 75% column.
+
+import { useState } from 'react'
+import { useNavigate } from 'react-router-dom'
+import { useAuth } from '@/hooks/useAuth'
+import Button from '@/components/ui/Button'
+import FormField from '@/components/ui/FormField'
+import logoSrc from '@/assets/logos/bell_systems_horizontal_darkMode.png'
+import bgSrc from '@/assets/images/login-widescreen.jpg'
+
+// ─── Eye toggle icons ─────────────────────────────────────────────────────────
+
+function IconEyeOpen() {
+ return (
+
+
+
+
+ )
+}
+
+function IconEyeClosed() {
+ return (
+
+
+
+
+
+ )
+}
+
+// ─── Page ─────────────────────────────────────────────────────────────────────
+
+export default function LoginPage() {
+ const [email, setEmail] = useState('')
+ const [password, setPassword] = useState('')
+ const [showPwd, setShowPwd] = useState(false)
+ const [error, setError] = useState('')
+ const [isLoading, setIsLoading] = useState(false)
+
+ const { login } = useAuth()
+ const navigate = useNavigate()
+
+ const handleSubmit = async (e) => {
+ e.preventDefault()
+ setError('')
+ setIsLoading(true)
+ try {
+ await login(email, password)
+ navigate('/', { replace: true })
+ } catch (err) {
+ setError(err.message || 'Invalid credentials. Please try again.')
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ return (
+ <>
+
+
+
+ {/* Full-bleed background */}
+
+
+ {/* Right-column layout */}
+
+
+
+ {/* Logo */}
+
+
+
+
+ {/* Error */}
+ {error && (
+
+
+
+
+
+
+ {error}
+
+ )}
+
+ {/* Form */}
+
+
+
+
+
+ >
+ )
+}
diff --git a/frontend/src/pages/bellcloud/devices/DeviceDetail.jsx b/frontend/src/pages/bellcloud/devices/DeviceDetail.jsx
new file mode 100644
index 0000000..b946185
--- /dev/null
+++ b/frontend/src/pages/bellcloud/devices/DeviceDetail.jsx
@@ -0,0 +1,433 @@
+// frontend/src/pages/bellcloud/devices/DeviceDetail.jsx
+// Parent shell — data fetching, tab routing, modals. Tab content lives in tabs/
+
+import { useState, useEffect, useCallback } from 'react'
+import { useParams, useNavigate, useSearchParams } from 'react-router-dom'
+import api from '@/lib/api'
+import { useAuth } from '@/hooks/useAuth'
+import { useToast } from '@/components/ui/Toast'
+import PageHeader from '@/components/ui/PageHeader'
+import Button from '@/components/ui/Button'
+import Tabs from '@/components/ui/Tabs'
+import Spinner from '@/components/ui/Spinner'
+import ConfirmDialog from '@/components/ui/ConfirmDialog'
+
+import EditLocationModal from '@/modals/bellcloud/devices/EditLocationModal'
+import EditAttributesModal from '@/modals/bellcloud/devices/EditAttributesModal'
+import EditLoggingModal from '@/modals/bellcloud/devices/EditLoggingModal'
+import EditMiscModal from '@/modals/bellcloud/devices/EditMiscModal'
+import EditBellOutputsModal from '@/modals/bellcloud/devices/EditBellOutputsModal'
+import EditClockSettingsModal from '@/modals/bellcloud/devices/EditClockSettingsModal'
+import EditAlertsModal from '@/modals/bellcloud/devices/EditAlertsModal'
+import EditBacklightModal from '@/modals/bellcloud/devices/EditBacklightModal'
+import EditSubscriptionModal from '@/modals/bellcloud/devices/EditSubscriptionModal'
+import EditWarrantyModal from '@/modals/bellcloud/devices/EditWarrantyModal'
+import AssignCustomerModal from '@/modals/bellcloud/devices/AssignCustomerModal'
+
+import OverviewTab from '@/pages/bellcloud/devices/tabs/OverviewTab'
+import GeneralTab from '@/pages/bellcloud/devices/tabs/GeneralTab'
+import BellsTab from '@/pages/bellcloud/devices/tabs/BellsTab'
+import ClockTab from '@/pages/bellcloud/devices/tabs/ClockTab'
+import WarrantyTab from '@/pages/bellcloud/devices/tabs/WarrantyTab'
+import ManageTab from '@/pages/bellcloud/devices/tabs/ManageTab'
+import ControlTab from '@/pages/bellcloud/devices/tabs/ControlTab'
+
+// ─── Tab config ───────────────────────────────────────────────────────────────
+
+const TABS = [
+ { key: 'overview', label: 'Overview' },
+ { key: 'general', label: 'General' },
+ { key: 'bells', label: 'Bell Mechanisms' },
+ { key: 'clock', label: 'Clock & Alerts' },
+ { key: 'warranty', label: 'Warranty & Subscription' },
+ { key: 'manage', label: 'Manage' },
+ { key: 'control', label: 'Control' },
+]
+
+function resolveInitialTab(searchParams) {
+ const raw = searchParams.get('tab')
+ if (!raw) return 'overview'
+ const match = TABS.find(t => t.key === raw.toLowerCase())
+ return match ? match.key : 'overview'
+}
+
+// ─── DeviceDetail ─────────────────────────────────────────────────────────────
+
+export default function DeviceDetail() {
+ const { id } = useParams()
+ const navigate = useNavigate()
+ const [searchParams, setSearchParams] = useSearchParams()
+ const { hasPermission } = useAuth()
+ const { toast } = useToast()
+ const canEdit = hasPermission('devices', 'edit')
+
+ // ── State ──────────────────────────────────────────────────────────────────
+ const [device, setDevice] = useState(null)
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState('')
+ const [mqttStatus, setMqttStatus] = useState(null)
+ const [activeTab, setActiveTab] = useState(() => resolveInitialTab(searchParams))
+
+ const [deviceUsers, setDeviceUsers] = useState([])
+ const [usersLoading, setUsersLoading] = useState(false)
+ const [ownerCustomer, setOwnerCustomer] = useState(null)
+ const [assigningCustomer, setAssigningCustomer] = useState(false)
+
+ const [tags, setTags] = useState([])
+ const [staffNotes, setStaffNotes] = useState('')
+
+ const [showDelete, setShowDelete] = useState(false)
+ const [deleting, setDeleting] = useState(false)
+
+ const [cmdHistory, setCmdHistory] = useState([])
+ const [cmdHistoryLoading, setCmdHistoryLoading] = useState(false)
+ const [sendingCmd, setSendingCmd] = useState('')
+
+ // Modal open flags
+ const [editingLocation, setEditingLocation] = useState(false)
+ const [editingAttributes, setEditingAttributes] = useState(false)
+ const [editingLogging, setEditingLogging] = useState(false)
+ const [editingMisc, setEditingMisc] = useState(false)
+ const [editingBellOutputs, setEditingBellOutputs] = useState(false)
+ const [editingClockSettings, setEditingClockSettings] = useState(false)
+ const [editingAlerts, setEditingAlerts] = useState(false)
+ const [editingBacklight, setEditingBacklight] = useState(false)
+ const [editingSubscription, setEditingSubscription] = useState(false)
+ const [editingWarranty, setEditingWarranty] = useState(false)
+ const [showAssignCustomer, setShowAssignCustomer] = useState(false)
+
+ // ── Data fetching ──────────────────────────────────────────────────────────
+
+ const loadDevice = useCallback(async () => {
+ setLoading(true)
+ setError('')
+ try {
+ const d = await api.get(`/devices/${id}`)
+ setDevice(d)
+ if (d.staffNotes) setStaffNotes(d.staffNotes)
+ if (Array.isArray(d.tags)) setTags(d.tags)
+ setLoading(false)
+
+ const sn = d.serial_number || d.device_id
+ if (sn) {
+ api.get('/mqtt/status').then(mqttData => {
+ if (mqttData?.devices) {
+ const match = mqttData.devices.find(s => s.device_serial === sn)
+ setMqttStatus(match || null)
+ }
+ }).catch(() => {})
+ }
+
+ if (d.customer_id) {
+ api.get(`/devices/${id}/customer`).then(res => {
+ setOwnerCustomer(res.customer || null)
+ }).catch(() => setOwnerCustomer(null))
+ } else {
+ setOwnerCustomer(null)
+ }
+
+ setUsersLoading(true)
+ api.get(`/devices/${id}/users`).then(data => {
+ setDeviceUsers(data.users || [])
+ }).catch(() => setDeviceUsers([])).finally(() => setUsersLoading(false))
+
+ } catch (err) {
+ setError(err.message || 'Failed to load device.')
+ setLoading(false)
+ }
+ }, [id])
+
+ useEffect(() => { loadDevice() }, [loadDevice])
+
+ const loadCmdHistory = useCallback(async () => {
+ const sn = device?.serial_number || device?.device_id
+ if (!sn) return
+ setCmdHistoryLoading(true)
+ try {
+ const data = await api.get(`/mqtt/commands/${sn}?limit=50`)
+ setCmdHistory(data.commands || [])
+ } catch {
+ setCmdHistory([])
+ } finally {
+ setCmdHistoryLoading(false)
+ }
+ }, [device])
+
+ useEffect(() => {
+ if (activeTab === 'control' && device) loadCmdHistory()
+ }, [activeTab, device, loadCmdHistory])
+
+ // ── Tab sync ───────────────────────────────────────────────────────────────
+
+ const handleTabChange = key => {
+ setActiveTab(key)
+ setSearchParams(prev => {
+ const next = new URLSearchParams(prev)
+ next.set('tab', key)
+ return next
+ }, { replace: true })
+ }
+
+ // ── Actions ────────────────────────────────────────────────────────────────
+
+ const handleDelete = async () => {
+ setDeleting(true)
+ try {
+ await api.delete(`/devices/${id}`)
+ toast.success('Deleted', 'Device removed.')
+ navigate('/devices')
+ } catch (err) {
+ toast.danger('Error', err.message || 'Failed to delete device.')
+ setDeleting(false)
+ setShowDelete(false)
+ }
+ }
+
+ const handleAssignCustomer = async customer => {
+ setAssigningCustomer(true)
+ try {
+ await api.post(`/devices/${id}/assign-customer`, { customer_id: customer.id })
+ setOwnerCustomer(customer)
+ setDevice(prev => ({ ...prev, customer_id: customer.id }))
+ setShowAssignCustomer(false)
+ toast.success('Assigned', `Assigned to ${[customer.name, customer.surname].filter(Boolean).join(' ')}.`)
+ } catch (err) {
+ toast.danger('Error', err.message || 'Failed to assign customer.')
+ } finally {
+ setAssigningCustomer(false)
+ }
+ }
+
+ const handleUnassignCustomer = async () => {
+ setAssigningCustomer(true)
+ try {
+ const cid = device?.customer_id
+ await api.delete(`/devices/${id}/assign-customer${cid ? `?customer_id=${cid}` : ''}`)
+ setOwnerCustomer(null)
+ setDevice(prev => ({ ...prev, customer_id: '' }))
+ toast.success('Removed', 'Customer unassigned.')
+ } catch (err) {
+ toast.danger('Error', err.message || 'Failed to unassign.')
+ } finally {
+ setAssigningCustomer(false)
+ }
+ }
+
+ const sendMqttCommand = async (cmd, contents = {}) => {
+ const deviceId = device?.device_id || device?.serial_number
+ if (!deviceId) return
+ setSendingCmd(cmd)
+ try {
+ await api.post(`/mqtt/command/${deviceId}`, { cmd, contents })
+ toast.success('Sent', `Command "${cmd}" sent.`)
+ loadCmdHistory()
+ } catch (err) {
+ toast.danger('Error', err.message || 'Failed to send command.')
+ } finally {
+ setSendingCmd('')
+ }
+ }
+
+ // ── Derived ────────────────────────────────────────────────────────────────
+
+ const attr = device?.device_attributes || {}
+ const clock = attr.clockSettings || {}
+ const sub = device?.device_subscription || {}
+ const stats = device?.device_stats || {}
+ const sn = device?.serial_number || device?.device_id
+ const isOnline = mqttStatus ? mqttStatus.online : device?.is_Online
+
+ // ── Render states ──────────────────────────────────────────────────────────
+
+ if (loading) {
+ return (
+
+ )
+ }
+
+ if (error) {
+ return (
+
+ )
+ }
+
+ if (!device) return null
+
+ // Props bundle passed to every tab
+ const tabProps = {
+ device,
+ canEdit,
+ onDeviceUpdated: setDevice,
+ attr,
+ clock,
+ sub,
+ stats,
+ sn,
+ isOnline,
+ tags,
+ setTags,
+ staffNotes,
+ setStaffNotes,
+ deviceUsers,
+ usersLoading,
+ ownerCustomer,
+ assigningCustomer,
+ cmdHistory,
+ cmdHistoryLoading,
+ sendingCmd,
+ loadDevice,
+ loadCmdHistory,
+ sendMqttCommand,
+ onShowDelete: () => setShowDelete(true),
+ onAssignCustomer: () => setShowAssignCustomer(true),
+ onUnassignCustomer: handleUnassignCustomer,
+ onEditLocation: () => setEditingLocation(true),
+ onEditAttributes: () => setEditingAttributes(true),
+ onEditLogging: () => setEditingLogging(true),
+ onEditMisc: () => setEditingMisc(true),
+ onEditBellOutputs: () => setEditingBellOutputs(true),
+ onEditClockSettings: () => setEditingClockSettings(true),
+ onEditAlerts: () => setEditingAlerts(true),
+ onEditBacklight: () => setEditingBacklight(true),
+ onEditSubscription: () => setEditingSubscription(true),
+ onEditWarranty: () => setEditingWarranty(true),
+ }
+
+ // ── Render ─────────────────────────────────────────────────────────────────
+
+ return (
+
+
+
+ {canEdit && (
+ navigate(`/devices/${id}/edit`)}>
+ Edit Device
+
+ )}
+
+
+
+
+
+ {activeTab === 'overview' && }
+ {activeTab === 'general' && }
+ {activeTab === 'bells' && }
+ {activeTab === 'clock' && }
+ {activeTab === 'warranty' && }
+ {activeTab === 'manage' && }
+ {activeTab === 'control' && }
+
+
+ {/* ── Modals ────────────────────────────────────────────────────────── */}
+
+
setEditingLocation(false)}
+ onSaved={loadDevice}
+ device={device}
+ id={id}
+ />
+ setEditingAttributes(false)}
+ onSaved={loadDevice}
+ attr={attr}
+ id={id}
+ />
+ setEditingLogging(false)}
+ onSaved={loadDevice}
+ attr={attr}
+ id={id}
+ />
+ setEditingMisc(false)}
+ onSaved={loadDevice}
+ device={device}
+ attr={attr}
+ id={id}
+ />
+ setEditingBellOutputs(false)}
+ onSaved={loadDevice}
+ attr={attr}
+ sub={sub}
+ id={id}
+ />
+ setEditingClockSettings(false)}
+ onSaved={loadDevice}
+ attr={attr}
+ sub={sub}
+ id={id}
+ />
+ setEditingAlerts(false)}
+ onSaved={loadDevice}
+ clock={clock}
+ attr={attr}
+ id={id}
+ />
+ setEditingBacklight(false)}
+ onSaved={loadDevice}
+ attr={attr}
+ clock={clock}
+ sub={sub}
+ id={id}
+ />
+ setEditingSubscription(false)}
+ onSaved={loadDevice}
+ sub={sub}
+ id={id}
+ />
+ setEditingWarranty(false)}
+ onSaved={loadDevice}
+ stats={stats}
+ id={id}
+ />
+ setShowAssignCustomer(false)}
+ onSelect={handleAssignCustomer}
+ deviceId={id}
+ />
+ setShowDelete(false)}
+ loading={deleting}
+ />
+
+
+ )
+}
diff --git a/frontend/src/pages/bellcloud/devices/DeviceForm.jsx b/frontend/src/pages/bellcloud/devices/DeviceForm.jsx
new file mode 100644
index 0000000..e0f1bb2
--- /dev/null
+++ b/frontend/src/pages/bellcloud/devices/DeviceForm.jsx
@@ -0,0 +1,4 @@
+// TODO: implement
+export default function DeviceForm() {
+ return null
+}
diff --git a/frontend/src/pages/bellcloud/devices/DeviceList.jsx b/frontend/src/pages/bellcloud/devices/DeviceList.jsx
new file mode 100644
index 0000000..6297c48
--- /dev/null
+++ b/frontend/src/pages/bellcloud/devices/DeviceList.jsx
@@ -0,0 +1,638 @@
+// frontend/src/pages/devices/DeviceList.jsx
+
+import { useState, useEffect, useCallback } from 'react'
+import { useNavigate } from 'react-router-dom'
+import api from '@/lib/api'
+import { useAuth } from '@/hooks/useAuth'
+import PageHeader from '@/components/ui/PageHeader'
+import Button from '@/components/ui/Button'
+import DataTable from '@/components/ui/DataTable'
+import StatusBadge from '@/components/ui/StatusBadge'
+import Pagination from '@/components/ui/Pagination'
+import SearchBar from '@/components/ui/SearchBar'
+import RowActions from '@/components/ui/RowActions'
+import Icon from '@/components/ui/Icon'
+import SegmentedControl from '@/components/ui/SegmentedControl'
+import DeleteDeviceModal from '@/modals/bellcloud/devices/DeleteDeviceModal'
+import DeviceFiltersModal from '@/modals/bellcloud/devices/DeviceFiltersModal'
+import DeviceListCardView from './DeviceListCardView'
+import DeviceListMapView from './DeviceListMapView'
+
+// ─── Column definitions ───────────────────────────────────────────────────────
+
+// Columns marked sortable: name, location, totalBells, assignedUsers, maxOutputs,
+// totalPlaybacks, totalHammerStrikes, totalWarnings, totalMelodies
+const ALL_COLUMNS = [
+ { key: 'status', label: '', pickerLabel: 'Status', defaultOn: true, width: '44px' },
+ { key: 'name', label: 'Name', defaultOn: true, alwaysOn: true, sortable: true },
+ { key: 'serialNumber', label: 'Serial Number', defaultOn: true },
+ { key: 'location', label: 'Location', defaultOn: true, sortable: true },
+ { key: 'subscrTier', label: 'Tier', defaultOn: true },
+ { key: 'totalBells', label: 'Total Bells', defaultOn: true, sortable: true },
+ { key: 'assignedUsers', label: 'Users', defaultOn: true, sortable: true },
+ { key: 'maxOutputs', label: 'Max Outputs', defaultOn: false, sortable: true },
+ { key: 'hasClock', label: 'Has Clock', defaultOn: false },
+ { key: 'hasBells', label: 'Has Bells', defaultOn: false },
+ { key: 'bellGuard', label: 'Bell Guard', defaultOn: false },
+ { key: 'warningsOn', label: 'Warnings On', defaultOn: false },
+ { key: 'serialLogLevel', label: 'Serial Log Level', defaultOn: false },
+ { key: 'sdLogLevel', label: 'SD Log Level', defaultOn: false },
+ { key: 'bellOutputs', label: 'Bell Outputs', defaultOn: false },
+ { key: 'hammerTimings', label: 'Hammer Timings', defaultOn: false },
+ { key: 'ringAlertsMaster', label: 'Ring Alerts', defaultOn: false },
+ { key: 'totalPlaybacks', label: 'Playbacks', defaultOn: false, sortable: true },
+ { key: 'totalHammerStrikes', label: 'Hammer Strikes', defaultOn: false, sortable: true },
+ { key: 'totalWarnings', label: 'Warnings Given', defaultOn: false, sortable: true },
+ { key: 'warrantyActive', label: 'Warranty', defaultOn: false },
+ { key: 'totalMelodies', label: 'Melodies', defaultOn: false, sortable: true },
+ { key: 'tags', label: 'Tags', defaultOn: false },
+ { key: 'hw_family', label: 'HW Family', defaultOn: false },
+ { key: 'hw_revision', label: 'HW Revision', defaultOn: false },
+]
+
+const ALL_KEYS = ALL_COLUMNS.map((c) => c.key)
+
+function loadColumnPrefs() {
+ try {
+ const raw = localStorage.getItem('deviceListColumnPrefs')
+ if (raw) {
+ const prefs = JSON.parse(raw)
+ // prefs = { order: [...keys], visible: [...keys] }
+ if (prefs.order && prefs.visible) return prefs
+ }
+ } catch { /* ignore */ }
+ return {
+ order: ALL_KEYS,
+ visible: ALL_COLUMNS.filter((c) => c.defaultOn).map((c) => c.key),
+ }
+}
+
+function saveColumnPrefs(order, visible) {
+ try {
+ localStorage.setItem('deviceListColumnPrefs', JSON.stringify({ order, visible }))
+ } catch { /* ignore */ }
+}
+
+// ─── Helpers ──────────────────────────────────────────────────────────────────
+
+function parseFirestoreDate(str) {
+ if (!str) return null
+ const cleaned = str.replace(' at ', ' ').replace('UTC+0000', 'UTC').replace(/UTC\+\d{4}/, 'UTC')
+ const d = new Date(cleaned)
+ return isNaN(d.getTime()) ? null : d
+}
+
+function isSubscriptionActive(device) {
+ const sub = device.device_subscription
+ if (!sub?.subscrStart || !sub?.subscrDuration) return false
+ try {
+ const start = parseFirestoreDate(sub.subscrStart)
+ if (!start) return false
+ return new Date(start.getTime() + sub.subscrDuration * 86400000) > new Date()
+ } catch { return false }
+}
+
+function isWarrantyActive(device) {
+ const stats = device.device_stats
+ if (!stats?.warrantyStart || !stats?.warrantyPeriod) return !!stats?.warrantyActive
+ try {
+ const start = parseFirestoreDate(stats.warrantyStart)
+ if (!start) return !!stats?.warrantyActive
+ return new Date(start.getTime() + stats.warrantyPeriod * 86400000) > new Date()
+ } catch { return !!stats?.warrantyActive }
+}
+
+// ─── Cell sub-components ──────────────────────────────────────────────────────
+
+function OnlineDot({ isOnline }) {
+ return (
+
+ )
+}
+
+function BoolBadge({ value, yesLabel = 'Yes', noLabel = 'No' }) {
+ return (
+
+ {value ? yesLabel : noLabel}
+
+ )
+}
+
+function TierBadge({ tier }) {
+ const t = (tier || 'basic').toLowerCase()
+ const variant = t === 'vip' || t === 'premium' ? 'primary' : t === 'custom' ? 'info' : 'neutral'
+ return (
+
+ {t.charAt(0).toUpperCase() + t.slice(1)}
+
+ )
+}
+
+function TagList({ tags }) {
+ if (!tags?.length) return —
+ return (
+
+ {tags.map((t) => (
+ {t}
+ ))}
+
+ )
+}
+
+function Muted({ children }) {
+ return {children}
+}
+
+// ─── Sorting helper ───────────────────────────────────────────────────────────
+
+function getSortValue(device, key) {
+ const attr = device.device_attributes || {}
+ const sub = device.device_subscription || {}
+ const stats = device.device_stats || {}
+ switch (key) {
+ case 'name': return (device.device_name || '').toLowerCase()
+ case 'location': return (device.device_location || '').toLowerCase()
+ case 'totalBells': return attr.totalBells ?? -1
+ case 'assignedUsers': return Array.isArray(device.user_list) ? device.user_list.length : 0
+ case 'maxOutputs': return sub.maxOutputs ?? -1
+ case 'totalPlaybacks': return stats.totalPlaybacks ?? -1
+ case 'totalHammerStrikes': return stats.totalHammerStrikes ?? -1
+ case 'totalWarnings': return stats.totalWarningsGiven ?? -1
+ case 'totalMelodies': return device.device_melodies_all?.length ?? -1
+ default: return 0
+ }
+}
+
+function sortDevices(devices, key, dir) {
+ if (!key) return devices
+ return [...devices].sort((a, b) => {
+ const va = getSortValue(a, key)
+ const vb = getSortValue(b, key)
+ if (va < vb) return dir === 'asc' ? -1 : 1
+ if (va > vb) return dir === 'asc' ? 1 : -1
+ return 0
+ })
+}
+
+// ─── DeviceList ───────────────────────────────────────────────────────────────
+
+export default function DeviceList() {
+ const navigate = useNavigate()
+ const { hasPermission } = useAuth()
+ const canEdit = hasPermission('devices', 'edit')
+
+ // Data
+ const [devices, setDevices] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState('')
+ const [mqttStatusMap, setMqttStatusMap] = useState({})
+ const [mqttLoaded, setMqttLoaded] = useState(false)
+
+ // Server-side filters
+ const [search, setSearch] = useState('')
+ const [onlineFilter, setOnlineFilter] = useState('')
+ const [tierFilter, setTierFilter] = useState('')
+
+ // Client-side filters
+ const [subscrStatusFilter, setSubscrStatusFilter] = useState('')
+ const [warrantyStatusFilter, setWarrantyStatusFilter] = useState('')
+ const [hasClockFilter, setHasClockFilter] = useState('')
+ const [hasBellsFilter, setHasBellsFilter] = useState('')
+
+ // Column prefs (visibility + order)
+ const [colPrefs, setColPrefs] = useState(loadColumnPrefs)
+
+ // Sort
+ const [sortKey, setSortKey] = useState('')
+ const [sortDir, setSortDir] = useState('asc')
+
+ // Pagination
+ const [page, setPage] = useState(1)
+ const [pageSize, setPageSize] = useState(20)
+
+ // View mode: 'table' | 'card' (map is temporarily disabled — access via /devices/map-preview)
+ const [viewMode, setViewMode] = useState(() => {
+ try {
+ const saved = localStorage.getItem('deviceListViewMode')
+ // Reset 'map' to 'table' while map view is under construction
+ return (saved === 'map' ? 'table' : saved) || 'table'
+ } catch { return 'table' }
+ })
+
+ // Filters modal
+ const [filtersOpen, setFiltersOpen] = useState(false)
+
+ // Delete modal
+ const [deleteTarget, setDeleteTarget] = useState(null)
+ const [deleteError, setDeleteError] = useState('')
+
+ // ── Column change handler ──────────────────────────────────────────────────
+ // Accepts either a new visible array (from toggle) or a new order array (from drag)
+ // DataTable calls onColumnChange with the new visibleKeys array for both operations
+ const handleColumnChange = useCallback((newVisibleKeys) => {
+ setColPrefs((prev) => {
+ // Detect if this is a reorder (same keys, different order) or visibility toggle
+ const sortedPrev = [...prev.visible].sort()
+ const sortedNext = [...newVisibleKeys].sort()
+ const isReorder = JSON.stringify(sortedPrev) === JSON.stringify(sortedNext)
+
+ let nextOrder = prev.order
+ let nextVisible = prev.visible
+
+ if (isReorder) {
+ // Drag reorder: update the order of visible keys, keeping hidden keys appended
+ const hiddenKeys = prev.order.filter((k) => !prev.visible.includes(k))
+ nextOrder = [...newVisibleKeys, ...hiddenKeys]
+ nextVisible = newVisibleKeys
+ } else {
+ // Visibility toggle: preserve order, just update visible set
+ nextVisible = newVisibleKeys
+ }
+
+ saveColumnPrefs(nextOrder, nextVisible)
+ return { order: nextOrder, visible: nextVisible }
+ })
+ }, [])
+
+ // ── Fetch devices ──────────────────────────────────────────────────────────
+ const fetchDevices = useCallback(async () => {
+ setLoading(true)
+ setMqttLoaded(false)
+ setError('')
+ try {
+ const params = new URLSearchParams()
+ if (search) params.set('search', search)
+ if (onlineFilter === 'true') params.set('online', 'true')
+ if (onlineFilter === 'false') params.set('online', 'false')
+ if (tierFilter) params.set('tier', tierFilter)
+ // Note: online filter is server-side — we pass it as a query param but
+ // the server returns ALL devices when the param is absent, so we also
+ // apply a client-side guard to ensure ONLINE-only actually filters.
+ const qs = params.toString()
+ const data = await api.get(`/devices${qs ? `?${qs}` : ''}`)
+ setDevices(data.devices ?? [])
+ api.get('/mqtt/status').then((mqttData) => {
+ if (mqttData?.devices) {
+ const map = {}
+ for (const s of mqttData.devices) map[s.device_serial] = s
+ setMqttStatusMap(map)
+ }
+ setMqttLoaded(true)
+ }).catch(() => { setMqttLoaded(true) })
+ } catch (err) {
+ setError(err.message || 'Failed to load devices.')
+ } finally {
+ setLoading(false)
+ }
+ }, [search, onlineFilter, tierFilter])
+
+ useEffect(() => { fetchDevices() }, [fetchDevices])
+
+ useEffect(() => {
+ setPage(1)
+ }, [search, onlineFilter, tierFilter, subscrStatusFilter, warrantyStatusFilter, hasClockFilter, hasBellsFilter])
+
+ // ── Delete ─────────────────────────────────────────────────────────────────
+ const handleDelete = async () => {
+ if (!deleteTarget) return
+ setDeleteError('')
+ try {
+ await api.delete(`/devices/${deleteTarget.id}`)
+ setDeleteTarget(null)
+ fetchDevices()
+ } catch (err) {
+ setDeleteError(err.message || 'Failed to delete device.')
+ }
+ }
+
+ // ── Client-side filtering + sorting ───────────────────────────────────────
+ const filteredDevices = devices.filter((device) => {
+ // Online/offline — wait for MQTT data before applying so we don't show
+ // false-negative "no devices" while the status map is still loading.
+ const sn = device.serial_number || device.device_id
+ const mqtt = mqttStatusMap[sn]
+ const isOnline = mqtt ? mqtt.online : (device.is_Online ?? false)
+ // Only apply online/offline filter once MQTT status has resolved —
+ // prevents false-negative empty table while the status map is still loading.
+ if (onlineFilter === 'true' && mqttLoaded && !isOnline) return false
+ if (onlineFilter === 'false' && mqttLoaded && isOnline) return false
+
+ if (subscrStatusFilter === 'active' && !isSubscriptionActive(device)) return false
+ if (subscrStatusFilter === 'expired' && isSubscriptionActive(device)) return false
+ if (warrantyStatusFilter === 'active' && !isWarrantyActive(device)) return false
+ if (warrantyStatusFilter === 'expired' && isWarrantyActive(device)) return false
+ if (hasClockFilter === 'yes' && !device.device_attributes?.hasClock) return false
+ if (hasClockFilter === 'no' && device.device_attributes?.hasClock) return false
+ if (hasBellsFilter === 'yes' && !device.device_attributes?.hasBells) return false
+ if (hasBellsFilter === 'no' && device.device_attributes?.hasBells) return false
+ return true
+ })
+
+ const sortedDevices = sortDevices(filteredDevices, sortKey, sortDir)
+ const total = sortedDevices.length
+ const pagedDevices = sortedDevices.slice((page - 1) * pageSize, page * pageSize)
+
+ // ── Active filter count (for badge on Filters button) ─────────────────────
+ const activeFilterCount = [onlineFilter, tierFilter, subscrStatusFilter, warrantyStatusFilter, hasClockFilter, hasBellsFilter].filter(Boolean).length
+
+ // ── Middle-click handler ───────────────────────────────────────────────────
+ const handleRowMiddleClick = (device, e) => {
+ if (e.button === 1) {
+ e.preventDefault()
+ window.open(`/devices/${device.id}`, '_blank', 'noopener,noreferrer')
+ }
+ }
+
+ // ── Build DataTable column definitions ────────────────────────────────────
+ // Respect the saved column order
+ const orderedVisible = colPrefs.order.filter((k) => colPrefs.visible.includes(k))
+
+ const tableColumns = orderedVisible
+ .map((key) => {
+ const c = ALL_COLUMNS.find((col) => col.key === key)
+ if (!c) return null
+ return {
+ key: c.key,
+ label: c.label,
+ width: c.width,
+ sortable: c.sortable,
+ render: (device) => {
+ const attr = device.device_attributes || {}
+ const clock = attr.clockSettings || {}
+ const sub = device.device_subscription || {}
+ const stats = device.device_stats || {}
+ const sn = device.serial_number || device.device_id
+ const mqtt = mqttStatusMap[sn]
+ const isOnline = mqtt ? mqtt.online : device.is_Online
+
+ switch (c.key) {
+ case 'status':
+ return
+ case 'name':
+ return (
+
+ {device.device_name || 'Unnamed Device'}
+
+ )
+ case 'serialNumber':
+ return (
+
+ {sn || '—'}
+
+ )
+ case 'location':
+ return device.device_location || —
+ case 'subscrTier':
+ return
+ case 'totalBells':
+ return attr.totalBells ?? 0
+ case 'assignedUsers':
+ return Array.isArray(device.user_list) ? device.user_list.length : 0
+ case 'maxOutputs':
+ return sub.maxOutputs ?? —
+ case 'hasClock':
+ return
+ case 'hasBells':
+ return
+ case 'bellGuard':
+ return
+ case 'warningsOn':
+ return
+ case 'serialLogLevel':
+ return attr.serialLogLevel ?? 0
+ case 'sdLogLevel':
+ return attr.sdLogLevel ?? 0
+ case 'bellOutputs':
+ return attr.bellOutputs?.length ? attr.bellOutputs.join(', ') : —
+ case 'hammerTimings':
+ return attr.hammerTimings?.length ? attr.hammerTimings.join(', ') : —
+ case 'ringAlertsMaster':
+ return
+ case 'totalPlaybacks':
+ return stats.totalPlaybacks ?? 0
+ case 'totalHammerStrikes':
+ return stats.totalHammerStrikes ?? 0
+ case 'totalWarnings':
+ return stats.totalWarningsGiven ?? 0
+ case 'warrantyActive':
+ return
+ case 'totalMelodies':
+ return device.device_melodies_all?.length ?? 0
+ case 'tags':
+ return
+ case 'hw_family':
+ return device.hw_family || —
+ case 'hw_revision':
+ return device.hw_revision || —
+ default:
+ return —
+ }
+ },
+ }
+ })
+ .filter(Boolean)
+
+ // Actions column (not part of ALL_COLUMNS — appended, never draggable)
+ tableColumns.push({
+ key: '__actions',
+ label: '',
+ width: '110px',
+ align: 'right',
+ render: (device) => (
+ e.stopPropagation()} style={{ display: 'flex', justifyContent: 'flex-end' }}>
+ ,
+ onClick: () => navigate(`/devices/${device.id}`),
+ },
+ ...(canEdit ? [
+ {
+ label: 'Edit',
+ icon: ,
+ color: 'var(--color-info)',
+ onClick: () => navigate(`/devices/${device.id}/edit`),
+ },
+ {
+ label: 'Delete',
+ icon: ,
+ color: 'var(--color-danger)',
+ divider: true,
+ onClick: () => { setDeleteError(''); setDeleteTarget(device) },
+ },
+ ] : []),
+ ]}
+ />
+
+ ),
+ })
+
+ // ─────────────────────────────────────────────────────────────────────────
+
+ return (
+
+
+ {/* Page header */}
+
+ {canEdit && (
+ navigate('/devices/new')}>
+ Add Device
+
+ )}
+
+
+ {/* Toolbar + pagination + content — single tight-gap column so all three
+ items are visually grouped and equidistant from each other */}
+
+
+ {/* Filter toolbar */}
+
+
+
+
+
+
+
0 ? 'secondary' : 'ghost'}
+ size="md"
+ onClick={() => setFiltersOpen(true)}
+ style={{ paddingTop: 'var(--space-3)', paddingBottom: 'var(--space-3)', lineHeight: 'var(--line-height-base)' }}
+ icon={
+
+
+
+ }
+ >
+ Filters{activeFilterCount > 0 ? ` (${activeFilterCount})` : ''}
+
+
+
},
+ { value: 'card', label: 'Cards', icon:
},
+ // Map option temporarily hidden — access via /devices/map-preview while under construction
+ ]}
+ value={viewMode}
+ onChange={(v) => {
+ setViewMode(v)
+ try { localStorage.setItem('deviceListViewMode', v) } catch { /* ignore */ }
+ }}
+ size="md"
+ style={{ flexShrink: 0 }}
+ />
+
+
+
+ {/* Error banner */}
+ {error && (
+
+ {error}
+
+ )}
+
+ {total > 0 && viewMode !== 'map' && (
+
{ setPageSize(s); setPage(1) }}
+ pageSizes={[10, 20, 50, 100]}
+ />
+ )}
+
+ {/* Table, Card, or Map view */}
+ {viewMode === 'map' ? (
+ navigate(`/devices/${device.id}`)}
+ />
+ ) : viewMode === 'card' ? (
+ navigate(`/devices/${device.id}`)}
+ onMiddleClick={(device, e) => handleRowMiddleClick(device, e)}
+ />
+ ) : (
+ navigate(`/devices/${device.id}`)}
+ onRowMiddleClick={(device, e) => handleRowMiddleClick(device, e)}
+ skeletonRows={8}
+ sortKey={sortKey}
+ sortDir={sortDir}
+ onSort={(key, dir) => { setSortKey(key); setSortDir(dir); setPage(1) }}
+ allColumns={ALL_COLUMNS}
+ visibleKeys={colPrefs.visible}
+ onColumnChange={handleColumnChange}
+ />
+ )}
+
+
+
+ {/* Filters modal */}
+
setFiltersOpen(false)}
+ onlineFilter={onlineFilter} setOnlineFilter={setOnlineFilter}
+ tierFilter={tierFilter} setTierFilter={setTierFilter}
+ subscrStatusFilter={subscrStatusFilter} setSubscrStatusFilter={setSubscrStatusFilter}
+ warrantyStatusFilter={warrantyStatusFilter} setWarrantyStatusFilter={setWarrantyStatusFilter}
+ hasClockFilter={hasClockFilter} setHasClockFilter={setHasClockFilter}
+ hasBellsFilter={hasBellsFilter} setHasBellsFilter={setHasBellsFilter}
+ />
+
+ {/* Delete confirmation modal */}
+ { setDeleteTarget(null); setDeleteError('') }}
+ error={deleteError}
+ />
+
+
+ )
+}
diff --git a/frontend/src/pages/bellcloud/devices/DeviceListCardView.jsx b/frontend/src/pages/bellcloud/devices/DeviceListCardView.jsx
new file mode 100644
index 0000000..01ffb61
--- /dev/null
+++ b/frontend/src/pages/bellcloud/devices/DeviceListCardView.jsx
@@ -0,0 +1,861 @@
+// frontend/src/pages/bellcloud/devices/DeviceListCardView.jsx
+//
+// Card-based view of the Device Fleet list.
+// Glassmorphism + gradient glow border (online=green, offline=grey)
+// + real OSM tile map pulled from device_location_coordinates.
+// All colours via design tokens only.
+
+import { useMemo } from 'react'
+import Icon from '@/components/ui/Icon'
+
+// ─── Tier config ──────────────────────────────────────────────────────────────
+
+const TIER_META = {
+ vip: { label: 'VIP' },
+ premium: { label: 'Premium' },
+ custom: { label: 'Custom' },
+ small: { label: 'Small' },
+ mini: { label: 'Mini' },
+ basic: { label: 'Basic' },
+}
+
+function getTierLabel(tier) {
+ return TIER_META[(tier || 'basic').toLowerCase()]?.label ?? 'Basic'
+}
+
+// ─── OSM tile helpers ─────────────────────────────────────────────────────────
+// Convert lat/lng to OSM tile x/y at a given zoom level.
+
+// Returns the exact tile coords AND the sub-tile pixel offset (0–255) of the coordinate.
+function latLngToTile(lat, lng, zoom) {
+ const n = Math.pow(2, zoom)
+ const xFrac = ((lng + 180) / 360) * n
+ const latRad = (lat * Math.PI) / 180
+ const yFrac = ((1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2) * n
+ const x = Math.floor(xFrac)
+ const y = Math.floor(yFrac)
+ // px/py: how far into the focal tile the coordinate sits, in pixels (0–256)
+ const px = (xFrac - x) * 256
+ const py = (yFrac - y) * 256
+ return { x, y, px, py }
+}
+
+// Build a 4-wide × 3-tall grid of Stamen Toner tiles centred on the coordinate.
+// Using 4 columns (dx = -2..+1) guarantees the grid is 1024px wide, which always
+// exceeds the card width (~500px) even when the marker sits near the right edge
+// of the focal tile (markerOffset.x up to 512px, anchor = 80% - markerOffset.x).
+// markerOffset is the pixel position of the coordinate within the 1024×768 grid,
+// measured from the grid's top-left. The grid is anchored via inline style so
+// markerOffset lands at exactly 80%/60% of the card.
+function buildTileGrid(lat, lng, zoom = 13) {
+ const { x, y, px, py } = latLngToTile(lat, lng, zoom)
+ const tiles = []
+ for (let dy = -1; dy <= 1; dy++) {
+ for (let dx = -2; dx <= 1; dx++) {
+ const tx = x + dx
+ const ty = y + dy
+ tiles.push({
+ url: `https://tiles.stadiamaps.com/tiles/stamen_toner/${zoom}/${tx}/${ty}.png`,
+ col: dx + 2 + 1, // cols 1–4
+ row: dy + 1 + 1, // rows 1–3
+ })
+ }
+ }
+ // The focal tile is at grid column index 2 (dx=0), so its left edge is at
+ // 2 × 256 = 512px from the grid's left. markerOffset.x = 512 + px.
+ return { tiles, markerOffset: { x: 512 + px, y: 256 + py } }
+}
+
+// Normalise GeoPoint from Firestore — can arrive as {lat,lng}, {latitude,longitude},
+// or a Firestore GeoPoint object with .latitude / .longitude getters.
+function parseCoords(raw) {
+ if (!raw) return null
+ const lat = raw.latitude ?? raw.lat ?? null
+ const lng = raw.longitude ?? raw.lng ?? null
+ if (lat == null || lng == null) return null
+ return { lat: Number(lat), lng: Number(lng) }
+}
+
+// ─── Inline SVGs ─────────────────────────────────────────────────────────────
+
+function BellIcon({ size = 13, color = 'currentColor' }) {
+ return (
+
+
+
+
+ )
+}
+
+function ClockIcon({ size = 13, color = 'currentColor' }) {
+ return (
+
+
+
+
+ )
+}
+
+function AlertTriangleIcon({ size = 13, color = 'currentColor' }) {
+ return (
+
+
+
+
+
+ )
+}
+
+
+// ─── Real OSM tile map background ────────────────────────────────────────────
+// Renders a 3×3 grid of OSM tiles centred on device_location_coordinates,
+// tinted dark to match the card theme, with a left-fade gradient overlay.
+
+function MapBackground({ coords }) {
+ const ZOOM = 13
+
+ const { tiles, markerOffset } = useMemo(() => {
+ if (!coords) return { tiles: null, markerOffset: null }
+ return buildTileGrid(coords.lat, coords.lng, ZOOM)
+ }, [coords])
+
+ return (
+
+ {tiles ? (
+ // 4×3 tile grid. Anchored so markerOffset lands at exactly 80%/60% of
+ // the card. The grid is 1024px wide so it always covers the full card.
+
+ {tiles.map((t, i) => (
+
+ ))}
+
+ ) : (
+
+ )}
+
+ {/* Marker pinned at exactly 80%/60% of the card */}
+ {tiles && markerOffset && (
+
+ )}
+
+
+
+
+ )
+}
+
+// ─── Online status block (rectangular, spans both identity rows) ──────────────
+
+function OnlineBlock({ online }) {
+ return (
+
+
+ {online ? 'Online' : 'Offline'}
+
+ )
+}
+
+// ─── Inline pill (used for tier, warranty, clock, bells) ─────────────────────
+
+function InlinePill({ icon, label, variant = 'default', title }) {
+ return (
+
+ {icon && {icon} }
+ {label}
+
+ )
+}
+
+// ─── Warning count badge ──────────────────────────────────────────────────────
+
+function WarningsBadge({ count }) {
+ if (!count) return null
+ return (
+ 1 ? 's' : ''} given`}>
+
+
{count}
+
+ )
+}
+
+
+
+// ─── Single Device Card ───────────────────────────────────────────────────────
+
+function DeviceCard({ device, mqttStatusMap, onView, onMiddleClick }) {
+ const attr = device.device_attributes || {}
+ const sub = device.device_subscription || {}
+ const stats = device.device_stats || {}
+ const sn = device.serial_number || device.device_id || ''
+ const mqtt = mqttStatusMap[sn]
+ const isOnline = mqtt ? mqtt.online : device.is_Online
+
+ const name = device.device_name || 'Unnamed Device'
+ const location = device.device_location
+ const coords = useMemo(() => parseCoords(device.device_location_coordinates), [device.device_location_coordinates])
+ const tierLabel = getTierLabel(sub.subscrTier)
+ const hasClock = !!attr.hasClock
+ const hasBells = !!attr.hasBells
+ const totalWarnings = stats.totalWarningsGiven ?? 0
+
+
+ return (
+ { if (e.button === 1) { e.preventDefault(); onMiddleClick?.(e) } }}
+ >
+ {/* ── Tier badge — absolute top-right corner ── */}
+ {tierLabel}
+
+ {/* ── Card content (above map) ── */}
+
+ {/* ── Real OSM map background — fills card, pinned top-left ── */}
+
+
+ {/* ── Header: status block + identity ── */}
+
+
+ {/* ── Feature pills: clock + bells — absolute bottom-left ── */}
+
+ {hasClock && (
+ } label="Clock" variant="feature" title="Has Clock" />
+ )}
+ {hasBells && (
+ } label="Bells" variant="feature" title="Has Bells" />
+ )}
+
+
+ {/* ── Location label — under marker at 80%/60%, or bottom-right corner ── */}
+ {location && (
+
+ {location}
+
+ )}
+
+
+
+ )
+}
+
+// ─── Skeleton card ────────────────────────────────────────────────────────────
+
+function SkeletonCard() {
+ return (
+
+
+
+
+ {[1, 2, 3].map((i) => (
+
+ ))}
+
+
+
+ )
+}
+
+// ─── DeviceListCardView (exported) ────────────────────────────────────────────
+
+export default function DeviceListCardView({
+ devices,
+ loading,
+ mqttStatusMap,
+ onView,
+ onMiddleClick,
+}) {
+ if (loading) {
+ return (
+ <>
+
+
+ {Array.from({ length: 9 }).map((_, i) => )}
+
+ >
+ )
+ }
+
+ if (!devices.length) {
+ return (
+ <>
+
+
+
+
+
+
No devices found
+
Try adjusting your search or filters.
+
+ >
+ )
+ }
+
+ return (
+ <>
+
+
+ {devices.map((device) => (
+ onView(device)}
+ onMiddleClick={onMiddleClick ? (e) => onMiddleClick(device, e) : undefined}
+ />
+ ))}
+
+ >
+ )
+}
+
+// ─── Styles ───────────────────────────────────────────────────────────────────
+
+const CARD_STYLES = `
+
+/* ── Grid ─────────────────────────────────────────────────────────────────── */
+.dcard-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(500px, 1fr));
+ gap: var(--space-4);
+ align-items: flex-start;
+}
+
+/* ── Card wrapper — gradient border that changes with online state ────────── */
+.dcard {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ border-radius: 16px;
+ padding: 1px; /* the 1px holds the gradient border colour */
+ cursor: pointer;
+ user-select: none;
+ box-sizing: border-box;
+ transition: transform 220ms ease, box-shadow 220ms ease, background-image 220ms ease;
+ /* min-height ensures border shows on all 4 sides even with short content */
+ min-height: 200px;
+}
+
+/* Online — green glow border */
+.dcard--online {
+ background-image: linear-gradient(
+ 135deg,
+ rgba(74,222,128,0.55) 0%,
+ rgba(255,255,255,0.08) 40%,
+ rgba(255,255,255,0.05) 65%,
+ rgba(74,222,128,0.70) 100%
+ );
+ box-shadow:
+ 0 8px 32px rgba(0,0,0,0.45),
+ 0 0 28px rgba(74,222,128,0.10);
+}
+.dcard--online:hover {
+ background-image: linear-gradient(
+ 135deg,
+ rgba(74,222,128,0.80) 0%,
+ rgba(255,255,255,0.10) 40%,
+ rgba(255,255,255,0.07) 65%,
+ rgba(74,222,128,0.95) 100%
+ );
+ box-shadow:
+ 0 12px 40px rgba(0,0,0,0.55),
+ 0 0 48px rgba(74,222,128,0.18);
+}
+
+/* Offline — neutral grey border */
+.dcard--offline {
+ background-image: linear-gradient(
+ 135deg,
+ rgba(120,130,145,0.35) 0%,
+ rgba(255,255,255,0.05) 40%,
+ rgba(255,255,255,0.03) 65%,
+ rgba(90,100,115,0.45) 100%
+ );
+ box-shadow:
+ 0 8px 32px rgba(0,0,0,0.45),
+ 0 0 20px rgba(100,110,125,0.06);
+}
+.dcard--offline:hover {
+ background-image: linear-gradient(
+ 135deg,
+ rgba(140,155,175,0.50) 0%,
+ rgba(255,255,255,0.08) 40%,
+ rgba(255,255,255,0.05) 65%,
+ rgba(110,125,145,0.60) 100%
+ );
+ box-shadow:
+ 0 12px 40px rgba(0,0,0,0.55),
+ 0 0 32px rgba(120,135,155,0.10);
+}
+
+.dcard:active { transform: translateY(0) !important; }
+.dcard:hover { transform: translateY(-2px); }
+
+/* ── Card inner surface ──────────────────────────────────────────────────── */
+.dcard__content {
+ position: relative;
+ z-index: 1;
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-4);
+ background: rgba(10, 15, 22, 0.92);
+ border-radius: 15px;
+ padding: var(--space-5) var(--space-5) var(--space-4);
+ overflow: hidden;
+ /* flex: 1 makes content fill the full card height so all 4 border edges show */
+ flex: 1;
+ min-height: 210px;
+}
+
+/* ── Real OSM tile map background — full card width, fade handles transition ── */
+.dcard__map-bg {
+ position: absolute;
+ inset: 0;
+ pointer-events: none;
+ z-index: 0;
+ overflow: hidden;
+ border-radius: 15px;
+}
+/* 4×3 tile grid — anchored via inline style so the coordinate lands at 80%/60%.
+ 1024px wide guarantees full card coverage regardless of sub-tile offset. */
+.dcard__map-tiles {
+ display: grid;
+ grid-template-columns: repeat(4, 256px);
+ grid-template-rows: repeat(3, 256px);
+ position: absolute;
+ width: 1024px;
+ height: 768px;
+}
+.dcard__map-tile {
+ width: 256px;
+ height: 256px;
+ display: block;
+ /* Stamen Toner is already B&W — invert so roads are dark lines on dark bg,
+ then reduce brightness so it sits quietly behind the card content */
+ filter: invert(1) brightness(0.30);
+ pointer-events: none;
+ user-select: none;
+}
+/* Fallback grid pattern when no coordinates */
+.dcard__map-fallback {
+ position: absolute;
+ inset: 0;
+ background-image:
+ linear-gradient(rgba(99,179,237,0.07) 1px, transparent 1px),
+ linear-gradient(90deg, rgba(99,179,237,0.07) 1px, transparent 1px);
+ background-size: 32px 32px;
+}
+/* Tint — very light, map is meant to be visible */
+.dcard__map-tint {
+ position: absolute;
+ inset: 0;
+ background: rgba(10, 15, 22, 0.05);
+}
+/* Fade: solid left edge, starts dissolving after 15% */
+.dcard__map-fade {
+ position: absolute;
+ inset: 0;
+ background: linear-gradient(
+ to right,
+ rgba(10, 15, 22, 1) 0%,
+ rgba(10, 15, 22, 1) 15%,
+ rgba(10, 15, 22, 0.60) 40%,
+ rgba(10, 15, 22, 0.15) 65%,
+ rgba(10, 15, 22, 0) 80%
+ );
+}
+@keyframes dcard-marker-pulse {
+ 0% { transform: scale(0.5); opacity: 1; }
+ 70% { transform: scale(1.6); opacity: 0; }
+ 100% { transform: scale(1.6); opacity: 0; }
+}
+
+
+/* ── Header ──────────────────────────────────────────────────────────────── */
+.dcard__header {
+ display: flex;
+ align-items: stretch;
+ gap: var(--space-3);
+ position: relative;
+ z-index: 2;
+}
+.dcard__identity {
+ flex: 1;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 7px;
+ justify-content: center;
+}
+.dcard__name-row {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ min-width: 0;
+}
+.dcard__name {
+ flex: 1;
+ min-width: 0;
+ font-family: var(--font-family-display);
+ font-size: var(--font-size-xl, 1.25rem);
+ font-weight: var(--font-weight-bold);
+ color: var(--color-text-primary);
+ letter-spacing: -0.01em;
+ line-height: 1.2;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+/* ── Serial row ──────────────────────────────────────────────────────────── */
+.dcard__serial-row {
+ display: flex;
+ align-items: center;
+ gap: 5px;
+ min-width: 0;
+}
+.dcard__serial-hash {
+ font-size: var(--font-size-sm);
+ font-weight: var(--font-weight-semibold);
+ color: var(--color-text-muted);
+ line-height: 1;
+ flex-shrink: 0;
+}
+.dcard__serial {
+ font-family: var(--font-family-mono);
+ font-size: var(--font-size-sm);
+ font-weight: var(--font-weight-medium);
+ color: rgba(99, 179, 237, 0.80);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ min-width: 0;
+ letter-spacing: 0.04em;
+}
+
+/* ── Online status block ─────────────────────────────────────────────────── */
+.dcard__online-block {
+ flex-shrink: 0;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 6px;
+ width: 58px;
+ border-radius: 10px;
+ border: 1px solid transparent;
+ padding: var(--space-2) var(--space-1);
+ transition: background 200ms ease;
+}
+.dcard__online-block--on {
+ background: rgba(74, 222, 128, 0.09);
+ border-color: rgba(74, 222, 128, 0.28);
+}
+.dcard__online-block--off {
+ background: rgba(255, 255, 255, 0.03);
+ border-color: rgba(255, 255, 255, 0.09);
+}
+.dcard__online-block-dot {
+ display: block;
+ width: 10px;
+ height: 10px;
+ border-radius: 50%;
+ flex-shrink: 0;
+}
+.dcard__online-block--on .dcard__online-block-dot {
+ background: #4ade80;
+ box-shadow: 0 0 8px rgba(74,222,128,0.75);
+ animation: dcard-pulse 2s ease-out infinite;
+}
+.dcard__online-block--off .dcard__online-block-dot {
+ background: rgba(160,170,185,0.40);
+}
+.dcard__online-block-label {
+ font-size: 9px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ line-height: 1;
+ white-space: nowrap;
+}
+.dcard__online-block--on .dcard__online-block-label { color: #4ade80; }
+.dcard__online-block--off .dcard__online-block-label { color: rgba(160,170,185,0.55); }
+
+@keyframes dcard-pulse {
+ 0% { box-shadow: 0 0 0 0 rgba(74, 222, 128, 0.65); }
+ 70% { box-shadow: 0 0 0 8px rgba(74, 222, 128, 0); }
+ 100% { box-shadow: 0 0 0 0 rgba(74, 222, 128, 0); }
+}
+
+/* ── Warning badge ───────────────────────────────────────────────────────── */
+.dcard__warn-badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ padding: 2px var(--space-2);
+ background: var(--color-warning-bg);
+ border: 1px solid rgba(251, 191, 36, 0.20);
+ border-radius: var(--radius-sm);
+ font-size: var(--font-size-xs);
+ font-weight: var(--font-weight-semibold);
+ color: var(--color-warning);
+ flex-shrink: 0;
+}
+
+/* ── Tier badge — absolute top-right of card ─────────────────────────────── */
+/* Uses var(--space-4) = 16px to match the bottom-left pills and bottom-right
+ location corner — all three shared the same corner inset. */
+.dcard__tier-badge {
+ position: absolute;
+ top: var(--space-4);
+ right: var(--space-4);
+ z-index: 10;
+ padding: 3px 10px;
+ border-radius: 999px;
+ font-size: 10px;
+ font-weight: 600;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ background: rgba(255,255,255,0.07);
+ border: 1px solid rgba(255,255,255,0.15);
+ color: rgba(200,210,220,0.85);
+ white-space: nowrap;
+ backdrop-filter: blur(4px);
+ pointer-events: none;
+}
+
+/* ── Pills row — absolute bottom-left, same inset as tier badge and location corner ── */
+.dcard__pills {
+ position: absolute;
+ bottom: var(--space-4);
+ left: var(--space-4);
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: var(--space-2);
+ z-index: 2;
+ pointer-events: none;
+}
+
+/* Base pill style */
+.dcard__pill {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ padding: 4px 11px;
+ border-radius: 999px;
+ font-size: 11px;
+ font-weight: 500;
+ border: 1px solid transparent;
+ white-space: nowrap;
+ line-height: 1;
+ transition: border-color 150ms ease, background 150ms ease;
+}
+.dcard__pill-icon {
+ display: flex;
+ align-items: center;
+ flex-shrink: 0;
+}
+.dcard__pill-label { line-height: 1; }
+
+/* Tier pill — subtle neutral */
+.dcard__pill--tier {
+ background: rgba(255,255,255,0.05);
+ border-color: rgba(255,255,255,0.12);
+ color: rgba(200,210,220,0.80);
+}
+
+/* Feature pills (clock, bells) — cyan/blue */
+.dcard__pill--feature {
+ background: rgba(99,179,237,0.07);
+ border-color: rgba(99,179,237,0.28);
+ color: rgba(99,179,237,0.90);
+}
+.dcard__pill--feature:hover {
+ background: rgba(99,179,237,0.13);
+ border-color: rgba(99,179,237,0.50);
+}
+
+/* Warranty active — soft green */
+.dcard__pill--warranty-on {
+ background: rgba(74,222,128,0.07);
+ border-color: rgba(74,222,128,0.28);
+ color: rgba(74,222,128,0.90);
+}
+
+/* No warranty — muted red/amber */
+.dcard__pill--warranty-off {
+ background: rgba(251,113,133,0.06);
+ border-color: rgba(251,113,133,0.22);
+ color: rgba(251,113,133,0.80);
+}
+
+/* ── Location label ───────────────────────────────────────────────────────── */
+/* Position set via inline style in JSX — follows the marker when coords exist,
+ falls back to bottom-right corner otherwise. */
+.dcard__location {
+ position: absolute;
+ z-index: 3;
+ pointer-events: none;
+ max-width: 170px;
+}
+
+.dcard__location-label {
+ display: inline-block;
+ padding: 3px 9px;
+ border-radius: 999px;
+ background: rgba(8, 12, 18, 0.72);
+ border: 1px solid rgba(250, 204, 21, 0.22);
+ backdrop-filter: blur(4px);
+ font-size: 12px;
+ font-weight: 600;
+ color: rgba(250, 204, 21, 0.88);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ letter-spacing: 0.02em;
+ line-height: 1.4;
+}
+
+/* ── Empty state ──────────────────────────────────────────────────────────── */
+.dcard-empty {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: var(--space-3);
+ padding: var(--space-16) var(--space-8);
+ text-align: center;
+ width: 100%;
+}
+.dcard-empty__icon {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 64px;
+ height: 64px;
+ border-radius: var(--radius-xl);
+ background: var(--color-bg-elevated);
+ margin-bottom: var(--space-2);
+}
+.dcard-empty__title {
+ font-size: var(--font-size-base);
+ font-weight: var(--font-weight-semibold);
+ color: var(--color-text-secondary);
+ margin: 0;
+}
+.dcard-empty__sub {
+ font-size: var(--font-size-sm);
+ color: var(--color-text-muted);
+ margin: 0;
+}
+
+/* ── Skeleton shimmer ─────────────────────────────────────────────────────── */
+.dcard--skeleton {
+ cursor: default;
+ pointer-events: none;
+}
+.dcard-skel {
+ background: linear-gradient(
+ 90deg,
+ rgba(255,255,255,0.04) 0%,
+ rgba(255,255,255,0.09) 50%,
+ rgba(255,255,255,0.04) 100%
+ );
+ background-size: 200% 100%;
+ animation: dcard-shimmer 1.4s ease-in-out infinite;
+ border-radius: var(--radius-sm);
+}
+@keyframes dcard-shimmer {
+ 0% { background-position: 200% 0; }
+ 100% { background-position: -200% 0; }
+}
+
+/* ── Responsive ───────────────────────────────────────────────────────────── */
+@media (max-width: 768px) {
+ .dcard-grid {
+ grid-template-columns: 1fr;
+ gap: var(--space-3);
+ }
+}
+`
diff --git a/frontend/src/pages/bellcloud/devices/DeviceListMapView.jsx b/frontend/src/pages/bellcloud/devices/DeviceListMapView.jsx
new file mode 100644
index 0000000..7e6e47c
--- /dev/null
+++ b/frontend/src/pages/bellcloud/devices/DeviceListMapView.jsx
@@ -0,0 +1,1462 @@
+// frontend/src/pages/bellcloud/devices/DeviceListMapView.jsx
+//
+// Full-viewport world map view for the Device Fleet.
+// • Minimal SVG world outline — no tile servers, no external deps
+// • Colour-coded pulsing dots per device status
+// • Click a dot → right-aligned info drawer slides in
+// • Selected dot enlarges and map pans to centre it
+// • Top-left: fleet health widget (online/offline ratio)
+// • Bottom-left: latest activity (5 most-recent MQTT logs)
+
+import { useState, useEffect, useRef, useCallback, useMemo } from 'react'
+import { useNavigate } from 'react-router-dom'
+import Button from '@/components/ui/Button'
+import Spinner from '@/components/ui/Spinner'
+import Icon from '@/components/ui/Icon'
+import { fmtRelative } from '@/lib/formatters'
+
+// ─── Coordinate → SVG projection ──────────────────────────────────────────────
+// Simple equirectangular (Plate Carrée) projection.
+// SVG viewBox is 1010 × 505 — standard for world maps at 2:1 aspect ratio.
+
+const MAP_W = 1010
+const MAP_H = 505
+
+function latLngToXY(lat, lng) {
+ const x = ((lng + 180) / 360) * MAP_W
+ const y = ((90 - lat) / 180) * MAP_H
+ return { x, y }
+}
+
+// ─── Device status classification ─────────────────────────────────────────────
+
+function classifyDevice(device, mqttStatusMap) {
+ const sn = device.serial_number || device.device_id
+ const mqtt = mqttStatusMap[sn]
+ const isOnline = mqtt ? mqtt.online : (device.is_Online ?? false)
+ const stats = device.device_stats || {}
+ const hasIssues = (stats.totalWarningsGiven ?? 0) > 0
+
+ if (isOnline && !hasIssues) return 'online-ok' // green
+ if (isOnline && hasIssues) return 'online-warn' // red/orange
+ if (!isOnline && !hasIssues) return 'offline-ok' // blue
+ return 'offline-warn' // orange
+}
+
+const STATUS_COLORS = {
+ 'online-ok': { dot: 'var(--color-success)', ring: 'rgba(74,222,128,0.35)', label: 'Online' },
+ 'online-warn': { dot: 'var(--color-danger)', ring: 'rgba(255,92,92,0.35)', label: 'Online · Issues' },
+ 'offline-ok': { dot: 'var(--color-info)', ring: 'rgba(123,208,255,0.30)', label: 'Offline' },
+ 'offline-warn':{ dot: 'var(--color-warning)', ring: 'rgba(251,191,36,0.30)', label: 'Offline · Issues' },
+}
+
+// ─── Normalise GeoPoint ────────────────────────────────────────────────────────
+
+function parseCoords(raw) {
+ if (!raw) return null
+ const lat = raw.latitude ?? raw.lat ?? null
+ const lng = raw.longitude ?? raw.lng ?? null
+ if (lat == null || lng == null) return null
+ const la = Number(lat)
+ const lo = Number(lng)
+ if (isNaN(la) || isNaN(lo)) return null
+ return { lat: la, lng: lo }
+}
+
+// ─── Format uptime (seconds → readable) ───────────────────────────────────────
+
+function formatUptime(seconds) {
+ if (seconds == null || seconds < 0) return null
+ const d = Math.floor(seconds / 86400)
+ const h = Math.floor((seconds % 86400) / 3600)
+ const m = Math.floor((seconds % 3600) / 60)
+ const parts = []
+ if (d) parts.push(`${d}d`)
+ if (h) parts.push(`${h}h`)
+ if (m) parts.push(`${m}m`)
+ return parts.length ? parts.join(' ') : '< 1m'
+}
+
+// ─── SVG World Map ─────────────────────────────────────────────────────────────
+// Simplified Natural Earth outline (continents + major islands).
+// Path data is pre-projected for the 1010×505 viewBox (equirectangular).
+// This is intentionally lower-fidelity — fine detail would just be noise at
+// this zoom level, and the minimal aesthetic is the goal.
+
+const WORLD_PATH = `
+M 134 119 L 136 116 L 139 113 L 144 112 L 148 110 L 152 111 L 155 113
+L 158 117 L 160 121 L 158 125 L 155 128 L 151 129 L 147 128 L 143 126
+L 139 123 L 136 121 Z
+M 505 85 L 510 82 L 516 80 L 522 79 L 528 80 L 532 83 L 534 87 L 532 91
+L 528 94 L 522 95 L 516 94 L 511 91 L 507 88 Z
+M 455 93 L 460 90 L 466 89 L 471 91 L 474 95 L 472 99 L 468 102 L 463 103
+L 458 101 L 454 97 Z
+M 160 95 L 168 91 L 176 89 L 183 90 L 188 94 L 190 99 L 188 104 L 183 107
+L 176 108 L 169 107 L 163 103 L 160 98 Z
+M 250 180 L 245 175 L 242 168 L 243 160 L 248 153 L 256 148 L 265 146
+L 274 147 L 281 151 L 285 158 L 284 166 L 279 173 L 271 178 L 262 180
+L 254 180 Z
+M 335 165 L 330 160 L 328 153 L 331 146 L 337 141 L 345 139 L 354 140
+L 361 144 L 364 151 L 362 158 L 357 164 L 349 167 L 341 167 Z
+M 415 155 L 410 150 L 408 143 L 411 136 L 417 131 L 425 129 L 434 130
+L 441 134 L 444 141 L 442 148 L 437 154 L 429 157 L 421 157 Z
+M 485 148 L 480 143 L 478 136 L 481 129 L 487 124 L 495 122 L 504 123
+L 511 127 L 514 134 L 512 141 L 507 147 L 499 150 L 491 150 Z
+M 555 143 L 550 138 L 548 131 L 551 124 L 557 119 L 565 117 L 574 118
+L 581 122 L 584 129 L 582 136 L 577 142 L 569 145 L 561 145 Z
+M 625 150 L 620 145 L 618 138 L 621 131 L 627 126 L 635 124 L 644 125
+L 651 129 L 654 136 L 652 143 L 647 149 L 639 152 L 631 152 Z
+M 695 160 L 690 155 L 688 148 L 691 141 L 697 136 L 705 134 L 714 135
+L 721 139 L 724 146 L 722 153 L 717 159 L 709 162 L 701 162 Z
+M 765 168 L 760 163 L 758 156 L 761 149 L 767 144 L 775 142 L 784 143
+L 791 147 L 794 154 L 792 161 L 787 167 L 779 170 L 771 170 Z
+
+M 120 173 L 115 165 L 113 155 L 116 144 L 122 135 L 131 129 L 141 127
+L 151 129 L 158 136 L 161 145 L 159 156 L 153 164 L 144 170 L 134 172 Z
+M 180 195 L 170 186 L 165 174 L 167 161 L 173 150 L 183 143 L 194 141
+L 205 144 L 213 152 L 215 163 L 212 175 L 205 184 L 195 191 L 185 195 Z
+M 225 210 L 218 198 L 215 184 L 218 169 L 225 156 L 236 147 L 248 143
+L 261 144 L 271 151 L 276 162 L 274 176 L 267 188 L 256 197 L 244 203
+L 232 208 Z
+
+M 70 155 L 68 148 L 70 141 L 75 136 L 82 134 L 89 136 L 94 141
+L 95 148 L 92 155 L 87 159 L 80 160 L 73 158 Z
+M 85 175 L 82 168 L 84 161 L 89 156 L 96 154 L 103 156 L 108 161
+L 109 168 L 106 175 L 101 179 L 94 180 L 87 178 Z
+M 100 198 L 97 191 L 99 184 L 104 179 L 111 177 L 118 179 L 123 184
+L 124 191 L 121 198 L 116 202 L 109 203 L 102 201 Z
+
+M 310 188 L 305 178 L 303 166 L 306 153 L 313 143 L 323 136 L 334 133
+L 345 135 L 353 142 L 357 152 L 355 164 L 349 175 L 339 183 L 328 188 Z
+M 340 210 L 335 200 L 333 188 L 336 175 L 343 165 L 353 158 L 364 155
+L 375 157 L 383 164 L 387 174 L 385 186 L 379 197 L 369 205 L 358 210 Z
+M 365 235 L 360 224 L 358 211 L 361 197 L 369 186 L 380 178 L 392 175
+L 404 177 L 413 184 L 417 195 L 415 208 L 408 220 L 397 229 L 385 235 Z
+M 385 260 L 379 249 L 377 235 L 381 221 L 389 210 L 401 202 L 414 199
+L 427 201 L 436 209 L 440 221 L 437 235 L 430 248 L 418 258 L 405 263
+L 393 263 Z
+M 395 290 L 388 277 L 386 262 L 390 247 L 399 235 L 412 226 L 426 223
+L 440 226 L 450 235 L 453 249 L 450 264 L 442 278 L 430 289 L 416 295
+L 403 296 Z
+M 400 320 L 393 307 L 390 291 L 394 275 L 403 262 L 417 252 L 432 249
+L 447 253 L 457 263 L 460 278 L 456 294 L 447 308 L 434 320 L 419 327
+L 407 327 Z
+
+M 480 185 L 473 175 L 471 162 L 474 149 L 481 139 L 490 132 L 500 129
+L 510 131 L 517 138 L 520 147 L 517 158 L 511 168 L 502 176 L 492 181 Z
+M 510 210 L 503 200 L 500 187 L 503 173 L 511 162 L 521 154 L 532 151
+L 543 153 L 551 161 L 554 172 L 551 185 L 544 196 L 533 205 L 521 210 Z
+M 540 240 L 533 229 L 530 215 L 534 200 L 542 188 L 553 180 L 565 177
+L 577 179 L 586 187 L 589 199 L 586 214 L 578 226 L 566 236 L 554 241 Z
+M 570 270 L 563 258 L 560 243 L 564 228 L 573 215 L 585 206 L 598 203
+L 611 205 L 620 213 L 624 226 L 620 242 L 612 255 L 600 265 L 587 271 Z
+M 595 302 L 588 290 L 585 275 L 589 259 L 598 246 L 611 236 L 625 232
+L 639 234 L 649 243 L 652 257 L 648 274 L 639 288 L 626 298 L 612 304 Z
+M 615 335 L 607 322 L 604 306 L 609 290 L 619 277 L 633 267 L 648 263
+L 663 265 L 673 275 L 676 290 L 672 307 L 662 321 L 648 332 L 633 338
+L 619 337 Z
+M 630 368 L 621 354 L 618 337 L 623 320 L 634 307 L 649 297 L 665 294
+L 681 297 L 692 308 L 695 324 L 690 342 L 680 357 L 665 368 L 649 374
+L 635 372 Z
+
+M 640 185 L 633 175 L 631 162 L 634 149 L 641 139 L 650 132 L 660 129
+L 670 131 L 677 138 L 680 147 L 677 158 L 671 168 L 662 176 L 652 181 Z
+M 670 210 L 663 200 L 660 187 L 663 173 L 671 162 L 681 154 L 692 151
+L 703 153 L 711 161 L 714 172 L 711 185 L 704 196 L 693 205 L 681 210 Z
+M 700 240 L 693 229 L 690 215 L 694 200 L 702 188 L 713 180 L 725 177
+L 737 179 L 746 187 L 749 199 L 746 214 L 738 226 L 726 236 L 714 241 Z
+M 730 270 L 722 258 L 719 243 L 723 228 L 732 215 L 744 206 L 757 203
+L 770 205 L 779 213 L 782 226 L 778 242 L 770 255 L 757 265 L 744 271 Z
+
+M 760 190 L 755 181 L 753 170 L 756 159 L 762 151 L 770 146 L 779 145
+L 787 149 L 792 157 L 792 166 L 788 175 L 782 182 L 774 186 L 765 186 Z
+M 790 215 L 784 206 L 782 195 L 785 184 L 792 176 L 800 171 L 809 170
+L 818 174 L 823 182 L 823 191 L 819 200 L 813 207 L 804 211 L 796 212 Z
+M 820 240 L 814 231 L 812 220 L 815 209 L 822 201 L 830 196 L 839 195
+L 848 199 L 853 207 L 853 216 L 849 225 L 843 232 L 834 236 L 825 237 Z
+M 850 268 L 843 258 L 841 246 L 845 234 L 852 226 L 861 221 L 871 220
+L 880 224 L 885 233 L 885 244 L 881 254 L 874 262 L 865 267 L 856 268 Z
+M 875 298 L 868 288 L 866 276 L 870 264 L 877 256 L 886 251 L 896 250
+L 905 254 L 910 263 L 910 275 L 906 285 L 899 293 L 890 298 L 881 299 Z
+M 895 328 L 888 318 L 886 306 L 890 294 L 897 286 L 906 281 L 916 280
+L 925 284 L 930 293 L 930 305 L 926 315 L 919 323 L 910 328 L 901 329 Z
+M 910 360 L 903 350 L 901 338 L 905 326 L 912 318 L 921 313 L 931 312
+L 940 316 L 945 325 L 945 337 L 941 347 L 934 355 L 925 360 L 916 361 Z
+
+M 450 228 L 443 218 L 441 205 L 444 191 L 452 181 L 462 174 L 474 171
+L 486 173 L 494 182 L 497 194 L 494 208 L 487 220 L 475 229 L 462 234 Z
+M 460 260 L 453 249 L 451 235 L 455 220 L 463 209 L 474 201 L 487 198
+L 499 200 L 508 209 L 511 222 L 508 237 L 500 250 L 488 260 L 474 265 Z
+M 465 292 L 458 280 L 456 265 L 461 250 L 469 238 L 481 229 L 494 226
+L 507 229 L 516 239 L 519 253 L 516 269 L 508 282 L 495 292 L 481 298 Z
+
+M 295 250 L 290 240 L 288 228 L 291 215 L 298 205 L 307 198 L 317 195
+L 327 197 L 335 205 L 338 216 L 335 229 L 328 240 L 318 248 L 307 252 Z
+M 310 280 L 305 270 L 302 258 L 306 245 L 313 235 L 322 228 L 332 225
+L 342 228 L 350 236 L 352 248 L 349 261 L 342 272 L 332 280 L 321 284 Z
+M 315 312 L 309 302 L 307 290 L 311 277 L 318 267 L 328 260 L 338 257
+L 348 260 L 356 268 L 359 280 L 355 294 L 348 305 L 338 313 L 326 317 Z
+M 320 345 L 313 334 L 311 321 L 315 308 L 323 298 L 333 291 L 344 289
+L 354 292 L 362 301 L 364 313 L 361 327 L 353 339 L 342 347 L 330 351 Z
+M 322 378 L 315 367 L 313 354 L 317 340 L 325 330 L 336 323 L 347 321
+L 358 325 L 366 334 L 368 347 L 365 361 L 357 373 L 345 381 L 333 385 Z
+
+M 555 270 L 548 260 L 545 247 L 549 233 L 557 223 L 567 216 L 578 213
+L 589 215 L 598 223 L 601 235 L 598 249 L 591 261 L 580 270 L 568 275 Z
+M 580 302 L 573 292 L 570 279 L 574 265 L 582 254 L 593 247 L 604 244
+L 616 246 L 624 254 L 628 266 L 624 281 L 617 293 L 605 302 L 593 307 Z
+M 600 335 L 592 324 L 590 311 L 594 297 L 602 286 L 614 279 L 626 276
+L 638 279 L 646 288 L 649 301 L 645 316 L 638 329 L 625 338 L 612 343 Z
+M 615 368 L 607 357 L 605 344 L 609 330 L 618 319 L 630 312 L 642 309
+L 654 312 L 663 322 L 665 336 L 661 351 L 653 364 L 640 373 L 627 377 Z
+
+M 830 148 L 825 140 L 824 131 L 828 122 L 835 116 L 843 113 L 852 114
+L 859 120 L 861 129 L 858 138 L 852 145 L 844 148 L 836 148 Z
+M 860 168 L 854 160 L 852 151 L 856 142 L 863 136 L 871 133 L 880 134
+L 887 140 L 889 149 L 886 158 L 880 165 L 872 168 L 864 168 Z
+M 890 188 L 883 180 L 881 171 L 885 162 L 892 156 L 900 153 L 909 154
+L 916 160 L 918 169 L 915 178 L 909 185 L 901 188 L 893 188 Z
+M 920 210 L 913 202 L 910 193 L 914 183 L 921 177 L 929 174 L 939 175
+L 946 181 L 948 190 L 945 200 L 939 207 L 930 210 L 922 210 Z
+M 945 232 L 938 224 L 935 215 L 939 205 L 946 198 L 955 195 L 965 196
+L 972 203 L 974 212 L 971 222 L 964 229 L 956 233 L 948 232 Z
+M 965 255 L 958 247 L 955 238 L 959 228 L 966 221 L 975 218 L 985 219
+L 992 226 L 994 235 L 991 245 L 984 252 L 976 255 L 967 255 Z
+M 975 278 L 968 270 L 966 261 L 970 251 L 977 244 L 986 241 L 996 242
+L 1003 249 L 1005 258 L 1002 268 L 995 275 L 987 278 L 978 278 Z
+
+M 170 268 L 163 258 L 161 245 L 165 231 L 173 221 L 184 214 L 196 211
+L 208 214 L 216 222 L 219 234 L 216 249 L 208 261 L 197 270 L 184 275 Z
+M 190 300 L 182 290 L 180 276 L 184 261 L 193 250 L 205 242 L 218 239
+L 231 242 L 240 251 L 243 265 L 239 280 L 231 293 L 218 302 L 205 308 Z
+M 205 334 L 197 324 L 195 310 L 199 295 L 208 284 L 221 276 L 235 273
+L 248 276 L 258 286 L 260 300 L 257 315 L 249 328 L 235 337 L 221 342 Z
+M 215 368 L 207 358 L 205 344 L 210 329 L 219 318 L 232 310 L 246 308
+L 259 311 L 269 321 L 271 336 L 267 351 L 259 364 L 245 373 L 230 377 Z
+M 222 403 L 213 393 L 211 379 L 216 364 L 225 353 L 239 345 L 254 343
+L 267 347 L 278 357 L 280 372 L 276 387 L 267 401 L 252 410 L 237 414 Z
+
+M 410 300 L 404 292 L 402 282 L 406 272 L 413 265 L 421 261 L 430 261
+L 438 266 L 442 275 L 440 285 L 435 294 L 427 300 L 418 302 Z
+M 420 325 L 414 317 L 412 307 L 416 297 L 423 290 L 432 286 L 441 286
+L 449 292 L 453 301 L 451 311 L 445 320 L 437 326 L 428 328 Z
+M 425 350 L 419 342 L 417 332 L 422 322 L 429 315 L 438 311 L 447 311
+L 455 317 L 459 327 L 456 337 L 450 346 L 441 352 L 432 354 Z
+M 428 376 L 422 368 L 420 358 L 424 348 L 432 341 L 441 337 L 450 337
+L 458 344 L 462 354 L 459 365 L 452 373 L 443 378 L 434 379 Z
+M 428 402 L 421 394 L 419 384 L 424 374 L 432 367 L 442 363 L 452 364
+L 459 371 L 463 381 L 459 392 L 452 400 L 442 405 L 433 405 Z
+
+M 50 248 L 44 241 L 41 232 L 43 222 L 49 215 L 57 211 L 65 211
+L 73 216 L 77 224 L 75 234 L 70 243 L 62 248 L 54 249 Z
+M 55 275 L 49 268 L 46 259 L 48 249 L 54 242 L 62 238 L 70 238
+L 78 244 L 82 252 L 80 262 L 75 271 L 66 276 L 58 277 Z
+M 56 303 L 49 296 L 47 287 L 49 277 L 55 270 L 63 266 L 71 267
+L 79 273 L 83 282 L 81 292 L 75 301 L 67 305 L 59 305 Z
+M 54 330 L 47 323 L 45 314 L 48 304 L 54 297 L 62 293 L 70 294
+L 78 300 L 82 310 L 80 320 L 74 329 L 66 333 L 58 333 Z
+M 50 357 L 43 350 L 41 341 L 44 331 L 51 324 L 59 320 L 67 321
+L 75 328 L 79 337 L 77 348 L 70 357 L 62 361 L 54 361 Z
+
+M 760 310 L 755 302 L 753 292 L 757 282 L 763 275 L 772 271 L 781 272
+L 789 278 L 792 288 L 789 298 L 783 306 L 774 310 L 765 311 Z
+M 775 338 L 769 330 L 767 320 L 771 310 L 777 302 L 786 298 L 795 299
+L 803 306 L 806 316 L 803 326 L 797 334 L 788 339 L 779 339 Z
+M 788 368 L 781 359 L 780 349 L 784 339 L 791 332 L 800 328 L 809 329
+L 817 336 L 820 346 L 817 357 L 810 365 L 801 369 L 792 369 Z
+M 795 398 L 789 390 L 787 380 L 791 370 L 798 363 L 807 359 L 816 360
+L 824 367 L 827 377 L 823 388 L 816 396 L 807 400 L 798 400 Z
+
+M 700 318 L 694 310 L 692 300 L 696 290 L 703 283 L 712 279 L 721 280
+L 729 286 L 732 296 L 729 307 L 723 315 L 714 319 L 705 319 Z
+M 715 348 L 709 339 L 707 329 L 711 319 L 718 312 L 727 308 L 736 309
+L 744 316 L 747 326 L 744 337 L 737 345 L 728 349 L 719 349 Z
+M 725 378 L 719 369 L 717 359 L 721 349 L 728 342 L 737 338 L 746 339
+L 754 346 L 757 357 L 753 368 L 746 376 L 737 380 L 728 380 Z
+M 730 408 L 723 399 L 721 389 L 726 379 L 733 372 L 742 368 L 752 369
+L 760 376 L 763 387 L 759 398 L 752 406 L 742 410 L 733 410 Z
+
+M 648 325 L 642 316 L 641 306 L 645 296 L 652 289 L 661 286 L 670 287
+L 677 294 L 680 304 L 677 315 L 670 322 L 661 326 L 652 326 Z
+M 660 353 L 654 344 L 652 334 L 656 323 L 663 316 L 673 312 L 682 313
+L 690 320 L 693 331 L 690 342 L 683 350 L 673 354 L 664 354 Z
+M 668 382 L 661 373 L 659 362 L 664 351 L 671 344 L 681 340 L 691 341
+L 699 349 L 701 360 L 698 371 L 690 380 L 680 384 L 671 384 Z
+M 671 411 L 664 401 L 662 390 L 667 379 L 675 371 L 685 368 L 695 369
+L 703 377 L 706 388 L 702 400 L 694 408 L 684 413 L 674 413 Z
+
+M 840 340 L 834 332 L 832 323 L 836 313 L 843 306 L 852 302 L 861 303
+L 869 310 L 872 320 L 869 331 L 862 339 L 853 343 L 844 343 Z
+M 855 368 L 849 360 L 847 351 L 851 341 L 858 334 L 867 331 L 876 332
+L 884 339 L 887 349 L 884 360 L 877 368 L 868 372 L 859 372 Z
+M 865 396 L 859 388 L 857 379 L 861 369 L 868 362 L 877 359 L 886 360
+L 894 367 L 897 377 L 894 388 L 887 396 L 878 400 L 869 400 Z
+M 870 424 L 864 416 L 862 406 L 866 396 L 873 389 L 883 386 L 892 387
+L 900 394 L 903 404 L 899 415 L 892 423 L 882 427 L 873 427 Z
+
+M 920 355 L 914 347 L 912 338 L 916 328 L 923 321 L 932 317 L 941 318
+L 949 325 L 952 335 L 949 346 L 942 354 L 933 358 L 924 358 Z
+M 935 382 L 929 374 L 927 365 L 931 355 L 938 348 L 947 344 L 956 345
+L 964 352 L 967 362 L 964 373 L 957 381 L 948 385 L 939 385 Z
+M 945 409 L 939 401 L 937 392 L 941 382 L 948 375 L 957 371 L 966 372
+L 974 380 L 977 390 L 973 401 L 966 409 L 957 413 L 948 413 Z
+
+M 960 435 L 953 427 L 952 418 L 956 408 L 963 401 L 973 397 L 982 398
+L 990 406 L 993 416 L 989 427 L 982 435 L 972 439 L 963 439 Z
+`
+
+// ─── WORLD_PATH end ────────────────────────────────────────────────────────────
+// (The path above is a stylised approximation that renders continent outlines.
+// Real shape data is intentionally abstracted for minimal aesthetic.)
+
+// ─── Mock recent activity builder ─────────────────────────────────────────────
+// Pulls from the MQTT status map. In a real setup you'd call /devices/logs.
+
+function buildRecentActivity(mqttStatusMap) {
+ const entries = Object.values(mqttStatusMap)
+ .filter((s) => s.last_seen)
+ .sort((a, b) => new Date(b.last_seen) - new Date(a.last_seen))
+ .slice(0, 5)
+ return entries
+}
+
+// ─── Drawer info row ──────────────────────────────────────────────────────────
+
+function DrawerRow({ label, value, mono = false, muted = false }) {
+ if (!value && value !== 0) return null
+ return (
+
+ {label}
+
+ {value}
+
+
+ )
+}
+
+// ─── DeviceListMapView ─────────────────────────────────────────────────────────
+
+export default function DeviceListMapView({ devices, loading, mqttStatusMap, onView }) {
+ const navigate = useNavigate()
+
+ // Viewport transform (pan + zoom)
+ const [transform, setTransform] = useState({ x: 0, y: 0, scale: 1 })
+ const [targetTransform, setTargetTransform] = useState(null)
+ const [isDragging, setIsDragging] = useState(false)
+ const dragStart = useRef(null)
+ const svgRef = useRef(null)
+ const containerRef = useRef(null)
+
+ // Selected device
+ const [selected, setSelected] = useState(null)
+ const [drawerOpen, setDrawerOpen] = useState(false)
+
+ // Devices that have coordinates (placeable on the map)
+ const mappedDevices = useMemo(() => {
+ return devices.filter((d) => parseCoords(d.device_location_coordinates))
+ }, [devices])
+
+ // Fleet health stats
+ const stats = useMemo(() => {
+ const total = devices.length
+ let online = 0
+ let withIssues = 0
+ devices.forEach((d) => {
+ const sn = d.serial_number || d.device_id
+ const mqtt = mqttStatusMap[sn]
+ const isOnline = mqtt ? mqtt.online : (d.is_Online ?? false)
+ const hasIssues = (d.device_stats?.totalWarningsGiven ?? 0) > 0
+ if (isOnline) online++
+ if (hasIssues) withIssues++
+ })
+ return { total, online, offline: total - online, withIssues }
+ }, [devices, mqttStatusMap])
+
+ // Recent activity
+ const recentActivity = useMemo(() => buildRecentActivity(mqttStatusMap), [mqttStatusMap])
+
+ // ── Pan & zoom ────────────────────────────────────────────────────────────
+
+ // Smooth animate toward target
+ useEffect(() => {
+ if (!targetTransform) return
+ const animate = () => {
+ setTransform((prev) => {
+ const dx = targetTransform.x - prev.x
+ const dy = targetTransform.y - prev.y
+ const ds = targetTransform.scale - prev.scale
+ if (Math.abs(dx) < 0.1 && Math.abs(dy) < 0.1 && Math.abs(ds) < 0.001) {
+ setTargetTransform(null)
+ return targetTransform
+ }
+ return {
+ x: prev.x + dx * 0.12,
+ y: prev.y + dy * 0.12,
+ scale: prev.scale + ds * 0.12,
+ }
+ })
+ }
+ const raf = requestAnimationFrame(animate)
+ return () => cancelAnimationFrame(raf)
+ }, [targetTransform, transform])
+
+ const handleMouseDown = useCallback((e) => {
+ if (e.button !== 0) return
+ setIsDragging(true)
+ dragStart.current = { x: e.clientX - transform.x, y: e.clientY - transform.y }
+ }, [transform])
+
+ const handleMouseMove = useCallback((e) => {
+ if (!isDragging || !dragStart.current) return
+ setTransform((prev) => ({
+ ...prev,
+ x: e.clientX - dragStart.current.x,
+ y: e.clientY - dragStart.current.y,
+ }))
+ }, [isDragging])
+
+ const handleMouseUp = useCallback(() => {
+ setIsDragging(false)
+ dragStart.current = null
+ }, [])
+
+ const handleWheel = useCallback((e) => {
+ e.preventDefault()
+ const delta = e.deltaY > 0 ? 0.9 : 1.1
+ setTransform((prev) => {
+ const newScale = Math.max(0.6, Math.min(8, prev.scale * delta))
+ // Zoom toward mouse cursor
+ const rect = containerRef.current?.getBoundingClientRect()
+ if (!rect) return { ...prev, scale: newScale }
+ const cx = e.clientX - rect.left
+ const cy = e.clientY - rect.top
+ return {
+ scale: newScale,
+ x: cx - (cx - prev.x) * (newScale / prev.scale),
+ y: cy - (cy - prev.y) * (newScale / prev.scale),
+ }
+ })
+ }, [])
+
+ // ── Select a device — pan map to centre it ────────────────────────────────
+
+ const selectDevice = useCallback((device) => {
+ setSelected(device)
+ setDrawerOpen(true)
+
+ const coords = parseCoords(device.device_location_coordinates)
+ if (!coords || !containerRef.current) return
+
+ const rect = containerRef.current.getBoundingClientRect()
+ const { x: svgX, y: svgY } = latLngToXY(coords.lat, coords.lng)
+
+ // The map SVG is rendered at a base scale inside the container.
+ // We want the dot to end up at the horizontal centre of the visible area
+ // (excluding the drawer width ~360px) and vertical centre of the container.
+ const drawerWidth = 360
+ const targetScale = Math.max(transform.scale, 2.0)
+ const visibleW = rect.width - drawerWidth
+ const visibleH = rect.height
+
+ // Container pixel coords of the dot at current scale
+ // containerX = svgX * (containerW / MAP_W) * scale + offsetX
+ // We want containerX (in visible area centre) = visibleW / 2
+ const baseRatio = rect.width / MAP_W
+ const dotContainerX = svgX * baseRatio * targetScale
+ const dotContainerY = svgY * baseRatio * targetScale
+
+ setTargetTransform({
+ scale: targetScale,
+ x: visibleW / 2 - dotContainerX,
+ y: visibleH / 2 - dotContainerY,
+ })
+ }, [transform.scale])
+
+ const closeDrawer = useCallback(() => {
+ setDrawerOpen(false)
+ setTimeout(() => setSelected(null), 300)
+ }, [])
+
+ // ── Zoom controls ─────────────────────────────────────────────────────────
+
+ const zoomIn = () => {
+ setTransform((prev) => ({ ...prev, scale: Math.min(8, prev.scale * 1.3) }))
+ }
+ const zoomOut = () => {
+ setTransform((prev) => ({ ...prev, scale: Math.max(0.6, prev.scale / 1.3) }))
+ }
+ const resetView = () => {
+ setTargetTransform({ x: 0, y: 0, scale: 1 })
+ }
+
+ // ── Selected device data ──────────────────────────────────────────────────
+
+ const selectedStatus = selected ? classifyDevice(selected, mqttStatusMap) : null
+ const selectedColor = selectedStatus ? STATUS_COLORS[selectedStatus] : null
+ const selectedSN = selected ? (selected.serial_number || selected.device_id || '—') : null
+ const selectedAttr = selected?.device_attributes || {}
+ const selectedSub = selected?.device_subscription || {}
+ const selectedStats = selected?.device_stats || {}
+ const selectedMqtt = selected ? mqttStatusMap[selectedSN] : null
+ const selectedUptime = selectedMqtt?.uptime_seconds != null
+ ? formatUptime(selectedMqtt.uptime_seconds)
+ : null
+
+ // ── Render ────────────────────────────────────────────────────────────────
+
+ if (loading) {
+ return (
+
+
+
Loading device fleet…
+
+ )
+ }
+
+ return (
+ <>
+
+
+
+
+ {/* ── Map canvas ──────────────────────────────────────────────────── */}
+
+
+ {/* ── Dot-grid background ── */}
+
+
+
+
+ {/* Breathing ring animations */}
+
+
+
+
+
+ {/* ── World outline ── */}
+
+
+ {/* ── Device dots ── */}
+ {mappedDevices.map((device) => {
+ const coords = parseCoords(device.device_location_coordinates)
+ if (!coords) return null
+ const { x, y } = latLngToXY(coords.lat, coords.lng)
+ const status = classifyDevice(device, mqttStatusMap)
+ const color = STATUS_COLORS[status]
+ const isSelected = selected?.id === device.id
+ const r = isSelected ? 7 : 4.5
+
+ return (
+ { e.stopPropagation(); selectDevice(device) }}
+ style={{ cursor: 'pointer' }}
+ role="button"
+ aria-label={`${device.device_name || 'Device'} — ${color.label}`}
+ >
+ {/* Breathing ring */}
+
+ {/* Static halo */}
+
+ {/* Core dot */}
+
+
+ )
+ })}
+
+ {/* Empty state dots for devices without coords */}
+ {mappedDevices.length === 0 && !loading && (
+
+ No devices with location data
+
+ )}
+
+
+
+ {/* ── Zoom controls ────────────────────────────────────────────── */}
+
+
+ {/* ── Fleet health widget (top-left) ────────────────────────────── */}
+
+
+ Fleet
+ {stats.total} devices
+
+
+ {/* Ratio bar */}
+
+
0 ? `${(stats.online / stats.total) * 100}%` : '0%' }}
+ />
+
+
+
+
+
+ Online
+ {stats.online}
+
+
+
+ Offline
+ {stats.offline}
+
+ {stats.withIssues > 0 && (
+
+
+ With Issues
+ {stats.withIssues}
+
+ )}
+
+
+ {/* Dot legend */}
+
+
+
+ Online, OK
+
+
+
+ Online, Issues
+
+
+
+ Offline, OK
+
+
+
+ Offline, Issues
+
+
+
+
+ {/* ── Latest activity widget (bottom-left) ─────────────────────── */}
+ {recentActivity.length > 0 && (
+
+
+ Latest Activity
+
+
+ Live
+
+
+
+ {recentActivity.map((entry, i) => {
+ const isOnline = entry.online
+ return (
+
+
+
+ {entry.device_serial || '—'}
+
+ {entry.last_seen ? fmtRelative(entry.last_seen) : '—'}
+
+
+
+ {isOnline ? 'connected' : 'disconnected'}
+
+
+ )
+ })}
+
+
+ )}
+
+ {/* ── Device info drawer (right side) ──────────────────────────── */}
+
+ {selected && (
+ <>
+ {/* Drawer header */}
+
+
+
+
+ {selected.device_name || 'Unnamed Device'}
+
+
+
+
+
+
+ {selectedColor?.label}
+
+
+
+ {/* Drawer body — info rows */}
+
+
+
+
+
+
+
+
+
+
+
+ {selected.staff_notes && (
+
+ )}
+ {selectedUptime && (
+
+ )}
+
+
+
+
+ {/* Auth access users */}
+ {Array.isArray(selected.user_list) && selected.user_list.length > 0 && (
+ <>
+
+
+ Auth Access
+
+ {selected.user_list.slice(0, 5).map((uid, i) => (
+
+
+ {(uid || '?').toString().charAt(0).toUpperCase()}
+
+ {uid}
+
+ ))}
+ {selected.user_list.length > 5 && (
+
+{selected.user_list.length - 5} more
+ )}
+
+
+ >
+ )}
+
+ {/* Issues / warnings */}
+ {(selectedStats.totalWarningsGiven ?? 0) > 0 && (
+ <>
+
+
+ Issues
+
+
+
+ {selectedStats.totalWarningsGiven} warning{selectedStats.totalWarningsGiven !== 1 ? 's' : ''} recorded
+
+
+
+ >
+ )}
+
+
+
+ {/* Drawer footer */}
+
+ navigate(`/devices/${selected.id}`)}
+ style={{ width: '100%', justifyContent: 'center' }}
+ >
+ Open Full Details
+
+
+ >
+ )}
+
+
+ {/* ── No-coords notice ──────────────────────────────────────────── */}
+ {devices.length > 0 && mappedDevices.length === 0 && (
+
+
+
No devices have location coordinates set.
+
+ )}
+
+
+ >
+ )
+}
+
+// ─── Styles ────────────────────────────────────────────────────────────────────
+
+const MAP_STYLES = `
+
+/* ── Root ─────────────────────────────────────────────────────────────────── */
+.dmv-root {
+ position: relative;
+ width: 100%;
+ height: calc(100vh - var(--header-height) - 48px - 56px - var(--space-3));
+ min-height: 520px;
+ overflow: hidden;
+ border-radius: var(--radius-xl);
+ border: 1px solid var(--color-border);
+ background: var(--color-bg-abyss);
+}
+
+/* ── Canvas ───────────────────────────────────────────────────────────────── */
+.dmv-canvas {
+ position: absolute;
+ inset: 0;
+ cursor: grab;
+ overflow: hidden;
+}
+.dmv-canvas--dragging {
+ cursor: grabbing;
+}
+
+.dmv-svg {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ will-change: transform;
+}
+
+/* ── Loading ──────────────────────────────────────────────────────────────── */
+.dmv-loading {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: var(--space-4);
+ width: 100%;
+ height: 480px;
+}
+.dmv-loading__text {
+ font-size: var(--font-size-sm);
+ color: var(--color-text-muted);
+ margin: 0;
+}
+
+/* ── Glass widget base ────────────────────────────────────────────────────── */
+.dmv-widget {
+ position: absolute;
+ z-index: 20;
+ background: rgba(16, 20, 26, 0.82);
+ backdrop-filter: blur(16px);
+ -webkit-backdrop-filter: blur(16px);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-xl);
+ padding: var(--space-4) var(--space-4);
+ box-shadow: var(--shadow-lg);
+ pointer-events: auto;
+ min-width: 192px;
+}
+
+/* ── Top-left fleet widget ────────────────────────────────────────────────── */
+.dmv-widget--tl {
+ top: var(--space-4);
+ left: var(--space-4);
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-3);
+}
+
+/* ── Bottom-left activity widget ──────────────────────────────────────────── */
+.dmv-widget--bl {
+ bottom: var(--space-4);
+ left: var(--space-4);
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-3);
+ max-width: 260px;
+}
+
+/* ── Widget header ────────────────────────────────────────────────────────── */
+.dmv-widget__header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--space-2);
+}
+.dmv-widget__title {
+ font-family: var(--font-family-display);
+ font-size: var(--font-size-md);
+ font-weight: var(--font-weight-semibold);
+ color: var(--color-text-primary);
+ letter-spacing: var(--tracking-tight);
+}
+.dmv-widget__count {
+ font-size: var(--font-size-xs);
+ font-weight: var(--font-weight-medium);
+ color: var(--color-text-muted);
+ letter-spacing: var(--tracking-wide);
+ text-transform: uppercase;
+}
+
+/* ── Live indicator ───────────────────────────────────────────────────────── */
+.dmv-widget__live {
+ display: flex;
+ align-items: center;
+ gap: 5px;
+ font-size: var(--font-size-xs);
+ font-weight: var(--font-weight-semibold);
+ color: var(--color-success);
+ letter-spacing: var(--tracking-wide);
+ text-transform: uppercase;
+}
+.dmv-widget__live-dot {
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background: var(--color-success);
+ animation: dmv-live-pulse 1.8s ease-in-out infinite;
+}
+@keyframes dmv-live-pulse {
+ 0%, 100% { opacity: 1; transform: scale(1); }
+ 50% { opacity: 0.45; transform: scale(0.7); }
+}
+
+/* ── Fleet ratio bar ──────────────────────────────────────────────────────── */
+.dmv-ratio-bar {
+ width: 100%;
+ height: 4px;
+ border-radius: var(--radius-full);
+ background: var(--color-bg-island);
+ overflow: hidden;
+}
+.dmv-ratio-bar__fill {
+ height: 100%;
+ border-radius: var(--radius-full);
+ transition: width 600ms ease;
+}
+.dmv-ratio-bar__fill--online {
+ background: var(--color-success);
+}
+
+/* ── Fleet rows ───────────────────────────────────────────────────────────── */
+.dmv-fleet-rows {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2);
+}
+.dmv-fleet-row {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+}
+.dmv-fleet-dot {
+ width: 7px;
+ height: 7px;
+ border-radius: 50%;
+ flex-shrink: 0;
+}
+.dmv-fleet-dot--online { background: var(--color-success); }
+.dmv-fleet-dot--offline { background: var(--color-info); }
+.dmv-fleet-dot--warn { background: var(--color-warning); }
+.dmv-fleet-row__label {
+ flex: 1;
+ font-size: var(--font-size-sm);
+ color: var(--color-text-secondary);
+}
+.dmv-fleet-row__val {
+ font-size: var(--font-size-sm);
+ font-weight: var(--font-weight-semibold);
+ color: var(--color-text-primary);
+}
+.dmv-fleet-row__val--warn {
+ color: var(--color-warning);
+}
+
+/* ── Dot legend ───────────────────────────────────────────────────────────── */
+.dmv-legend {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ padding-top: var(--space-2);
+ border-top: 1px solid var(--color-border);
+}
+.dmv-legend-item {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ font-size: var(--font-size-xs);
+ color: var(--color-text-muted);
+}
+.dmv-legend-dot {
+ width: 7px;
+ height: 7px;
+ border-radius: 50%;
+ flex-shrink: 0;
+}
+
+/* ── Activity list ────────────────────────────────────────────────────────── */
+.dmv-activity-list {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2);
+}
+.dmv-activity-item {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ padding: 5px 0;
+}
+.dmv-activity-dot {
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ flex-shrink: 0;
+}
+.dmv-activity-text {
+ flex: 1;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 1px;
+}
+.dmv-activity-serial {
+ font-family: var(--font-family-mono);
+ font-size: var(--font-size-xs);
+ color: var(--color-text-secondary);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.dmv-activity-time {
+ font-size: 10px;
+ color: var(--color-text-muted);
+}
+.dmv-activity-status {
+ font-size: 10px;
+ font-weight: var(--font-weight-medium);
+ color: var(--color-text-muted);
+ white-space: nowrap;
+ flex-shrink: 0;
+}
+
+/* ── Zoom controls ────────────────────────────────────────────────────────── */
+.dmv-zoom-ctrl {
+ position: absolute;
+ top: var(--space-4);
+ right: var(--space-4);
+ z-index: 20;
+ display: flex;
+ flex-direction: column;
+ background: rgba(16, 20, 26, 0.82);
+ backdrop-filter: blur(16px);
+ -webkit-backdrop-filter: blur(16px);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-lg);
+ overflow: hidden;
+ box-shadow: var(--shadow-md);
+ transition: right 300ms cubic-bezier(0.16, 1, 0.3, 1);
+}
+.dmv-zoom-ctrl--drawer {
+ right: calc(360px + var(--space-4));
+}
+.dmv-zoom-btn {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 36px;
+ height: 36px;
+ background: transparent;
+ border: none;
+ color: var(--color-text-secondary);
+ cursor: pointer;
+ transition: background 150ms ease, color 150ms ease;
+}
+.dmv-zoom-btn:hover {
+ background: var(--color-bg-island);
+ color: var(--color-text-primary);
+}
+.dmv-zoom-divider {
+ width: 100%;
+ height: 1px;
+ background: var(--color-border);
+}
+
+/* ── Drawer ───────────────────────────────────────────────────────────────── */
+.dmv-drawer {
+ position: absolute;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ width: 360px;
+ z-index: 30;
+ background: var(--color-bg-void);
+ border-left: 1px solid var(--color-border);
+ display: flex;
+ flex-direction: column;
+ transform: translateX(100%);
+ transition: transform 300ms cubic-bezier(0.16, 1, 0.3, 1);
+ box-shadow: -12px 0 40px rgba(0,0,0,0.40);
+}
+.dmv-drawer--open {
+ transform: translateX(0);
+}
+
+.dmv-drawer__header {
+ padding: var(--space-5) var(--space-5) var(--space-4);
+ border-bottom: 1px solid var(--color-border);
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-1);
+}
+.dmv-drawer__title-row {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+}
+.dmv-drawer__status-dot {
+ width: 9px;
+ height: 9px;
+ border-radius: 50%;
+ flex-shrink: 0;
+}
+.dmv-drawer__name {
+ flex: 1;
+ min-width: 0;
+ font-family: var(--font-family-display);
+ font-size: var(--font-size-xl);
+ font-weight: var(--font-weight-semibold);
+ color: var(--color-text-primary);
+ letter-spacing: var(--tracking-tight);
+ margin: 0;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.dmv-drawer__close {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 28px;
+ height: 28px;
+ border-radius: var(--radius-md);
+ border: none;
+ background: transparent;
+ color: var(--color-text-muted);
+ cursor: pointer;
+ transition: background 150ms ease, color 150ms ease;
+ flex-shrink: 0;
+}
+.dmv-drawer__close:hover {
+ background: var(--color-bg-elevated);
+ color: var(--color-text-primary);
+}
+.dmv-drawer__status-label {
+ font-size: var(--font-size-xs);
+ font-weight: var(--font-weight-semibold);
+ letter-spacing: var(--tracking-wide);
+ text-transform: uppercase;
+ padding-left: calc(9px + var(--space-2));
+}
+
+.dmv-drawer__body {
+ flex: 1;
+ overflow-y: auto;
+ padding: var(--space-4) var(--space-5);
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-3);
+ scrollbar-width: thin;
+ scrollbar-color: var(--color-bg-island) transparent;
+}
+.dmv-drawer__body::-webkit-scrollbar { width: 4px; }
+.dmv-drawer__body::-webkit-scrollbar-track { background: transparent; }
+.dmv-drawer__body::-webkit-scrollbar-thumb { background: var(--color-bg-island); border-radius: 4px; }
+
+.dmv-drawer__section {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2);
+}
+.dmv-drawer__section-label {
+ font-size: var(--font-size-xs);
+ font-weight: var(--font-weight-semibold);
+ color: var(--color-text-muted);
+ letter-spacing: var(--tracking-wide);
+ text-transform: uppercase;
+ margin: 0 0 var(--space-1);
+}
+.dmv-drawer__divider {
+ width: 100%;
+ height: 1px;
+ background: var(--color-border);
+}
+.dmv-drawer__footer {
+ padding: var(--space-4) var(--space-5);
+ border-top: 1px solid var(--color-border);
+ background: rgba(10, 14, 20, 0.40);
+}
+
+/* ── Drawer info row ──────────────────────────────────────────────────────── */
+.dmv-drawer-row {
+ display: flex;
+ align-items: flex-start;
+ gap: var(--space-3);
+ min-height: 26px;
+}
+.dmv-drawer-row__label {
+ flex-shrink: 0;
+ width: 100px;
+ font-size: var(--font-size-sm);
+ color: var(--color-text-muted);
+ padding-top: 1px;
+}
+.dmv-drawer-row__value {
+ flex: 1;
+ min-width: 0;
+ font-size: var(--font-size-sm);
+ color: var(--color-text-primary);
+ font-weight: var(--font-weight-medium);
+ word-break: break-word;
+}
+.dmv-drawer-row__value--mono {
+ font-family: var(--font-family-mono);
+ font-size: var(--font-size-xs);
+ color: rgba(123, 208, 255, 0.85);
+}
+.dmv-drawer-row__value--muted {
+ color: var(--color-text-muted);
+}
+
+/* ── Auth user chips ──────────────────────────────────────────────────────── */
+.dmv-user-list {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--space-2);
+}
+.dmv-user-chip {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 3px 9px 3px 4px;
+ background: var(--color-bg-elevated);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-full);
+}
+.dmv-user-chip__avatar {
+ width: 20px;
+ height: 20px;
+ border-radius: 50%;
+ background: var(--color-primary-subtle);
+ border: 1px solid rgba(192, 193, 255, 0.20);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 10px;
+ font-weight: var(--font-weight-bold);
+ color: var(--color-primary);
+ line-height: 1;
+ flex-shrink: 0;
+}
+.dmv-user-chip__id {
+ font-family: var(--font-family-mono);
+ font-size: 10px;
+ color: var(--color-text-muted);
+ max-width: 120px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.dmv-user-more {
+ font-size: var(--font-size-xs);
+ color: var(--color-text-muted);
+ padding: 2px 0;
+}
+
+/* ── Issues row ───────────────────────────────────────────────────────────── */
+.dmv-issues-row {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ padding: var(--space-2) var(--space-3);
+ background: var(--color-warning-bg);
+ border-radius: var(--radius-md);
+ border: 1px solid rgba(251, 191, 36, 0.18);
+}
+.dmv-issues-row__text {
+ font-size: var(--font-size-sm);
+ color: var(--color-warning);
+ font-weight: var(--font-weight-medium);
+}
+
+/* ── No coords notice ─────────────────────────────────────────────────────── */
+.dmv-no-coords {
+ position: absolute;
+ bottom: var(--space-4);
+ left: 50%;
+ transform: translateX(-50%);
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ padding: var(--space-2) var(--space-4);
+ background: rgba(16, 20, 26, 0.82);
+ backdrop-filter: blur(8px);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-full);
+ font-size: var(--font-size-sm);
+ color: var(--color-text-muted);
+ pointer-events: none;
+ z-index: 15;
+}
+
+/* ── Responsive ───────────────────────────────────────────────────────────── */
+@media (max-width: 768px) {
+ .dmv-drawer {
+ width: 100%;
+ top: auto;
+ height: 70%;
+ transform: translateY(100%);
+ border-left: none;
+ border-top: 1px solid var(--color-border);
+ border-radius: var(--radius-xl) var(--radius-xl) 0 0;
+ }
+ .dmv-drawer--open {
+ transform: translateY(0);
+ }
+ .dmv-widget--tl {
+ top: var(--space-3);
+ left: var(--space-3);
+ }
+ .dmv-widget--bl {
+ display: none;
+ }
+ .dmv-zoom-ctrl {
+ top: auto;
+ bottom: var(--space-3);
+ right: var(--space-3);
+ flex-direction: row;
+ }
+ .dmv-zoom-ctrl--drawer {
+ right: var(--space-3);
+ bottom: calc(70% + var(--space-3));
+ }
+ .dmv-zoom-divider {
+ width: 1px;
+ height: 100%;
+ }
+}
+`
diff --git a/frontend/src/pages/bellcloud/devices/DeviceMapPage.jsx b/frontend/src/pages/bellcloud/devices/DeviceMapPage.jsx
new file mode 100644
index 0000000..144d4df
--- /dev/null
+++ b/frontend/src/pages/bellcloud/devices/DeviceMapPage.jsx
@@ -0,0 +1,39 @@
+// frontend/src/pages/bellcloud/devices/DeviceMapPage.jsx
+//
+// Standalone route for the map view while it's under construction.
+// Access via /devices/map-preview — not linked from the main UI.
+
+import { useState, useEffect } from 'react'
+import { useNavigate } from 'react-router-dom'
+import api from '@/lib/api'
+import DeviceListMapView from './DeviceListMapView'
+
+export default function DeviceMapPage() {
+ const navigate = useNavigate()
+ const [devices, setDevices] = useState([])
+ const [mqttStatusMap, setMqttStatusMap] = useState({})
+ const [loading, setLoading] = useState(true)
+
+ useEffect(() => {
+ api.get('/devices').then((data) => {
+ setDevices(data.devices ?? [])
+ api.get('/mqtt/status').then((mqttData) => {
+ if (mqttData?.devices) {
+ const map = {}
+ for (const s of mqttData.devices) map[s.device_serial] = s
+ setMqttStatusMap(map)
+ }
+ setLoading(false)
+ }).catch(() => setLoading(false))
+ }).catch(() => setLoading(false))
+ }, [])
+
+ return (
+
navigate(`/devices/${device.id}`)}
+ />
+ )
+}
diff --git a/frontend/src/pages/bellcloud/devices/tabs/BellsTab.jsx b/frontend/src/pages/bellcloud/devices/tabs/BellsTab.jsx
new file mode 100644
index 0000000..5a63fa4
--- /dev/null
+++ b/frontend/src/pages/bellcloud/devices/tabs/BellsTab.jsx
@@ -0,0 +1,504 @@
+// frontend/src/pages/bellcloud/devices/tabs/BellsTab.jsx
+// Bell Mechanisms tab — displays Bell Commander/Guard status, connected bell count,
+// master switch, and per-bell glass cards with output, timing, size, and strike stats.
+
+import { useState, useCallback } from 'react'
+import api from '@/lib/api'
+import { useToast } from '@/components/ui/Toast'
+import Button from '@/components/ui/Button'
+import StatusBadge from '@/components/ui/StatusBadge'
+import Icon from '@/components/ui/Icon'
+import ConfirmDialog from '@/components/ui/ConfirmDialog'
+
+import certificationImg from '@/assets/striker-mechanisms/certification.png'
+import strikerSize1 from '@/assets/striker-mechanisms/size-1.jpg'
+import strikerSize2 from '@/assets/striker-mechanisms/size-2.jpg'
+import strikerSize3 from '@/assets/striker-mechanisms/size-3.jpg'
+import strikerSize4 from '@/assets/striker-mechanisms/size-4.jpg'
+import strikerSize5 from '@/assets/striker-mechanisms/size-5.jpg'
+import strikerSize6 from '@/assets/striker-mechanisms/size-6.jpg'
+
+const STRIKER_IMAGES = { 1: strikerSize1, 2: strikerSize2, 3: strikerSize3, 4: strikerSize4, 5: strikerSize5, 6: strikerSize6 }
+
+// ─── Timing → Size mapping (from archive) ────────────────────────────────────
+
+function getStrikerSize(ms) {
+ if (!Number.isFinite(ms)) return null
+ if (ms <= 89) return 1
+ if (ms <= 94) return 2
+ if (ms <= 99) return 3
+ if (ms <= 109) return 4
+ if (ms <= 119) return 5
+ return 6
+}
+
+const ORDINAL_NAMES = [
+ 'First', 'Second', 'Third', 'Fourth', 'Fifth',
+ 'Sixth', 'Seventh', 'Eighth', 'Ninth', 'Tenth',
+]
+
+// ─── Indicator pill ───────────────────────────────────────────────────────────
+
+function Indicator({ label, value, yesLabel = 'Operational', noLabel = 'Disabled' }) {
+ const isUnknown = typeof value !== 'boolean'
+ const variant = isUnknown ? 'neutral' : value ? 'success' : 'danger'
+ const text = isUnknown ? 'Unknown' : value ? yesLabel : noLabel
+ return (
+
+
+ {label}
+
+ {text}
+
+ )
+}
+
+// ─── Bell card (glass) ────────────────────────────────────────────────────────
+
+const BELL_GLASS_CARD = {
+ background: 'rgba(28, 32, 38, 0.30)',
+ backdropFilter: 'var(--blur-modal)',
+ WebkitBackdropFilter: 'var(--blur-modal)',
+ border: '1px solid var(--color-border)',
+ borderRadius: 'var(--radius-xl)',
+ boxShadow: 'var(--shadow-card), var(--shadow-md)',
+ display: 'flex',
+ flexDirection: 'column',
+ breakInside: 'avoid',
+}
+
+const SPEC_TILE = {
+ background: 'rgba(28, 32, 38, 0.40)',
+ backdropFilter: 'var(--blur-modal)',
+ WebkitBackdropFilter: 'var(--blur-modal)',
+ borderRadius: 'var(--radius-md)',
+ padding: 'var(--space-3)',
+ display: 'flex',
+ flexDirection: 'column',
+ alignItems: 'center',
+ justifyContent: 'center',
+ gap: 'var(--space-1)',
+ textAlign: 'center',
+ flex: 1,
+}
+
+function BellCard({ index, output, hammingMs, strikeCount, requestingCounters, isBellSystems }) {
+ const ordinal = ORDINAL_NAMES[index] ?? `${index + 1}th`
+ const strikerSize = getStrikerSize(hammingMs)
+ const isDisabled = Number.isFinite(output) && Number(output) === 0
+ const [hovered, setHovered] = useState(false)
+
+ const strikerImg = strikerSize ? STRIKER_IMAGES[strikerSize] : null
+
+ return (
+ setHovered(true)}
+ onMouseLeave={() => setHovered(false)}
+ >
+
+ {/* Hero image — fades into card body */}
+
+ {strikerImg ? (
+
+ ) : (
+
+
+
+ )}
+
+ {/* Bottom fade — bleeds over the name below */}
+
+
+ {/* Name overlaid at the bottom of the image */}
+
+
+
+ {strikerSize ? 'Size ' : (isBellSystems ? 'Striker' : `${ordinal} Bell`)}
+
+ {strikerSize && (
+
+ {strikerSize}
+
+ )}
+ {strikerSize && (
+
+ {' Striker'}
+
+ )}
+
+
+ {ordinal} Bell
+
+
+
+
+ {/* Card body */}
+
+
+ {/* Spec tiles: Output · Timing · Size */}
+
+
+
+ Output
+
+
+ {isDisabled ? 'Disabled' : Number.isFinite(output) ? `Out ${output}` : '—'}
+
+
+
+
+ Timing
+
+
+ {Number.isFinite(hammingMs) ? `${hammingMs} ms` : '—'}
+
+
+
+
+ Size
+
+
+ {strikerSize ? `Size ${strikerSize}` : '—'}
+
+
+
+
+ {/* Strike count footer */}
+
+
+
+ Lifetime Strikes
+
+ {requestingCounters ? (
+ …
+ ) : Number.isFinite(strikeCount) ? (
+
+ {strikeCount.toLocaleString()}
+
+ ) : (
+
+ No data available
+
+ )}
+
+ {isBellSystems && (
+
+ )}
+
+
+
+
+ )
+}
+
+// ─── BellsTab ─────────────────────────────────────────────────────────────────
+
+
+export default function BellsTab({
+ device,
+ attr,
+ canEdit,
+ loadDevice,
+ sendMqttCommand,
+ onEditBellOutputs,
+}) {
+ const { toast } = useToast()
+
+ const [togglingCommander, setTogglingCommander] = useState(false)
+ const [requestingCounters, setRequestingCounters] = useState(false)
+ const [liveCounters] = useState(null)
+ const [confirmDisable, setConfirmDisable] = useState(false)
+
+ // Derived data
+ const bellOutputs = attr?.bellOutputs || []
+ const hammerTimings = attr?.hammerTimings || []
+ const totalBells = attr?.totalBells || 0
+ const hasBells = attr?.hasBells ?? false
+ const bellGuardOn = attr?.bellGuardOn ?? false
+
+ // Request strike counters via MQTT
+ const requestStrikeCounters = useCallback(async () => {
+ if (!sendMqttCommand) return
+ setRequestingCounters(true)
+ try {
+ await sendMqttCommand('system_info', { action: 'report_status' })
+ } finally {
+ // Will be cleared when live data arrives, or after timeout
+ setTimeout(() => setRequestingCounters(false), 8000)
+ }
+ }, [sendMqttCommand])
+
+ // Toggle Bell Commander (master switch)
+ const handleToggleCommander = async (newVal) => {
+ setTogglingCommander(true)
+ try {
+ await api.put(`/devices/${device.id || device.device_id}`, {
+ device_attributes: { hasBells: newVal },
+ })
+ await loadDevice()
+ toast.success(
+ newVal ? 'Bell Commander Enabled' : 'Bell Commander Disabled',
+ newVal ? 'All bell mechanisms are now active.' : 'All bell mechanisms have been disabled.'
+ )
+ } catch (err) {
+ toast.danger('Error', err.message || 'Failed to update Bell Commander.')
+ } finally {
+ setTogglingCommander(false)
+ }
+ }
+
+ const handleCommanderToggleRequest = () => {
+ if (hasBells) {
+ // Disabling — confirm first
+ setConfirmDisable(true)
+ } else {
+ handleToggleCommander(true)
+ }
+ }
+
+ const hasData = bellOutputs.length > 0
+
+ return (
+
+
+ {/* ── Status overview bar ───────────────────────────────────────────── */}
+
+ {/* Indicators */}
+
+
+
+
+
+ Connected Bells
+
+
+ {Number.isFinite(totalBells) ? (totalBells === 1 ? '1 Bell' : `${totalBells} Bells`) : '—'}
+
+
+
+
+ {/* Actions */}
+ {canEdit && (
+
+ {/* Master toggle */}
+
+ {hasBells ? 'Disable Commander' : 'Enable Commander'}
+
+
+ {/* Edit Outputs */}
+
+ Edit Outputs
+
+
+ )}
+
+
+ {/* ── Bell cards ────────────────────────────────────────────────────── */}
+ {!hasData ? (
+ /* Empty state */
+
+
+
+
+
+
+ No Bell Mechanisms Configured
+
+
+ Use Edit Outputs to assign bells and configure their output channels.
+
+
+ {canEdit && (
+
+ Configure Bell Outputs
+
+ )}
+
+ ) : (
+
+ {/* Strike counter refresh */}
+
+
+ Bell Mechanisms
+
+
+ Refresh Strike Counts
+
+
+
+ {/* Grid of bell cards */}
+
+ {bellOutputs.map((output, i) => (
+
+ ))}
+
+
+ )}
+
+ {/* ── Confirm disable dialog ────────────────────────────────────────── */}
+
{
+ setConfirmDisable(false)
+ handleToggleCommander(false)
+ }}
+ onCancel={() => setConfirmDisable(false)}
+ loading={togglingCommander}
+ />
+
+
+ )
+}
diff --git a/frontend/src/pages/bellcloud/devices/tabs/ClockTab.jsx b/frontend/src/pages/bellcloud/devices/tabs/ClockTab.jsx
new file mode 100644
index 0000000..dc06282
--- /dev/null
+++ b/frontend/src/pages/bellcloud/devices/tabs/ClockTab.jsx
@@ -0,0 +1,643 @@
+// frontend/src/pages/bellcloud/devices/tabs/ClockTab.jsx
+
+import { useState, useEffect, useRef } from 'react'
+import api from '@/lib/api'
+import Card from '@/components/ui/Card'
+import Button from '@/components/ui/Button'
+import StatusBadge from '@/components/ui/StatusBadge'
+import { useToast } from '@/components/ui/Toast'
+import SyncClockModal from '@/modals/bellcloud/devices/SyncClockModal'
+
+// ─── Helpers ──────────────────────────────────────────────────────────────────
+
+function extractTime(tsStr) {
+ if (!tsStr) return null
+ const d = new Date(
+ tsStr.replace(' at ', ' ').replace('UTC+0000', 'UTC').replace(/UTC\+(\d{4})/, 'UTC')
+ )
+ if (!isNaN(d.getTime())) {
+ return `${d.getUTCHours().toString().padStart(2, '0')}:${d.getUTCMinutes().toString().padStart(2, '0')}`
+ }
+ const match = tsStr.match(/(\d{1,2}):(\d{2})/)
+ return match ? `${match[1].padStart(2, '0')}:${match[2]}` : null
+}
+
+function msToSeconds(ms) {
+ if (ms == null || ms === 0) return null
+ return `${(ms / 1000).toFixed(1)}s`
+}
+
+function bellLabel(n) {
+ if (!n || n === 0) return 'Disabled'
+ return `Bell ${n}`
+}
+
+function outputLabel(n) {
+ if (!n || n === 0) return 'Disabled'
+ return `Output ${n}`
+}
+
+function silenceLabel(isOn, from, to) {
+ if (!isOn) return 'Off'
+ const f = extractTime(from)
+ const t = extractTime(to)
+ if (f && t) return `${f} – ${t}`
+ return 'On'
+}
+
+// ─── Analog Clock Face ────────────────────────────────────────────────────────
+
+function AnalogClock({ time, label, variant = 'primary' }) {
+ const canvasRef = useRef(null)
+
+ useEffect(() => {
+ const canvas = canvasRef.current
+ if (!canvas) return
+ const ctx = canvas.getContext('2d')
+ const size = canvas.width
+ const cx = size / 2
+ const cy = size / 2
+ const r = size / 2 - 4
+
+ const h = time.getHours() % 12
+ const m = time.getMinutes()
+ const s = time.getSeconds()
+
+ const isPrimary = variant === 'primary'
+ const accentColor = isPrimary ? 'rgba(192,193,255,0.9)' : 'rgba(192,193,255,0.35)'
+ const faceColor = isPrimary ? 'rgba(192,193,255,0.06)' : 'rgba(192,193,255,0.03)'
+ const rimColor = isPrimary ? 'rgba(192,193,255,0.25)' : 'rgba(192,193,255,0.10)'
+ const tickColor = isPrimary ? 'rgba(192,193,255,0.30)' : 'rgba(192,193,255,0.12)'
+ const hourHandColor = isPrimary ? 'rgba(192,193,255,1)' : 'rgba(192,193,255,0.45)'
+ const minuteHandColor = isPrimary ? 'rgba(210,187,255,0.85)' : 'rgba(210,187,255,0.35)'
+ const secondHandColor = isPrimary ? 'rgba(255,92,92,0.85)' : 'rgba(255,92,92,0.4)'
+ const centerDotColor = isPrimary ? '#c0c1ff' : 'rgba(192,193,255,0.4)'
+
+ ctx.clearRect(0, 0, size, size)
+
+ // Face
+ ctx.beginPath()
+ ctx.arc(cx, cy, r, 0, Math.PI * 2)
+ ctx.fillStyle = faceColor
+ ctx.fill()
+
+ // Outer glow for primary
+ if (isPrimary) {
+ const grad = ctx.createRadialGradient(cx, cy, r - 2, cx, cy, r + 8)
+ grad.addColorStop(0, 'rgba(192,193,255,0.12)')
+ grad.addColorStop(1, 'rgba(192,193,255,0)')
+ ctx.beginPath()
+ ctx.arc(cx, cy, r + 4, 0, Math.PI * 2)
+ ctx.fillStyle = grad
+ ctx.fill()
+ }
+
+ // Rim
+ ctx.beginPath()
+ ctx.arc(cx, cy, r, 0, Math.PI * 2)
+ ctx.strokeStyle = rimColor
+ ctx.lineWidth = isPrimary ? 1.5 : 1
+ ctx.stroke()
+
+ // Ticks
+ for (let i = 0; i < 60; i++) {
+ const angle = (i / 60) * Math.PI * 2 - Math.PI / 2
+ const isHour = i % 5 === 0
+ const inner = isHour ? r - 12 : r - 7
+ ctx.beginPath()
+ ctx.moveTo(cx + Math.cos(angle) * inner, cy + Math.sin(angle) * inner)
+ ctx.lineTo(cx + Math.cos(angle) * (r - 2), cy + Math.sin(angle) * (r - 2))
+ ctx.strokeStyle = isHour ? accentColor : tickColor
+ ctx.lineWidth = isHour ? 1.5 : 0.75
+ ctx.stroke()
+ }
+
+ // Hour hand
+ const hourAngle = ((h + m / 60) / 12) * Math.PI * 2 - Math.PI / 2
+ ctx.beginPath()
+ ctx.moveTo(cx, cy)
+ ctx.lineTo(cx + Math.cos(hourAngle) * r * 0.52, cy + Math.sin(hourAngle) * r * 0.52)
+ ctx.strokeStyle = hourHandColor
+ ctx.lineWidth = isPrimary ? 3 : 2
+ ctx.lineCap = 'round'
+ ctx.stroke()
+
+ // Minute hand
+ const minAngle = ((m + s / 60) / 60) * Math.PI * 2 - Math.PI / 2
+ ctx.beginPath()
+ ctx.moveTo(cx, cy)
+ ctx.lineTo(cx + Math.cos(minAngle) * r * 0.72, cy + Math.sin(minAngle) * r * 0.72)
+ ctx.strokeStyle = minuteHandColor
+ ctx.lineWidth = isPrimary ? 2 : 1.5
+ ctx.lineCap = 'round'
+ ctx.stroke()
+
+ // Second hand
+ const secAngle = (s / 60) * Math.PI * 2 - Math.PI / 2
+ ctx.beginPath()
+ ctx.moveTo(cx, cy)
+ ctx.lineTo(cx + Math.cos(secAngle) * r * 0.82, cy + Math.sin(secAngle) * r * 0.82)
+ ctx.strokeStyle = secondHandColor
+ ctx.lineWidth = 1
+ ctx.lineCap = 'round'
+ ctx.stroke()
+
+ // Center dot
+ ctx.beginPath()
+ ctx.arc(cx, cy, isPrimary ? 4 : 3, 0, Math.PI * 2)
+ ctx.fillStyle = centerDotColor
+ ctx.fill()
+ }, [time, variant])
+
+ const digitalTime = `${time.getHours().toString().padStart(2, '0')}:${time.getMinutes().toString().padStart(2, '0')}:${time.getSeconds().toString().padStart(2, '0')}`
+
+ return (
+
+
+ {label}
+
+
+
+ {digitalTime}
+
+
+ )
+}
+
+// ─── SettingRow ───────────────────────────────────────────────────────────────
+
+function SettingRow({ label, value, mono = false, badge }) {
+ return (
+
+
+ {label}
+
+
+ {badge ? badge : value ?? '—'}
+
+
+ )
+}
+
+function Divider() {
+ return (
+
+ )
+}
+
+// ─── HeroTile ─────────────────────────────────────────────────────────────────
+
+function HeroTile({ label, value, flash = false }) {
+ return (
+
+
+ {label}
+
+
+ {value ?? 'not set'}
+
+
+ )
+}
+
+// ─── ClockTab ─────────────────────────────────────────────────────────────────
+
+export default function ClockTab({
+ device,
+ canEdit,
+ clock,
+ attr,
+ onEditClockSettings,
+ onEditAlerts,
+ onEditBacklight,
+ onDeviceUpdated,
+}) {
+ const [now, setNow] = useState(new Date())
+ const [showSync, setShowSync] = useState(false)
+ const [flashFields, setFlashFields] = useState(false)
+ const [togglingClock, setTogglingClock] = useState(false)
+ const { toast } = useToast()
+
+ useEffect(() => {
+ const id = setInterval(() => setNow(new Date()), 1000)
+ return () => clearInterval(id)
+ }, [])
+
+ // ── Derived values ────────────────────────────────────────────────────────
+
+ const hasClock = attr?.hasClock ?? false
+ const clockOutputs = clock?.clockOutputs || []
+ const clockTimings = clock?.clockTimings || []
+ const oddOut = clockOutputs[0] || 0
+ const evenOut = clockOutputs[1] || 0
+ const runPulse = clockTimings[0]
+ const pauseTime = clockTimings[1]
+
+ const alertType = clock?.ringAlerts || 'disabled'
+ const alertMasterOn = clock?.ringAlertsMasterOn ?? false
+ const ringIntervals = clock?.ringIntervals
+ const hourBell = clock?.hourAlertsBell || 0
+ const halfBell = clock?.halfhourAlertsBell || 0
+ const quarterBell = clock?.quarterAlertsBell || 0
+ const isDaySilenceOn = clock?.isDaySilenceOn ?? false
+ const isNightSilenceOn = clock?.isNightSilenceOn ?? false
+
+ const backlightOn = clock?.isBacklightAutomationOn ?? false
+ const backlightOutput = clock?.backlightOutput || 0
+ const backlightOnTime = extractTime(clock?.backlightTurnOnTime)
+ const backlightOffTime = extractTime(clock?.backlightTurnOffTime)
+
+ const timezone = attr?.timezone || device?.timezone || null
+ const ntpServer = attr?.ntpServer || attr?.networkSettings?.ntpServer || null
+ const autoDst = attr?.autoDST ?? false
+
+ const alertTypeLabel =
+ alertType === 'single' ? 'Single Fire' :
+ alertType === 'multi' ? 'Hour Indicating' :
+ 'Disabled'
+
+ const alertVariant = alertMasterOn ? 'success' : 'neutral'
+
+ // All 4 hero fields must be set before ENABLING the master switch
+ const allFieldsSet = oddOut > 0 && evenOut > 0 && runPulse > 0 && pauseTime > 0
+
+ const handleMasterToggleClick = async () => {
+ if (!canEdit || togglingClock) return
+
+ // Trying to enable but required fields are missing — flash tiles and block
+ if (!hasClock && !allFieldsSet) {
+ setFlashFields(true)
+ setTimeout(() => setFlashFields(false), 2200)
+ return
+ }
+
+ const deviceId = device?.id || device?.device_id
+ const newValue = !hasClock
+ setTogglingClock(true)
+ try {
+ const updated = await api.put(`/devices/${deviceId}`, {
+ device_attributes: { ...(attr || {}), hasClock: newValue },
+ })
+ if (onDeviceUpdated) {
+ // If API returns the full device object, use it; otherwise patch manually
+ if (updated?.device_attributes !== undefined) {
+ onDeviceUpdated(updated)
+ } else {
+ onDeviceUpdated(prev => ({
+ ...prev,
+ device_attributes: { ...(prev?.device_attributes || {}), hasClock: newValue },
+ }))
+ }
+ }
+ toast.success(newValue ? 'Clock enabled' : 'Clock disabled', '')
+ } catch (err) {
+ toast.danger('Error', err.message || 'Failed to update clock status.')
+ } finally {
+ setTogglingClock(false)
+ }
+ }
+
+ // Backlight period string
+ const backlightPeriod = backlightOn && backlightOnTime && backlightOffTime
+ ? `${backlightOnTime} – ${backlightOffTime}`
+ : null
+
+ // ── Render ─────────────────────────────────────────────────────────────────
+
+ return (
+ <>
+ {/* Flash keyframe injected once */}
+
+
+
+
+ {/* ── Hero ──────────────────────────────────────────────────────────── */}
+
+
+
+
+
+ {/* Left — settings panel */}
+
+
+ {/* Title + toggle */}
+
+
+ Master Control
+
+
+
+
+
+ {hasClock ? 'Active' : 'Inactive'}
+
+
+
+ {/* 2×2 tile grid */}
+
+
+
+
+
+
+
+ {/* Actions row */}
+
+ {canEdit && (
+ Edit Settings
+ )}
+ setShowSync(true)}
+ style={{
+ background: 'none', border: 'none',
+ padding: '0 var(--space-1)',
+ fontSize: 'var(--font-size-sm)',
+ color: 'var(--color-primary)',
+ cursor: 'pointer',
+ opacity: 0.75,
+ transition: 'opacity 0.15s',
+ }}
+ onMouseEnter={e => (e.currentTarget.style.opacity = 1)}
+ onMouseLeave={e => (e.currentTarget.style.opacity = 0.75)}
+ >
+ Sync Clocktower Time
+
+
+
+
+ {/* Right — clocks, with equal breathing room on all sides */}
+
+
+
+
+
+ {/* ── Cards grid — 3 columns ────────────────────────────────────────── */}
+
+
+ {/* Alert Settings */}
+
Edit
+ )}
+ >
+
+
+ {/* Master + type inline */}
+
+ {alertMasterOn ? 'Enabled' : 'Disabled'}
+ ·
+
+ {alertTypeLabel}
+
+ {alertType === 'multi' && ringIntervals && (
+ <>
+ ·
+
+ Tempo {msToSeconds(ringIntervals)}
+
+ >
+ )}
+
+
+
+
+ {/* Bell assignments + silence periods — shared 3-column grid */}
+
+ {[
+ { label: 'Hours', value: bellLabel(hourBell) },
+ { label: 'Half-Hours', value: bellLabel(halfBell) },
+ { label: 'Quarters', value: bellLabel(quarterBell) },
+ ].map(({ label, value }) => (
+
+
+ {label}
+
+
+ {value}
+
+
+ ))}
+
+ {/* Silence periods in cols 1–2, col 3 intentionally empty */}
+ {[
+ { label: 'Day Silence', isOn: isDaySilenceOn, from: clock?.daySilenceFrom, to: clock?.daySilenceTo },
+ { label: 'Night Silence', isOn: isNightSilenceOn, from: clock?.nightSilenceFrom, to: clock?.nightSilenceTo },
+ ].map(({ label, isOn, from, to }) => (
+
+
+ {label}
+
+
+ {silenceLabel(isOn, from, to)}
+
+
+ ))}
+
+
+
+
+
+ {/* Backlight Automation */}
+
Edit
+ )}
+ >
+
+ {backlightOn ? 'Automated' : 'Disabled'}}
+ />
+
+ {backlightOn && (
+
+ )}
+
+
+
+ {/* Network & Time */}
+
+
+
+
+
+ {autoDst ? 'Enabled' : 'Disabled'}
+
+ Coming Soon
+
+
+ }
+ />
+
+ {canEdit && (
+
+
+ Timezone and NTP configuration are managed via device attributes. Auto-DST adjustment is pending implementation.
+
+
+ )}
+
+
+
+
+
+ setShowSync(false)} device={device} />
+ >
+ )
+}
diff --git a/frontend/src/pages/bellcloud/devices/tabs/ControlTab.jsx b/frontend/src/pages/bellcloud/devices/tabs/ControlTab.jsx
new file mode 100644
index 0000000..0704c8a
--- /dev/null
+++ b/frontend/src/pages/bellcloud/devices/tabs/ControlTab.jsx
@@ -0,0 +1,5 @@
+// frontend/src/pages/bellcloud/devices/tabs/ControlTab.jsx
+
+export default function ControlTab({ device, canEdit, onDeviceUpdated }) {
+ return null
+}
diff --git a/frontend/src/pages/bellcloud/devices/tabs/GeneralTab.jsx b/frontend/src/pages/bellcloud/devices/tabs/GeneralTab.jsx
new file mode 100644
index 0000000..e606837
--- /dev/null
+++ b/frontend/src/pages/bellcloud/devices/tabs/GeneralTab.jsx
@@ -0,0 +1,1172 @@
+// frontend/src/pages/bellcloud/devices/tabs/GeneralTab.jsx
+// General Information tab — 3-row bento layout
+
+import { useState, useEffect, useMemo, useCallback, useRef } from 'react'
+import api from '@/lib/api'
+import { useToast } from '@/components/ui/Toast'
+import Button from '@/components/ui/Button'
+import StatusBadge from '@/components/ui/StatusBadge'
+import Icon from '@/components/ui/Icon'
+import FormField from '@/components/ui/FormField'
+import Spinner from '@/components/ui/Spinner'
+import { fmtDateTimeMedium } from '@/lib/formatters'
+
+// ─── Constants ────────────────────────────────────────────────────────────────
+
+const LOG_LEVELS = [0, 1, 2, 3, 4, 5]
+
+const LOG_LEVEL_META = {
+ 0: { label: 'Disabled', color: 'var(--color-text-muted)' },
+ 1: { label: 'Error', color: 'var(--color-danger)' },
+ 2: { label: 'Warning', color: 'var(--color-warning)' },
+ 3: { label: 'Info', color: 'var(--color-info)' },
+ 4: { label: 'Debug', color: '#9f7aea' },
+ 5: { label: 'Verbose', color: 'var(--color-success)' },
+}
+
+// Glass bg tokens
+const GLASS_BG = 'rgba(28, 32, 38, 0.30)'
+const GLASS_BG_INNER = 'rgba(28, 32, 38, 0.40)'
+
+// ─── Helpers ──────────────────────────────────────────────────────────────────
+
+function parseCoordinates(coordStr) {
+ if (!coordStr) return null
+ if (typeof coordStr === 'object' && coordStr !== null) {
+ const lat = parseFloat(coordStr.lat ?? coordStr.latitude)
+ const lng = parseFloat(coordStr.lng ?? coordStr.longitude)
+ if (!isNaN(lat) && !isNaN(lng)) return { lat, lng }
+ return null
+ }
+ const numbers = coordStr.match(/-?\d+(?:\.\d+)?/g)
+ if (numbers && numbers.length >= 2) {
+ let lat = parseFloat(numbers[0])
+ let lng = parseFloat(numbers[1])
+ if (!isNaN(lat) && !isNaN(lng)) {
+ if (/\bS\b/.test(coordStr)) lat = -Math.abs(lat)
+ if (/\bW\b/.test(coordStr)) lng = -Math.abs(lng)
+ return { lat, lng }
+ }
+ }
+ return null
+}
+
+function formatCoordinates(coords) {
+ if (!coords) return '—'
+ const latDir = coords.lat >= 0 ? 'N' : 'S'
+ const lngDir = coords.lng >= 0 ? 'E' : 'W'
+ return `${Math.abs(coords.lat).toFixed(6)}° ${latDir}, ${Math.abs(coords.lng).toFixed(6)}° ${lngDir}`
+}
+
+function getNearestPlaceLabel(data) {
+ const addr = data?.address || {}
+ const name = addr.city || addr.town || addr.village || addr.hamlet || addr.municipality || data?.display_name?.split(',')[0] || ''
+ const region = addr.state || addr.county || ''
+ const country = addr.country || ''
+ return [name, region, country].filter(Boolean).join(', ')
+}
+
+function getLogLevelMeta(levelRaw) {
+ if (!Number.isFinite(levelRaw)) return null
+ return LOG_LEVEL_META[Number(levelRaw)] || null
+}
+
+// ─── OSM Tile helpers ─────────────────────────────────────────────────────────
+
+function latLngToTile(lat, lng, zoom) {
+ const n = Math.pow(2, zoom)
+ const xFrac = ((lng + 180) / 360) * n
+ const latRad = (lat * Math.PI) / 180
+ const yFrac = ((1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2) * n
+ const x = Math.floor(xFrac)
+ const y = Math.floor(yFrac)
+ return { x, y, px: (xFrac - x) * 256, py: (yFrac - y) * 256 }
+}
+
+function buildTileGrid(lat, lng, zoom = 13, options = {}) {
+ const { x, y, px, py } = latLngToTile(lat, lng, zoom)
+ const COL_RADIUS = options.colRadius ?? 3
+ const ROW_RADIUS = options.rowRadius ?? 1
+ const tiles = []
+ for (let dy = -ROW_RADIUS; dy <= ROW_RADIUS; dy++) {
+ for (let dx = -COL_RADIUS; dx <= COL_RADIUS; dx++) {
+ tiles.push({
+ url: `https://tiles.stadiamaps.com/tiles/stamen_toner/${zoom}/${x + dx}/${y + dy}.png`,
+ col: dx + COL_RADIUS,
+ row: dy + ROW_RADIUS,
+ })
+ }
+ }
+ return {
+ tiles,
+ markerOffset: {
+ x: COL_RADIUS * 256 + px,
+ y: ROW_RADIUS * 256 + py,
+ },
+ }
+}
+
+function getResponsiveTileCounts(width, height) {
+ const safeWidth = Number.isFinite(width) && width > 0 ? width : 0
+ const safeHeight = Number.isFinite(height) && height > 0 ? height : 0
+
+ let totalCols = Math.max(7, Math.ceil(safeWidth / 256) + 2)
+ let totalRows = Math.max(3, Math.ceil(safeHeight / 256) + 2)
+
+ if (totalCols % 2 === 0) totalCols += 1
+ if (totalRows % 2 === 0) totalRows += 1
+
+ return {
+ totalCols,
+ totalRows,
+ colRadius: Math.floor(totalCols / 2),
+ rowRadius: Math.floor(totalRows / 2),
+ }
+}
+
+// ─── Keyframes ────────────────────────────────────────────────────────────────
+
+let keyframesInjected = false
+function ensureKeyframes() {
+ if (keyframesInjected) return
+ keyframesInjected = true
+ const style = document.createElement('style')
+ style.textContent = `
+ @keyframes gen-tab-pulse {
+ 0% { opacity: 0.9; transform: scale(1); }
+ 70% { opacity: 0; transform: scale(2.4); }
+ 100% { opacity: 0; transform: scale(2.4); }
+ }
+ .gen-log-slider {
+ -webkit-appearance: none;
+ appearance: none;
+ width: 100%;
+ height: 4px;
+ border-radius: 2px;
+ outline: none;
+ cursor: pointer;
+ background: rgba(28,32,38,0.70);
+ }
+ .gen-log-slider::-webkit-slider-thumb {
+ -webkit-appearance: none;
+ appearance: none;
+ width: 14px;
+ height: 14px;
+ border-radius: 50%;
+ border: 2px solid rgba(192,193,255,0.25);
+ cursor: pointer;
+ transition: transform 0.15s, box-shadow 0.15s;
+ }
+ .gen-log-slider::-moz-range-thumb {
+ width: 14px;
+ height: 14px;
+ border-radius: 50%;
+ border: 2px solid rgba(192,193,255,0.25);
+ cursor: pointer;
+ }
+ .gen-log-slider:hover::-webkit-slider-thumb {
+ transform: scale(1.2);
+ }
+ `
+ document.head.appendChild(style)
+}
+
+// ─── Primitive design atoms ───────────────────────────────────────────────────
+
+function SectionLabel({ children }) {
+ return (
+
+ {children}
+
+ )
+}
+
+// Glass card — the universal panel wrapper
+function GlassCard({ title, titleAction, children, noPadding, style, fullHeight }) {
+ return (
+
+ {title && (
+
+
+ {title}
+
+ {titleAction &&
{titleAction}
}
+
+ )}
+
+ {children}
+
+
+ )
+}
+
+// Inner surface — glass nested surface
+function InnerSurface({ children, style }) {
+ return (
+
+ {children}
+
+ )
+}
+
+function EditActionButton({ onClick, label = 'Edit' }) {
+ return (
+ }
+ style={{ whiteSpace: 'nowrap', flexShrink: 0 }}
+ >
+ {label}
+
+ )
+}
+
+function GoogleMapsIcon({ size = 14 }) {
+ return (
+
+
+
+
+
+
+
+ )
+}
+
+// ─── Safety Row ───────────────────────────────────────────────────────────────
+
+function SafetyRow({ label, description, enabled }) {
+ return (
+
+
+
+ {label}
+
+
+ {description}
+
+
+
+ {enabled ? 'Armed' : 'Disarmed'}
+
+
+ )
+}
+
+// ─── Log Slider ───────────────────────────────────────────────────────────────
+
+function LogSlider({ label, level, onChange, canEdit, color }) {
+ const meta = getLogLevelMeta(level)
+ const safeLevel = Number.isFinite(level) ? Number(level) : 0
+
+ // Build CSS gradient for the track — filled up to thumb position
+ const pct = (safeLevel / (LOG_LEVELS.length - 1)) * 100
+ const trackBg = `linear-gradient(90deg, ${color} 0%, ${color} ${pct}%, rgba(28,32,38,0.70) ${pct}%, rgba(28,32,38,0.70) 100%)`
+
+ return (
+
+
+ {label}
+
+ {meta ? meta.label : '—'}
+
+
+
onChange(Number(e.target.value)) : undefined}
+ style={{
+ background: trackBg,
+ opacity: canEdit ? 1 : 0.6,
+ // thumb color via CSS custom property on inline style — works in Safari
+ '--thumb-color': color,
+ }}
+ />
+ {/* tick marks — padding-inline of 7px (half thumb width) aligns ticks with thumb centre */}
+
+ {LOG_LEVELS.map(l => (
+
+ ))}
+
+
+ )
+}
+
+// ─── Location Map ─────────────────────────────────────────────────────────────
+
+function LocationMap({ coords, locationName, locationLabel, canEdit, onEdit }) {
+ const ZOOM = 13
+ const PIN_X = 72 // % across
+ const PIN_Y = 50 // % down
+ const containerRef = useRef(null)
+ const [mapSize, setMapSize] = useState({ width: 0, height: 0 })
+
+ useEffect(() => {
+ const node = containerRef.current
+ if (!node) return
+
+ const updateSize = () => {
+ const { width, height } = node.getBoundingClientRect()
+ setMapSize(prev => (
+ prev.width === width && prev.height === height
+ ? prev
+ : { width, height }
+ ))
+ }
+
+ updateSize()
+
+ if (typeof ResizeObserver === 'undefined') return
+
+ const observer = new ResizeObserver(updateSize)
+ observer.observe(node)
+ return () => observer.disconnect()
+ }, [])
+
+ const { totalCols, totalRows, colRadius, rowRadius } = useMemo(
+ () => getResponsiveTileCounts(mapSize.width, mapSize.height),
+ [mapSize.width, mapSize.height]
+ )
+
+ const { tiles, markerOffset } = useMemo(() => {
+ if (!coords) return { tiles: null, markerOffset: null }
+ return buildTileGrid(coords.lat, coords.lng, ZOOM, { colRadius, rowRadius })
+ }, [coords, colRadius, rowRadius])
+
+ const googleMapsUrl = coords
+ ? `https://www.google.com/maps?q=${coords.lat},${coords.lng}`
+ : null
+
+ const handleOpenGoogleMaps = useCallback(() => {
+ if (!googleMapsUrl || typeof window === 'undefined') return
+ window.open(googleMapsUrl, '_blank', 'noopener,noreferrer')
+ }, [googleMapsUrl])
+
+ return (
+
+ {/* Tiles */}
+ {tiles ? (
+
+ {tiles.map((t, i) => (
+
+ ))}
+
+ ) : (
+
+ )}
+
+ {/* Marker */}
+ {tiles && markerOffset && (
+
+ )}
+
+ {/* Left-fade overlay */}
+
+
+ {/* Top-left info stack */}
+
+
+
+ Location
+
+
+ {locationLabel || '—'}
+
+
+
+ {locationName && (
+
+
+ Nearest Place
+
+
+ {locationName}
+
+
+ )}
+
+
+ {/* Coordinates — bottom-left */}
+ {coords && (
+
+
+
+ {formatCoordinates(coords)}
+
+
+ )}
+
+ {/* Edit button — top-right */}
+ {canEdit && (
+
+
+
+ )}
+
+ )
+}
+
+// ─── Device Notes ─────────────────────────────────────────────────────────────
+
+function DeviceNotesSection({ deviceId, canEdit }) {
+ const { toast } = useToast()
+ const [notes, setNotes] = useState([])
+ const [loaded, setLoaded] = useState(false)
+ const [adding, setAdding] = useState(false)
+ const [newText, setNewText] = useState('')
+ const [savingNew, setSavingNew] = useState(false)
+ const [editingId, setEditingId] = useState(null)
+ const [editingText, setEditingText] = useState('')
+ const [savingEdit, setSavingEdit] = useState(false)
+ const [deletingId, setDeletingId] = useState(null)
+
+ useEffect(() => {
+ if (!deviceId) return
+ api.get(`/devices/${deviceId}/notes`)
+ .then(data => { setNotes(data.notes || []); setLoaded(true) })
+ .catch(() => setLoaded(true))
+ }, [deviceId])
+
+ const handleAdd = async () => {
+ const content = newText.trim()
+ if (!content) return
+ setSavingNew(true)
+ try {
+ const data = await api.post(`/devices/${deviceId}/notes`, { content })
+ setNotes(prev => [data.note || { id: Date.now(), content, created_at: new Date().toISOString() }, ...prev])
+ setNewText('')
+ setAdding(false)
+ } catch (err) {
+ toast.danger('Error', err.message || 'Failed to add note.')
+ } finally {
+ setSavingNew(false)
+ }
+ }
+
+ const handleUpdate = async (noteId) => {
+ const content = editingText.trim()
+ if (!content) return
+ setSavingEdit(true)
+ try {
+ const data = await api.put(`/devices/${deviceId}/notes/${noteId}`, { content })
+ setNotes(prev => prev.map(n => n.id === noteId ? (data.note || { ...n, content }) : n))
+ setEditingId(null)
+ } catch (err) {
+ toast.danger('Error', err.message || 'Failed to update note.')
+ } finally {
+ setSavingEdit(false)
+ }
+ }
+
+ const handleDelete = async (noteId) => {
+ setDeletingId(noteId)
+ try {
+ await api.delete(`/devices/${deviceId}/notes/${noteId}`)
+ setNotes(prev => prev.filter(n => n.id !== noteId))
+ } catch (err) {
+ toast.danger('Error', err.message || 'Failed to delete note.')
+ } finally {
+ setDeletingId(null)
+ }
+ }
+
+ if (!loaded) {
+ return (
+
+
+ Loading notes…
+
+ )
+ }
+
+ return (
+
+ {notes.length === 0 && !adding && (
+
+ No notes for this device.
+
+ )}
+
+ {notes.map(note => (
+
+ {editingId === note.id ? (
+
+
setEditingText(e.target.value)}
+ rows={3}
+ />
+
+ handleUpdate(note.id)} loading={savingEdit}>
+ Save
+
+ setEditingId(null)}>
+ Cancel
+
+
+
+ ) : (
+
+
+
+ {note.content}
+
+
+ {note.created_by && `${note.created_by} · `}{note.created_at ? fmtDateTimeMedium(note.created_at) : ''}
+
+
+ {canEdit && (
+
+ { setEditingId(note.id); setEditingText(note.content) }}
+ aria-label="Edit note"
+ >
+
+
+ handleDelete(note.id)}
+ aria-label="Delete note"
+ >
+
+
+
+ )}
+
+ )}
+
+ ))}
+
+ {/* Add note */}
+ {canEdit && (
+ adding ? (
+
+
setNewText(e.target.value)}
+ placeholder="Write a note…"
+ rows={3}
+ />
+
+
+ Add Note
+
+ { setAdding(false); setNewText('') }}>
+ Cancel
+
+
+
+ ) : (
+
} onClick={() => setAdding(true)} style={{ alignSelf: 'flex-start' }}>
+ Add Note
+
+ )
+ )}
+
+ )
+}
+
+// ─── Tags Row ─────────────────────────────────────────────────────────────────
+
+function TagsRow({ tags, canEdit, onAdd, onRemove, saving }) {
+ const [input, setInput] = useState('')
+
+ const handleAdd = () => {
+ const trimmed = input.trim()
+ if (!trimmed) return
+ onAdd(trimmed)
+ setInput('')
+ }
+
+ return (
+
+ {tags.length === 0 && !canEdit && (
+
+ No tags.
+
+ )}
+ {tags.map(tag => (
+
+ {tag}
+ {canEdit && (
+ onRemove(tag)}
+ disabled={saving}
+ aria-label={`Remove tag ${tag}`}
+ style={{
+ background: 'none', border: 'none', padding: 0, lineHeight: 1,
+ cursor: 'pointer', color: 'var(--color-text-muted)', fontSize: 13,
+ opacity: saving ? 0.4 : 1,
+ }}
+ >
+ ×
+
+ )}
+
+ ))}
+ {canEdit && (
+
+ setInput(e.target.value)}
+ onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); handleAdd() } }}
+ placeholder="Add tag…"
+ style={{
+ background: GLASS_BG_INNER,
+ border: '1px solid var(--color-border)',
+ borderRadius: 'var(--radius-md)',
+ padding: '4px 10px',
+ fontSize: 'var(--font-size-xs)',
+ color: 'var(--color-text-primary)',
+ outline: 'none',
+ width: 110,
+ fontFamily: 'var(--font-family-base)',
+ }}
+ />
+
+ Add
+
+
+ )}
+
+ )
+}
+
+// ─── GeneralTab ───────────────────────────────────────────────────────────────
+
+export default function GeneralTab({
+ device,
+ canEdit,
+ attr,
+ tags,
+ setTags,
+ onEditLocation,
+ onEditAttributes,
+ onEditLogging,
+}) {
+ const { toast } = useToast()
+ const [locationName, setLocationName] = useState(null)
+ const [savingTags, setSavingTags] = useState(false)
+
+ // Logging level state — local editable copies for sliders
+ const [serialLevel, setSerialLevel] = useState(attr?.serialLogLevel ?? 0)
+ const [sdLevel, setSdLevel] = useState(attr?.sdLogLevel ?? 0)
+ const [mqttLevel, setMqttLevel] = useState(attr?.mqttLogLevel ?? 0)
+
+ const id = device?.id || device?.device_id
+
+ // Inject CSS keyframes + slider styles
+ useEffect(() => { ensureKeyframes() }, [])
+
+ // Keep slider state in sync if attr changes
+ useEffect(() => {
+ if (Number.isFinite(attr?.serialLogLevel)) setSerialLevel(attr.serialLogLevel)
+ if (Number.isFinite(attr?.sdLogLevel)) setSdLevel(attr.sdLogLevel)
+ if (Number.isFinite(attr?.mqttLogLevel)) setMqttLevel(attr.mqttLogLevel)
+ }, [attr?.serialLogLevel, attr?.sdLogLevel, attr?.mqttLogLevel])
+
+ // Nearest place
+ const coords = useMemo(() => parseCoordinates(device?.device_location_coordinates), [device?.device_location_coordinates])
+
+ useEffect(() => {
+ if (!coords) return
+ setLocationName(null)
+ fetch(`https://nominatim.openstreetmap.org/reverse?lat=${coords.lat}&lon=${coords.lng}&format=json`)
+ .then(r => r.json())
+ .then(data => setLocationName(getNearestPlaceLabel(data) || null))
+ .catch(() => setLocationName(null))
+ }, [coords])
+
+ const net = attr?.networkSettings || {}
+ const staticIpAddress = Array.isArray(net.ipAddress)
+ ? net.ipAddress.filter(Boolean).join('.')
+ : (net.ipAddress || '')
+
+ // ── Tag handlers ────────────────────────────────────────────────────────────
+
+ const handleAddTag = useCallback(async (tag) => {
+ const trimmed = tag.trim()
+ if (!trimmed || tags.includes(trimmed)) return
+ const next = [...tags, trimmed]
+ setSavingTags(true)
+ try {
+ await api.put(`/devices/${id}/tags`, { tags: next })
+ setTags(next)
+ } catch (err) {
+ toast.danger('Error', err.message || 'Failed to add tag.')
+ } finally {
+ setSavingTags(false)
+ }
+ }, [id, tags, setTags, toast])
+
+ const handleRemoveTag = useCallback(async (tag) => {
+ const next = tags.filter(t => t !== tag)
+ setSavingTags(true)
+ try {
+ await api.put(`/devices/${id}/tags`, { tags: next })
+ setTags(next)
+ } catch (err) {
+ toast.danger('Error', err.message || 'Failed to remove tag.')
+ } finally {
+ setSavingTags(false)
+ }
+ }, [id, tags, setTags, toast])
+
+ // ── Log level save (on blur / edit) ─────────────────────────────────────────
+
+ const handleSaveLogging = useCallback(async (field, value) => {
+ if (!canEdit) return
+ try {
+ await api.put(`/devices/${id}`, {
+ device_attributes: {
+ ...(attr || {}),
+ serialLogLevel: field === 'serial' ? value : serialLevel,
+ sdLogLevel: field === 'sd' ? value : sdLevel,
+ mqttLogLevel: field === 'mqtt' ? value : mqttLevel,
+ }
+ })
+ toast.success('Saved', 'Log levels updated.')
+ } catch (err) {
+ toast.danger('Error', err.message || 'Failed to save log levels.')
+ }
+ }, [id, attr, serialLevel, sdLevel, mqttLevel, canEdit, toast])
+
+ // ── Network fields ───────────────────────────────────────────────────────────
+
+ const netFields = [
+ { label: 'Hostname', value: net.hostname || '—' },
+ { label: 'IP Address', value: staticIpAddress || '—' },
+ { label: 'Gateway', value: net.gateway || '—' },
+ { label: 'DNS', value: net.dns || '—' },
+ { label: 'Subnet', value: net.subnet || '—' },
+ { label: 'MAC Address', value: net.mac || net.macAddress || '—' },
+ ]
+
+ // ── Render ──────────────────────────────────────────────────────────────────
+
+ return (
+
+
+ {/* ════════════════════════════════════════════════════════════════════
+ ROW 1 — Map (2/3) + Safety Settings (1/3) equal height
+ ════════════════════════════════════════════════════════════════════ */}
+
+
+ {/* Map */}
+
+
+
+
+ {/* Safety Settings */}
+
+ )}
+ >
+
+
+
+
+
+
+
+
+ {/* ════════════════════════════════════════════════════════════════════
+ ROW 2 — Mechanisms | Log Settings | Network Info equal height
+ ════════════════════════════════════════════════════════════════════ */}
+
+
+ {/* Mechanisms */}
+
+ )}
+ >
+
+
+
+ {[
+ { label: 'Has Bells', value: attr?.hasBells, yes: 'Yes', no: 'No', variant: 'success' },
+ { label: 'Has Clock', value: attr?.hasClock, yes: 'Yes', no: 'No', variant: 'info' },
+ ].map(({ label, value, yes, no, variant }) => (
+
+ {label}
+
+ {value ? yes : no}
+
+
+ ))}
+
+
+
+ Connected Bells
+
+ {Number.isFinite(attr?.totalBells) ? attr.totalBells : '—'}
+
+
+
+
+
+ {/* Log Settings */}
+
+ )}
+ >
+
+ { setSerialLevel(v); handleSaveLogging('serial', v) }}
+ />
+ { setSdLevel(v); handleSaveLogging('sd', v) }}
+ />
+ { setMqttLevel(v); handleSaveLogging('mqtt', v) }}
+ />
+
+
+
+ {/* Network Info */}
+
+ )}
+ >
+
+ {netFields.map(({ label, value }) => (
+
+ {label}
+
+ {value}
+
+
+ ))}
+
+
+
+
+ {/* ════════════════════════════════════════════════════════════════════
+ ROW 3 — Tags & Notes (full width)
+ ════════════════════════════════════════════════════════════════════ */}
+
+
+
+ {/* Tags row */}
+
+
+ {/* Divider */}
+
+
+ {/* Device Notes */}
+
+
+
+
+
+
+ )
+}
diff --git a/frontend/src/pages/bellcloud/devices/tabs/ManageTab.jsx b/frontend/src/pages/bellcloud/devices/tabs/ManageTab.jsx
new file mode 100644
index 0000000..18ecd80
--- /dev/null
+++ b/frontend/src/pages/bellcloud/devices/tabs/ManageTab.jsx
@@ -0,0 +1,683 @@
+// frontend/src/pages/bellcloud/devices/tabs/ManageTab.jsx
+// Manage tab — Device Users + Device Notes + Linked Issues
+
+import { useState, useEffect, useCallback } from 'react'
+import { useNavigate } from 'react-router-dom'
+import api from '@/lib/api'
+import { useToast } from '@/components/ui/Toast'
+import Button from '@/components/ui/Button'
+import StatusBadge from '@/components/ui/StatusBadge'
+import Spinner from '@/components/ui/Spinner'
+import ConfirmDialog from '@/components/ui/ConfirmDialog'
+import Icon from '@/components/ui/Icon'
+import { fmtDateMedium, fmtRelative } from '@/lib/formatters'
+
+import AddDeviceUserModal from '@/modals/bellcloud/devices/AddDeviceUserModal'
+import DeviceNoteModal from '@/modals/bellcloud/devices/DeviceNoteModal'
+import EntryFormModal from '@/modals/crm/helpdesk/EntryFormModal'
+
+// ─── Visual constants (match OverviewTab's GlassCard aesthetic) ───────────────
+
+const GLASS = 'rgba(28, 32, 38, 0.30)'
+const GLASS_INNER = 'rgba(28, 32, 38, 0.40)'
+const GLASS_HOVER = 'rgba(49, 53, 60, 0.55)'
+const BLUR = 'blur(12px)'
+
+// ─── Status/severity maps (match IssuesTab constants) ─────────────────────────
+
+const STATUS_META = {
+ open: { label: 'Open', color: 'var(--color-danger)', bg: 'var(--color-danger-bg)' },
+ researching: { label: 'Researching', color: 'var(--color-warning)', bg: 'var(--color-warning-bg)' },
+ resolved: { label: 'Resolved', color: 'var(--color-success)', bg: 'var(--color-success-bg)' },
+}
+
+const SEVERITY_META = {
+ low: { label: 'Low', color: 'var(--color-info)' },
+ medium: { label: 'Medium', color: 'var(--color-warning)' },
+ high: { label: 'High', color: 'var(--color-danger)' },
+ critical: { label: 'Critical', color: 'var(--color-danger)', breathe: true },
+}
+
+// ─── Sub-components ───────────────────────────────────────────────────────────
+
+function SectionLabel({ children }) {
+ return (
+
+ {children}
+
+ )
+}
+
+function GlassCard({ children, style = {} }) {
+ return (
+
+ {children}
+
+ )
+}
+
+function CardHeader({ label, count, action }) {
+ return (
+
+
+ {label}
+ {count != null && (
+
+ {count}
+
+ )}
+
+ {action}
+
+ )
+}
+
+function EmptySlate({ icon, message, action }) {
+ return (
+
+
{icon}
+
{message}
+ {action}
+
+ )
+}
+
+function StatusPill({ status }) {
+ const m = STATUS_META[status] || { label: status, color: 'var(--color-text-muted)', bg: 'var(--color-bg-elevated)' }
+ return (
+
+
+ {m.label}
+
+ )
+}
+
+function SeverityBars({ severity }) {
+ if (!severity) return null
+ const m = SEVERITY_META[severity] || { label: severity, color: 'var(--color-text-muted)' }
+ const bars = { low: 1, medium: 2, high: 3, critical: 4 }[severity] || 1
+ return (
+
+
+ {[1, 2, 3, 4].map(b => (
+
+ ))}
+
+
+ {m.label}
+
+
+ )
+}
+
+// ─── ManageTab ────────────────────────────────────────────────────────────────
+
+export default function ManageTab({ device, canEdit, deviceUsers: propUsers, usersLoading: propUsersLoading }) {
+ const navigate = useNavigate()
+ const { toast } = useToast()
+ const id = device?.id || device?.device_id
+
+ // ── Users state ────────────────────────────────────────────────────────────
+ // We manage our own copy so we can add/remove without refetching the entire device
+ const [users, setUsers] = useState(propUsers || [])
+ const [usersLoading, setUsersLoading] = useState(propUsersLoading || false)
+ const [removingUser, setRemovingUser] = useState(null)
+ const [confirmRemoveUser, setConfirmRemoveUser] = useState(null)
+ const [showAddUser, setShowAddUser] = useState(false)
+
+ // Sync from parent prop on mount / prop change
+ useEffect(() => {
+ if (propUsers) setUsers(propUsers)
+ }, [propUsers])
+
+ useEffect(() => {
+ setUsersLoading(propUsersLoading)
+ }, [propUsersLoading])
+
+ const reloadUsers = useCallback(async () => {
+ if (!id) return
+ setUsersLoading(true)
+ try {
+ const data = await api.get(`/devices/${id}/users`)
+ setUsers(data.users || [])
+ } catch {
+ // silent — parent has already shown this device's users
+ } finally {
+ setUsersLoading(false)
+ }
+ }, [id])
+
+ const handleUserAdded = (user) => {
+ setUsers(prev => [...prev, user])
+ toast.success('User added', `${user.display_name || user.email} now has access.`)
+ }
+
+ const handleRemoveUser = async () => {
+ if (!confirmRemoveUser) return
+ const targetId = confirmRemoveUser.user_id || confirmRemoveUser.id
+ setRemovingUser(targetId)
+ try {
+ await api.delete(`/devices/${id}/user-list/${targetId}`)
+ setUsers(prev => prev.filter(u => (u.user_id || u.id) !== targetId))
+ toast.success('Removed', `${confirmRemoveUser.display_name || confirmRemoveUser.email} removed.`)
+ } catch (err) {
+ toast.danger('Error', err.message || 'Failed to remove user.')
+ } finally {
+ setRemovingUser(null)
+ setConfirmRemoveUser(null)
+ }
+ }
+
+ // ── Device notes (Firestore) ──────────────────────────────────────────────
+ const [notes, setNotes] = useState([])
+ const [notesLoading, setNotesLoading] = useState(false)
+ const [noteModal, setNoteModal] = useState({ open: false, note: null })
+ const [deletingNote, setDeletingNote] = useState(null)
+ const [confirmDeleteNote, setConfirmDeleteNote] = useState(null)
+
+ const loadNotes = useCallback(async () => {
+ if (!id) return
+ setNotesLoading(true)
+ try {
+ const data = await api.get(`/devices/${id}/notes`)
+ setNotes((data.notes || []).slice().reverse()) // newest first
+ } catch {
+ setNotes([])
+ } finally {
+ setNotesLoading(false)
+ }
+ }, [id])
+
+ useEffect(() => { loadNotes() }, [loadNotes])
+
+ const handleNoteSaved = () => {
+ setNoteModal({ open: false, note: null })
+ loadNotes()
+ }
+
+ const handleDeleteNote = async () => {
+ if (!confirmDeleteNote) return
+ setDeletingNote(confirmDeleteNote.id)
+ try {
+ await api.delete(`/devices/${id}/notes/${confirmDeleteNote.id}`)
+ setNotes(prev => prev.filter(n => n.id !== confirmDeleteNote.id))
+ toast.success('Deleted', 'Note removed.')
+ } catch (err) {
+ toast.danger('Error', err.message || 'Failed to delete note.')
+ } finally {
+ setDeletingNote(null)
+ setConfirmDeleteNote(null)
+ }
+ }
+
+ // ── Linked issues (global /notes system, entity_type=device) ─────────────
+ const [issues, setIssues] = useState([])
+ const [issuesLoading, setIssuesLoading] = useState(false)
+ const [issueModal, setIssueModal] = useState({ open: false, entry: null })
+ const [confirmDeleteIssue, setConfirmDeleteIssue] = useState(null)
+ const [deletingIssue, setDeletingIssue] = useState(false)
+
+ const loadIssues = useCallback(async () => {
+ if (!id) return
+ setIssuesLoading(true)
+ try {
+ const data = await api.get(`/notes/by-entity/device/${id}`)
+ setIssues(Array.isArray(data) ? data : [])
+ } catch {
+ setIssues([])
+ } finally {
+ setIssuesLoading(false)
+ }
+ }, [id])
+
+ useEffect(() => { loadIssues() }, [loadIssues])
+
+ const openNewIssue = () => setIssueModal({ open: true, entry: null })
+ const openEditIssue = (entry) => setIssueModal({ open: true, entry })
+
+ const handleIssueSaved = () => {
+ setIssueModal({ open: false, entry: null })
+ loadIssues()
+ }
+
+ const handleDeleteIssue = async () => {
+ if (!confirmDeleteIssue) return
+ setDeletingIssue(true)
+ try {
+ await api.delete(`/notes/${confirmDeleteIssue}`)
+ toast.success('Deleted', 'Issue removed.')
+ setConfirmDeleteIssue(null)
+ loadIssues()
+ } catch (err) {
+ toast.danger('Error', err.message || 'Failed to delete issue.')
+ } finally {
+ setDeletingIssue(false)
+ }
+ }
+
+ const sn = device?.serial_number || device?.device_id
+ const existingUserIds = users.map(u => u.user_id || u.id).filter(Boolean)
+
+ // ── Render ─────────────────────────────────────────────────────────────────
+
+ return (
+ <>
+
+
+
+
+ {/* ── 1. DEVICE ACCESS (Users) ──────────────────────────────────────── */}
+
+ setShowAddUser(true)}>
+ + Add User
+
+ )}
+ />
+
+
+ {usersLoading ? (
+
+
+
+ ) : users.length === 0 ? (
+
+
+
+
+
+ }
+ message="No users have access to this device."
+ action={canEdit && (
+ setShowAddUser(true)}>Add first user
+ )}
+ />
+ ) : (
+ users.map((user) => {
+ const userId = user.user_id || user.id
+ const initials = (user.display_name || user.email || '?').charAt(0).toUpperCase()
+ return (
+ userId && navigate(`/users/${userId}`)}
+ onMouseEnter={e => e.currentTarget.style.background = GLASS_HOVER}
+ onMouseLeave={e => e.currentTarget.style.background = GLASS_INNER}
+ >
+
+ {user.photo_url ?
: initials}
+
+
+
+ {user.display_name || user.email || 'Unknown User'}
+
+ {user.email && user.display_name && (
+
+ {user.email}
+
+ )}
+
+ {user.role &&
{user.role} }
+ {canEdit && (
+
{ e.stopPropagation(); setConfirmRemoveUser(user) }}
+ disabled={removingUser === userId}
+ title="Remove access" aria-label="Remove user"
+ style={{
+ width: 28, height: 28, borderRadius: 'var(--radius-md)',
+ border: '1px solid var(--color-border-strong)',
+ background: 'var(--color-danger-bg)', color: 'var(--color-danger)',
+ cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
+ flexShrink: 0, transition: 'background 0.12s',
+ }}
+ onMouseEnter={e => e.currentTarget.style.background = 'var(--color-danger)'}
+ onMouseLeave={e => e.currentTarget.style.background = 'var(--color-danger-bg)'}
+ >
+
+
+ )}
+
+ )
+ })
+ )}
+
+
+
+ {/* ── 2. DEVICE NOTES ───────────────────────────────────────────────── */}
+
+ setNoteModal({ open: true, note: null })}>
+ + Add Note
+
+ )}
+ />
+
+
+ {notesLoading ? (
+
+
+
+ ) : notes.length === 0 ? (
+
+
+
+
+
+
+
+ }
+ message="No notes recorded for this device yet."
+ action={canEdit && (
+ setNoteModal({ open: true, note: null })}>
+ Add first note
+
+ )}
+ />
+ ) : (
+
+ {notes.map(note => (
+
+
+ {note.content || '—'}
+
+
+
+
+ {fmtRelative(note.created_at)}
+
+ {note.created_by && (
+
+ {note.created_by}
+
+ )}
+
+ {canEdit && (
+
+ setNoteModal({ open: true, note })} title="Edit note" aria-label="Edit note"
+ style={{ width: 26, height: 26, borderRadius: 'var(--radius-sm)', border: '1px solid transparent', background: 'none', color: 'var(--color-text-muted)', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', transition: 'color 0.12s, background 0.12s, border-color 0.12s' }}
+ onMouseEnter={e => { e.currentTarget.style.color = 'var(--color-primary)'; e.currentTarget.style.background = 'var(--color-primary-subtle)'; e.currentTarget.style.borderColor = 'rgba(192,193,255,0.25)' }}
+ onMouseLeave={e => { e.currentTarget.style.color = 'var(--color-text-muted)'; e.currentTarget.style.background = 'none'; e.currentTarget.style.borderColor = 'transparent' }}
+ >
+
+
+ setConfirmDeleteNote(note)} title="Delete note" aria-label="Delete note"
+ style={{ width: 26, height: 26, borderRadius: 'var(--radius-sm)', border: '1px solid transparent', background: 'none', color: 'var(--color-text-muted)', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', transition: 'color 0.12s, background 0.12s, border-color 0.12s' }}
+ onMouseEnter={e => { e.currentTarget.style.color = 'var(--color-danger)'; e.currentTarget.style.background = 'var(--color-danger-bg)'; e.currentTarget.style.borderColor = 'var(--color-danger)' }}
+ onMouseLeave={e => { e.currentTarget.style.color = 'var(--color-text-muted)'; e.currentTarget.style.background = 'none'; e.currentTarget.style.borderColor = 'transparent' }}
+ >
+
+
+
+ )}
+
+
+ ))}
+
+ )}
+
+
+
+ {/* ── 3. LINKED ISSUES ──────────────────────────────────────────────── */}
+
+ i.status !== 'resolved').length > 0
+ ? `${issues.filter(i => i.status !== 'resolved').length} open`
+ : issues.length > 0 ? `${issues.length} total` : undefined
+ }
+ action={canEdit && (
+
+ + New Issue
+
+ )}
+ />
+
+
+ {issuesLoading ? (
+
+
+
+ ) : issues.length === 0 ? (
+
+
+
+ }
+ message="No issues linked to this device."
+ action={canEdit && (
+ Log first issue
+ )}
+ />
+ ) : (
+ issues.map((issue, idx) => (
+ canEdit && openEditIssue(issue)}
+ >
+
+
+
+
+ {issue.title}
+
+ {issue.body && (
+
+ {issue.body.replace(/<[^>]*>/g, '').slice(0, 90)}
+
+ )}
+
+
+
+
+
+ {fmtRelative(issue.created_at)}
+
+
+ {canEdit && (
+
{ e.stopPropagation(); setConfirmDeleteIssue(issue.id) }}
+ title="Delete issue" aria-label="Delete issue"
+ style={{ width: 26, height: 26, borderRadius: 'var(--radius-sm)', border: '1px solid transparent', background: 'none', color: 'var(--color-text-muted)', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, transition: 'color 0.12s, background 0.12s, border-color 0.12s' }}
+ onMouseEnter={e => { e.currentTarget.style.color = 'var(--color-danger)'; e.currentTarget.style.background = 'var(--color-danger-bg)'; e.currentTarget.style.borderColor = 'var(--color-danger)' }}
+ onMouseLeave={e => { e.currentTarget.style.color = 'var(--color-text-muted)'; e.currentTarget.style.background = 'none'; e.currentTarget.style.borderColor = 'transparent' }}
+ >
+
+
+ )}
+
+ ))
+ )}
+
+
+
+
+
+ {/* ── Modals ─────────────────────────────────────────────────────────── */}
+
+ setShowAddUser(false)}
+ onAdded={handleUserAdded}
+ />
+
+ setNoteModal({ open: false, note: null })}
+ onSaved={handleNoteSaved}
+ />
+
+ {/* Issue form — pre-link to this device (locked — cannot be removed) */}
+ setIssueModal({ open: false, entry: null })}
+ onSaved={handleIssueSaved}
+ onDelete={canEdit ? (entryId) => {
+ setIssueModal({ open: false, entry: null })
+ setConfirmDeleteIssue(entryId)
+ } : null}
+ />
+
+ {/* Confirm: remove user */}
+ setConfirmRemoveUser(null)}
+ loading={!!removingUser}
+ />
+
+ {/* Confirm: delete note */}
+ setConfirmDeleteNote(null)}
+ loading={deletingNote === confirmDeleteNote?.id}
+ />
+
+ {/* Confirm: delete issue */}
+ setConfirmDeleteIssue(null)}
+ loading={deletingIssue}
+ />
+ >
+ )
+}
diff --git a/frontend/src/pages/bellcloud/devices/tabs/OverviewTab.jsx b/frontend/src/pages/bellcloud/devices/tabs/OverviewTab.jsx
new file mode 100644
index 0000000..2be2812
--- /dev/null
+++ b/frontend/src/pages/bellcloud/devices/tabs/OverviewTab.jsx
@@ -0,0 +1,1059 @@
+// frontend/src/pages/bellcloud/devices/tabs/OverviewTab.jsx
+// Overview tab — Hero (photo | hw info | subscription+warranty | uptime+issue),
+// Row 2 (auth access 30% | issues & notes 70%), Row 3 (live logs)
+
+import { useState, useEffect, useRef, useCallback } from 'react'
+import { useNavigate } from 'react-router-dom'
+import api from '@/lib/api'
+import { useToast } from '@/components/ui/Toast'
+import Button from '@/components/ui/Button'
+import StatusBadge from '@/components/ui/StatusBadge'
+import Spinner from '@/components/ui/Spinner'
+import Icon from '@/components/ui/Icon'
+import { fmtDateMedium, fmtRelative } from '@/lib/formatters'
+import { useMqttWebSocket } from '@/hooks/useMqttWebSocket'
+import {
+ parseFirestoreDate,
+ addDays,
+ daysUntil,
+} from './shared'
+import DeviceNoteModal from '@/modals/bellcloud/devices/DeviceNoteModal'
+import EntryFormModal from '@/modals/crm/helpdesk/EntryFormModal'
+
+// ─── Glass surface tokens ─────────────────────────────────────────────────────
+
+const GLASS = 'rgba(28, 32, 38, 0.30)'
+const GLASS_INNER = 'rgba(28, 32, 38, 0.40)'
+const GLASS_HOVER = 'rgba(49, 53, 60, 0.50)'
+const BLUR = 'blur(12px)'
+
+// ─── Helpers ──────────────────────────────────────────────────────────────────
+
+function calcPeriodProgress(start, end) {
+ if (!start || !end) return null
+ const total = end.getTime() - start.getTime()
+ if (total <= 0) return null
+ const elapsed = Date.now() - start.getTime()
+ return Math.max(0, Math.min(100, (elapsed / total) * 100))
+}
+
+function formatUptime(seconds) {
+ if (seconds == null) return null
+ const d = Math.floor(seconds / 86400)
+ const h = Math.floor((seconds % 86400) / 3600)
+ const m = Math.floor((seconds % 3600) / 60)
+ return [d && `${d}d`, h && `${h}h`, m && `${m}m`].filter(Boolean).join(' ') || '< 1m'
+}
+
+function subscrDurationLabel(days) {
+ if (!days) return null
+ if (days % 365 === 0) return `${days / 365} yr`
+ if (days % 30 === 0) return `${days / 30} mo`
+ return `${days}d`
+}
+
+// ─── Sub-components ───────────────────────────────────────────────────────────
+
+function GlassCard({ children, style = {}, className = '' }) {
+ return (
+
+ {children}
+
+ )
+}
+
+function SectionLabel({ children }) {
+ return (
+
+ {children}
+
+ )
+}
+
+function InfoField({ label, children, mono = false }) {
+ return (
+
+
+ {label}
+
+
+ {children || '—'}
+
+
+ )
+}
+
+function ProgressTrack({ value, variant = 'primary' }) {
+ const pct = Number.isFinite(value) ? Math.max(0, Math.min(100, value)) : 0
+ const color =
+ variant === 'success' ? 'var(--color-success)' :
+ variant === 'danger' ? 'var(--color-danger)' :
+ variant === 'warning' ? 'var(--color-warning)' :
+ 'var(--color-primary)'
+ return (
+
+ )
+}
+
+
+// Log level colour map
+const LOG_COLORS = {
+ ERROR: { text: 'var(--color-danger)', bg: 'var(--color-danger-bg)' },
+ WARN: { text: 'var(--color-warning)', bg: 'var(--color-warning-bg)' },
+ INFO: { text: 'var(--color-info)', bg: 'var(--color-info-bg)' },
+}
+
+// Level slider steps: 0=All, 1=INFO, 2=WARN, 3=ERROR
+const LEVEL_STEPS = [
+ { value: '', label: 'ALL', color: 'var(--color-text-muted)' },
+ { value: 'INFO', label: 'INFO', color: 'var(--color-info)' },
+ { value: 'WARN', label: 'WARN', color: 'var(--color-warning)' },
+ { value: 'ERROR', label: 'ERROR', color: 'var(--color-danger)' },
+]
+
+// ─── OverviewTab ──────────────────────────────────────────────────────────────
+
+export default function OverviewTab({
+ device,
+ canEdit,
+ sub,
+ stats,
+ sn,
+ isOnline,
+ mqttStatus,
+ staffNotes,
+ setStaffNotes,
+ deviceUsers,
+ usersLoading,
+}) {
+ const navigate = useNavigate()
+ const { toast } = useToast()
+ const id = device?.id || device?.device_id
+
+ // ── Quick note ────────────────────────────────────────────────────────────
+ const [editingNote, setEditingNote] = useState(false)
+ const [noteText, setNoteText] = useState(staffNotes || '')
+ const [savingNote, setSavingNote] = useState(false)
+ const noteRef = useRef(null)
+
+ useEffect(() => { setNoteText(staffNotes || '') }, [staffNotes])
+
+ const saveNote = async (val) => {
+ setSavingNote(true)
+ try {
+ await api.put(`/devices/${id}`, { staffNotes: val })
+ setStaffNotes(val)
+ setEditingNote(false)
+ toast.success('Saved', 'Quick note updated.')
+ } catch (err) {
+ toast.danger('Error', err.message || 'Failed to save note.')
+ } finally {
+ setSavingNote(false)
+ }
+ }
+
+ // ── Hardware image ────────────────────────────────────────────────────────
+ const [hwImage, setHwImage] = useState('/devices/VesperPlus.png')
+ const [hwVariant, setHwVariant] = useState('VesperPlus')
+ const [hwRevision, setHwRevision] = useState(null)
+
+ useEffect(() => {
+ if (!sn) return
+ Promise.all([
+ api.get(`/manufacturing/devices/${sn}`).catch(() => null),
+ api.get('/crm/products').catch(() => null),
+ ]).then(([mfgItem, productsRes]) => {
+ if (mfgItem?.revision) setHwRevision(mfgItem.revision)
+ 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) {
+ setHwVariant(match.name)
+ if (match.photo_url) setHwImage(`/api${match.photo_url}`)
+ }
+ }).catch(() => {})
+ }, [sn])
+
+ // ── Issues + Notes ────────────────────────────────────────────────────────
+ const [notes, setNotes] = useState([])
+ const [notesLoading, setNotesLoading] = useState(false)
+
+ const loadNotes = useCallback(async () => {
+ if (!id) return
+ setNotesLoading(true)
+ try {
+ const data = await api.get(`/devices/${id}/notes`)
+ setNotes(data.notes || [])
+ } catch {
+ setNotes([])
+ } finally {
+ setNotesLoading(false)
+ }
+ }, [id])
+
+ useEffect(() => { loadNotes() }, [loadNotes])
+
+ const [noteModal, setNoteModal] = useState({ open: false })
+ const [issueModal, setIssueModal] = useState({ open: false, entry: null })
+
+ const handleNoteSaved = () => {
+ setNoteModal({ open: false })
+ loadNotes()
+ }
+
+ const handleIssueSaved = () => {
+ setIssueModal({ open: false, entry: null })
+ }
+
+ // ── Live logs ─────────────────────────────────────────────────────────────
+ const [logs, setLogs] = useState([])
+ const [logsLoading, setLogsLoading] = useState(false)
+ const [levelStep, setLevelStep] = useState(0) // index into LEVEL_STEPS
+ const [searchText, setSearchText] = useState('')
+ const [autoScroll, setAutoScroll] = useState(true)
+ const [autoRefresh, setAutoRefresh] = useState(false)
+ const [liveLogs, setLiveLogs] = useState([])
+ const logsContainerRef = useRef(null)
+ const LIMIT = 50
+
+ const levelFilter = LEVEL_STEPS[levelStep].value
+
+ const fetchLogs = useCallback(async () => {
+ if (!sn) return
+ setLogsLoading(true)
+ try {
+ const params = new URLSearchParams({ limit: LIMIT, offset: 0 })
+ if (levelFilter) params.set('level', levelFilter)
+ if (searchText) params.set('search', searchText)
+ const data = await api.get(`/mqtt/logs/${sn}?${params}`)
+ setLogs(data.logs || [])
+ } catch {
+ // silent
+ } finally {
+ setLogsLoading(false)
+ }
+ }, [sn, levelFilter, searchText])
+
+ useEffect(() => { if (sn) fetchLogs() }, [sn, fetchLogs])
+
+ useEffect(() => {
+ if (!autoRefresh || !sn) return
+ const t = setInterval(fetchLogs, 5000)
+ return () => clearInterval(t)
+ }, [autoRefresh, sn, fetchLogs])
+
+ const handleWsMessage = useCallback((data) => {
+ if (data.type === 'logs' && data.device_serial === sn) {
+ const entry = {
+ id: Date.now(),
+ level: data.payload?.level?.includes('EROR') ? 'ERROR' :
+ data.payload?.level?.includes('WARN') ? 'WARN' : 'INFO',
+ message: data.payload?.message || '',
+ received_at: new Date().toISOString(),
+ _live: true,
+ }
+ setLiveLogs(prev => [entry, ...prev].slice(0, 50))
+ }
+ }, [sn])
+
+ useMqttWebSocket({ enabled: true, onMessage: handleWsMessage })
+
+ const allLogs = [
+ ...liveLogs.filter(l => {
+ if (levelFilter && l.level !== levelFilter) return false
+ if (searchText && !l.message.toLowerCase().includes(searchText.toLowerCase())) return false
+ return true
+ }),
+ ...logs,
+ ]
+
+ useEffect(() => {
+ if (autoScroll && logsContainerRef.current) {
+ logsContainerRef.current.scrollTop = 0
+ }
+ }, [allLogs.length, autoScroll])
+
+ // ── Service lifecycle ─────────────────────────────────────────────────────
+ const subscrStart = parseFirestoreDate(sub?.subscrStart)
+ const subscrEnd = subscrStart && sub?.subscrDuration ? addDays(subscrStart, sub.subscrDuration) : null
+ const subscrDaysLeft = subscrEnd ? daysUntil(subscrEnd) : null
+ const subscrProgress = calcPeriodProgress(subscrStart, subscrEnd)
+
+ const warrantyStart = parseFirestoreDate(stats?.warrantyStart)
+ const warrantyEnd = warrantyStart && stats?.warrantyPeriod ? addDays(warrantyStart, stats.warrantyPeriod) : null
+ const warrantyDaysLeft = warrantyEnd ? daysUntil(warrantyEnd) : null
+ const warrantyProgress = calcPeriodProgress(warrantyStart, warrantyEnd)
+
+ const warrantyIsVoid = stats?.warrantyActive === false
+ const warrantyOk = warrantyIsVoid ? false : (warrantyDaysLeft === null ? stats?.warrantyActive : warrantyDaysLeft > 0)
+ const warrantyVariant = warrantyIsVoid ? 'warning' : warrantyOk ? 'success' : 'danger'
+ const warrantyLabel = warrantyIsVoid ? 'Void' : warrantyOk ? 'Active' : 'Expired'
+
+ const subscrVariant = subscrDaysLeft === null ? 'neutral' :
+ subscrDaysLeft > 30 ? 'success' :
+ subscrDaysLeft > 0 ? 'warning' : 'danger'
+
+ // ── Render ────────────────────────────────────────────────────────────────
+
+ return (
+
+
+ {/* ── ROW 1: Hero card ────────────────────────────────────────────── */}
+
+
+ {/* ── Online accent line (left edge of entire hero) ── */}
+
+
+ {/* ── Section A: Device photo ── */}
+
+ {/* Pulsating glow when online */}
+ {isOnline && (
+ <>
+
+ {/* Expanding ring */}
+
+ >
+ )}
+
+
{ e.target.src = '/devices/VesperPlus.png' }}
+ />
+
+
+
+
+ {/* ── Section B: Hardware info ── */}
+
+
+ {sn || '—'}
+ {hwVariant}
+ {device.device_location || '—'}
+ {hwRevision || device.device_attributes?.firmwareVersion || '—'}
+
+
+
+
+ {/* ── Section D: Uptime + Latest Issue ── */}
+
+
+ {mqttStatus?.uptime_seconds != null
+ ? {formatUptime(mqttStatus.uptime_seconds)}
+ : Unavailable }
+
+
+
+
Latest Device Issue
+ {mqttStatus?.last_warn_message ? (
+
+
+ MQTT · WARN · Health Monitor
+
+
+ {mqttStatus.last_warn_message}
+
+
+ ) : (
+
+ No recent issues
+
+ )}
+
+
+
+
+ {/* ── Section C: Subscription + Warranty ── */}
+
+ {/* Subscription row */}
+
+
+
+ Subscription
+
+
+ {sub?.subscrTier ? (
+
+ {sub.subscrTier}
+ {sub.subscrDuration ? ` · ${subscrDurationLabel(sub.subscrDuration)}` : ''}
+
+ ) : (
+ Not set
+ )}
+
+
+
+
+
+ {subscrStart ? fmtDateMedium(subscrStart) : '—'}
+
+ 30 ? 'var(--color-success)' :
+ subscrDaysLeft > 0 ? 'var(--color-warning)' : 'var(--color-danger)',
+ }}>
+ {subscrDaysLeft === null ? '—' : subscrDaysLeft > 0 ? `${subscrDaysLeft}d left` : 'Expired'}
+
+
+ {subscrEnd ? fmtDateMedium(subscrEnd) : '—'}
+
+
+
+
+ {/* Warranty row */}
+
+
+
+ Warranty
+
+ {warrantyLabel}
+
+
+
+
+ {warrantyStart ? fmtDateMedium(warrantyStart) : '—'}
+
+
+ {warrantyDaysLeft === null ? '—' : warrantyDaysLeft > 0 ? `${warrantyDaysLeft}d left` : 'Expired'}
+
+
+ {warrantyEnd ? fmtDateMedium(warrantyEnd) : '—'}
+
+
+
+
+
+
+
+ {/* ── ROW 1.5: Quick Note (full width, compact) ────────────────────── */}
+
+
+
+
+ Quick Note
+
+
+ {editingNote ? (
+
+ ) : (
+
{ setEditingNote(true); setTimeout(() => noteRef.current?.focus(), 0) } : undefined}
+ style={{
+ flex: 1,
+ fontSize: 'var(--font-size-sm)',
+ color: noteText ? 'var(--color-text-secondary)' : 'var(--color-text-muted)',
+ lineHeight: 1.6,
+ cursor: canEdit ? 'text' : 'default',
+ fontStyle: noteText ? 'normal' : 'italic',
+ paddingTop: 2,
+ }}
+ >
+ {noteText || 'No quick note set. Click to add one…'}
+
+ )}
+
+ {canEdit && !editingNote && (
+
{ setEditingNote(true); setTimeout(() => noteRef.current?.focus(), 0) }} style={{ flexShrink: 0 }}>
+ Edit
+
+ )}
+
+
+
+ {/* ── ROW 2: Auth Access (30%) + Issues & Notes (70%) ─────────────── */}
+
+
+ {/* Auth Access */}
+
+
+ Auth Access
+ {deviceUsers.length > 0 && (
+
+ {deviceUsers.length}
+
+ )}
+
+
+
+ {usersLoading ? (
+
+
+
+ ) : deviceUsers.length === 0 ? (
+
+ No users assigned to this device.
+
+ ) : (
+ deviceUsers.map((user, i) => (
+
user.user_id && navigate(`/users/${user.user_id}`)}
+ style={{
+ display: 'flex',
+ alignItems: 'center',
+ gap: 'var(--space-3)',
+ padding: 'var(--space-2) var(--space-3)',
+ borderRadius: 'var(--radius-md)',
+ background: GLASS_INNER,
+ border: '1px solid var(--color-border)',
+ cursor: user.user_id ? 'pointer' : 'default',
+ transition: 'background 0.15s',
+ }}
+ onMouseEnter={e => e.currentTarget.style.background = GLASS_HOVER}
+ onMouseLeave={e => e.currentTarget.style.background = GLASS_INNER}
+ >
+
+ {user.photo_url ? (
+
+ ) : (
+ (user.display_name || user.email || '?').charAt(0).toUpperCase()
+ )}
+
+
+
+ {user.display_name || user.email || 'Unknown User'}
+
+ {user.email && user.display_name && (
+
+ {user.email}
+
+ )}
+
+ {user.role &&
{user.role} }
+
+ ))
+ )}
+
+
+
+ {/* Issues & Notes */}
+
+
+
+ Issues & Notes
+ {notes.length > 0 && (
+
+ {notes.length}
+
+ )}
+
+ {canEdit && (
+
+ } onClick={() => setNoteModal({ open: true })}>
+ Add Note
+
+ } onClick={() => setIssueModal({ open: true, entry: null })}>
+ Record Issue
+
+
+ )}
+
+
+
+ {notesLoading ? (
+
+
+
+ ) : notes.length === 0 ? (
+
+ No notes for this device.
+
+ ) : (
+
+ {notes.map((note, i) => (
+
+
+ {note.content || note.text || '—'}
+
+ {note.created_at && (
+
+ {fmtRelative(note.created_at)}
+ {note.author_name ? ` · ${note.author_name}` : ''}
+
+ )}
+
+ ))}
+
+ )}
+
+
+
+
+ {/* ── ROW 3: Live Logs ─────────────────────────────────────────────── */}
+
+
+ {/* Log panel header */}
+
+ {/* Terminal traffic-light dots */}
+
+ {['var(--color-danger)', 'var(--color-warning)', 'var(--color-success)'].map((c, i) => (
+
+ ))}
+
+
+
+ Live Logs
+
+
+ {/* Live pulse indicator */}
+ {liveLogs.length > 0 && (
+
+
+
+ LIVE
+
+
+ )}
+
+
+
+ {/* Level colour slider */}
+
+
Level
+
+ {LEVEL_STEPS.map((step, idx) => (
+ { setLevelStep(idx); setLogs([]) }}
+ title={step.label}
+ style={{
+ height: 24,
+ paddingInline: 'var(--space-2)',
+ borderRadius: 'var(--radius-sm)',
+ border: `1px solid ${levelStep === idx ? step.color : 'var(--color-border)'}`,
+ background: levelStep === idx
+ ? `${step.color}22`
+ : GLASS_INNER,
+ color: levelStep === idx ? step.color : 'var(--color-text-muted)',
+ fontSize: 'var(--font-size-xs)',
+ fontWeight: 'var(--font-weight-semibold)',
+ letterSpacing: 'var(--tracking-wide)',
+ cursor: 'pointer',
+ transition: 'all 0.15s',
+ fontFamily: 'var(--font-family-base)',
+ }}
+ >
+ {step.label}
+
+ ))}
+
+
+
+ {/* Search */}
+
setSearchText(e.target.value)}
+ placeholder="Filter messages…"
+ style={{
+ height: 28,
+ padding: '0 var(--space-3)',
+ borderRadius: 'var(--radius-md)',
+ border: '1px solid var(--color-border-strong)',
+ background: GLASS_INNER,
+ backdropFilter: BLUR,
+ WebkitBackdropFilter: BLUR,
+ color: 'var(--color-text-primary)',
+ fontSize: 'var(--font-size-xs)',
+ fontFamily: 'var(--font-family-base)',
+ width: 160,
+ outline: 'none',
+ flexShrink: 0,
+ }}
+ />
+
+ {/* Toggle: AUTO-SCROLL */}
+
setAutoScroll(p => !p)}
+ style={{
+ height: 28,
+ paddingInline: 'var(--space-3)',
+ borderRadius: 'var(--radius-md)',
+ border: `1px solid ${autoScroll ? 'var(--color-primary)' : 'var(--color-border-strong)'}`,
+ background: autoScroll ? 'var(--color-primary-subtle)' : GLASS_INNER,
+ color: autoScroll ? 'var(--color-primary)' : 'var(--color-text-muted)',
+ fontSize: 'var(--font-size-xs)',
+ fontWeight: 'var(--font-weight-semibold)',
+ cursor: 'pointer',
+ letterSpacing: 'var(--tracking-wide)',
+ fontFamily: 'var(--font-family-base)',
+ flexShrink: 0,
+ }}
+ >
+ AUTO-SCROLL
+
+
+ {/* Toggle: AUTO-SYNC */}
+
setAutoRefresh(p => !p)}
+ style={{
+ height: 28,
+ paddingInline: 'var(--space-3)',
+ borderRadius: 'var(--radius-md)',
+ border: `1px solid ${autoRefresh ? 'var(--color-success)' : 'var(--color-border-strong)'}`,
+ background: autoRefresh ? 'rgba(74,222,128,0.10)' : GLASS_INNER,
+ color: autoRefresh ? 'var(--color-success)' : 'var(--color-text-muted)',
+ fontSize: 'var(--font-size-xs)',
+ fontWeight: 'var(--font-weight-semibold)',
+ cursor: 'pointer',
+ letterSpacing: 'var(--tracking-wide)',
+ fontFamily: 'var(--font-family-base)',
+ flexShrink: 0,
+ }}
+ >
+ AUTO-SYNC
+
+
+ {/* Refresh — same visual style as the toggle buttons */}
+
+ {logsLoading ? : null}
+ REFRESH
+
+
+
+ {/* Log rows */}
+
+ {logsLoading && allLogs.length === 0 ? (
+
+
+
+ ) : allLogs.length === 0 ? (
+
+ No logs found.
+
+ ) : (
+
+
+
+ {['Time', 'Level', 'Message'].map((h, i) => (
+
+ {h}
+
+ ))}
+
+
+
+ {allLogs.map((log, idx) => {
+ const s = LOG_COLORS[log.level] || LOG_COLORS.INFO
+ return (
+
+
+ {log.received_at?.replace('T', ' ').substring(0, 19)}
+ {log._live && (
+ LIVE
+ )}
+
+
+
+ {log.level}
+
+
+
+ {log.message}
+
+
+ )
+ })}
+
+
+ )}
+
+
+
+ {/* ── Responsive + animation styles ─────────────────────────────── */}
+
+
+
setNoteModal({ open: false })}
+ onSaved={handleNoteSaved}
+ />
+
+ setIssueModal({ open: false, entry: null })}
+ onSaved={handleIssueSaved}
+ />
+
+ )
+}
diff --git a/frontend/src/pages/bellcloud/devices/tabs/WarrantyTab.jsx b/frontend/src/pages/bellcloud/devices/tabs/WarrantyTab.jsx
new file mode 100644
index 0000000..9737b63
--- /dev/null
+++ b/frontend/src/pages/bellcloud/devices/tabs/WarrantyTab.jsx
@@ -0,0 +1,857 @@
+// frontend/src/pages/bellcloud/devices/tabs/WarrantyTab.jsx
+
+import Button from '@/components/ui/Button'
+import StatusBadge from '@/components/ui/StatusBadge'
+import { fmtDateMedium, fmtDateLong } from '@/lib/formatters'
+
+// ─── Helpers ──────────────────────────────────────────────────────────────────
+
+function parseDate(val) {
+ if (!val) return null
+ // Firestore Timestamp object
+ if (typeof val === 'object' && typeof val.seconds === 'number') {
+ return new Date(val.seconds * 1000)
+ }
+ const d = new Date(val)
+ return isNaN(d.getTime()) ? null : d
+}
+
+function addDays(date, days) {
+ if (!date || !days) return null
+ const d = new Date(date)
+ d.setDate(d.getDate() + Number(days))
+ return d
+}
+
+function daysUntil(date) {
+ if (!date) return null
+ return Math.ceil((date.getTime() - Date.now()) / 86400000)
+}
+
+/** 0–100 how much of the period has ELAPSED */
+function calcProgress(start, end) {
+ if (!start || !end) return null
+ const total = end.getTime() - start.getTime()
+ if (total <= 0) return 100
+ const elapsed = Date.now() - start.getTime()
+ return Math.min(100, Math.max(0, Math.round((elapsed / total) * 100)))
+}
+
+/** Remaining portion (inverse of elapsed) */
+function calcRemaining(start, end) {
+ const p = calcProgress(start, end)
+ return p === null ? null : 100 - p
+}
+
+function daysLabel(days) {
+ if (days == null || days === 0) return '—'
+ if (days % 365 === 0) {
+ const y = days / 365
+ return `${y} year${y !== 1 ? 's' : ''}`
+ }
+ if (days % 30 === 0) {
+ const m = days / 30
+ return `${m} month${m !== 1 ? 's' : ''}`
+ }
+ return `${days} day${days !== 1 ? 's' : ''}`
+}
+
+// ─── Glass surface styles ─────────────────────────────────────────────────────
+
+const GLASS = {
+ background: 'rgba(28, 32, 38, 0.30)',
+ backdropFilter: 'var(--blur-modal)',
+ WebkitBackdropFilter: 'var(--blur-modal)',
+ border: '1px solid var(--color-border)',
+ boxShadow: 'var(--shadow-card), var(--shadow-md)',
+ borderRadius: 'var(--radius-xl)',
+}
+
+const GLASS_ELEVATED = {
+ background: 'rgba(28, 32, 38, 0.40)',
+ backdropFilter: 'var(--blur-modal)',
+ WebkitBackdropFilter: 'var(--blur-modal)',
+ border: '1px solid var(--color-border)',
+ boxShadow: 'var(--shadow-sm)',
+ borderRadius: 'var(--radius-lg)',
+}
+
+// ─── Primitives ───────────────────────────────────────────────────────────────
+
+function SectionLabel({ children }) {
+ return (
+
+ {children}
+
+ )
+}
+
+function InfoCell({ label, children }) {
+ return (
+
+ {label}
+
+ {children ?? '—'}
+
+
+ )
+}
+
+function ProgressBar({ value, color = 'var(--color-primary)', label, showPct = true }) {
+ const pct = Math.max(0, Math.min(100, value ?? 0))
+ return (
+
+ {(label || showPct) && (
+
+ {label && (
+
+ {label}
+
+ )}
+ {showPct && (
+
+ {pct}%
+
+ )}
+
+ )}
+
+
+ )
+}
+
+function Divider() {
+ return
+}
+
+function DetailRow({ label, value, valueColor }) {
+ return (
+
+
+ {label}
+
+
+ {value ?? '—'}
+
+
+ )
+}
+
+// ─── Subscription Hero ────────────────────────────────────────────────────────
+
+function SubscriptionHero({ sub, canEdit, onEditSubscription, deviceUsers }) {
+ const subscrStart = parseDate(sub?.subscrStart)
+ const subscrEnd = addDays(subscrStart, sub?.subscrDuration)
+ const daysLeft = daysUntil(subscrEnd)
+ const remaining = calcRemaining(subscrStart, subscrEnd)
+
+ const isActive = daysLeft === null || daysLeft > 0
+ const tier = sub?.subscrTier || null
+
+ const progressColor = daysLeft === null
+ ? 'var(--color-primary)'
+ : daysLeft > 60
+ ? 'var(--color-success)'
+ : daysLeft > 14
+ ? 'var(--color-warning)'
+ : 'var(--color-danger)'
+
+ // Registered users = actual device users count passed from parent
+ const regUsers = Array.isArray(deviceUsers) ? deviceUsers.length : null
+ const maxUsers = sub?.maxUsers ?? null
+ const hwOutputs = sub?.hw_outputs ?? null
+ const maxOutputs = sub?.maxOutputs ?? null
+
+ return (
+
+
+ {/* Left accent glow */}
+
+ {/* Right ambient glow */}
+
+
+
+
+ {/* ── Column 1: Tier ── */}
+
+
+
+
+ {tier ? `${tier} Tier` : 'No Subscription'}
+
+
+
+ {isActive ? 'Active License' : 'Expired'}
+
+
+
+
+
+ {/* ── Column 2: Progress bar ── */}
+
+ {/* Top label row */}
+
+
+ {daysLeft === null
+ ? 'No expiry set'
+ : daysLeft <= 0
+ ? 'Subscription expired'
+ : `${daysLeft} days remaining`}
+
+
+ {daysLabel(sub?.subscrDuration)} plan
+
+
+
+ {/* Progress bar */}
+
+
+ {/* Bottom: inline Start / Renewal */}
+
+ {subscrStart && (
+
+ Start:{' '}
+
+ {fmtDateMedium(subscrStart.toISOString())}
+
+
+ )}
+ {subscrEnd && (
+
+ Renewal:{' '}
+
+ {fmtDateMedium(subscrEnd.toISOString())}
+
+
+ )}
+
+
+
+ {/* ── Column 3: Users + Outputs + Edit — side by side ── */}
+
+
+ {/* Users block */}
+
+
+
+
+ Device Users
+
+
+ Max:{' '}
+
+ {maxUsers ?? '—'}
+
+ {regUsers !== null && (
+ <>
+ {' '}| {' '}
+ Reg:{' '}
+
+ {regUsers}
+
+ >
+ )}
+
+
+
+
+ {/* Outputs block */}
+
+
+
+
+ Outputs
+
+
+ HW:{' '}
+
+ {hwOutputs ?? '—'}
+
+ {maxOutputs !== null && (
+ <>
+ {' '}| {' '}
+ Max:{' '}
+
+ {maxOutputs}
+
+ >
+ )}
+
+
+
+
+ {/* Edit pencil icon button */}
+ {canEdit && (
+
{
+ e.currentTarget.style.background = 'rgba(49,53,60,0.50)'
+ e.currentTarget.style.color = 'var(--color-text-primary)'
+ }}
+ onMouseLeave={e => {
+ e.currentTarget.style.background = 'transparent'
+ e.currentTarget.style.color = 'var(--color-text-muted)'
+ }}
+ >
+
+
+
+
+
+ )}
+
+
+
+
+
+ )
+}
+
+// ─── Warranty Card ────────────────────────────────────────────────────────────
+
+function WarrantyCard({ stats, canEdit, onEditWarranty }) {
+ const warrantyStart = parseDate(stats?.warrantyStart)
+ const warrantyEnd = addDays(warrantyStart, stats?.warrantyPeriod)
+ const daysLeft = daysUntil(warrantyEnd)
+ const remaining = calcRemaining(warrantyStart, warrantyEnd)
+
+ const isVoid = stats?.warrantyActive === false
+ const isExpired = !isVoid && daysLeft !== null && daysLeft <= 0
+ const isActive = !isVoid && !isExpired && (daysLeft === null || daysLeft > 0)
+
+ const statusLabel = isVoid ? 'Warranty Void' : isExpired ? 'Expired' : isActive ? 'Active' : '—'
+ const statusVariant = isVoid ? 'warning' : isExpired ? 'danger' : isActive ? 'success' : 'neutral'
+
+ const borderAccent = isVoid
+ ? 'var(--color-warning)'
+ : isExpired
+ ? 'var(--color-danger)'
+ : 'var(--color-success)'
+
+ const progressColor = isVoid || isExpired ? 'var(--color-danger)' : 'var(--color-success)'
+
+ return (
+
+
+ {/* Accent side glow */}
+
+
+
+
+ {/* Header */}
+
+
+
+ {isActive ? (
+
+
+
+
+ ) : (
+
+
+
+
+
+ )}
+
+
+
+ {statusLabel}
+
+
+ {isVoid
+ ? 'Warranty has been manually voided'
+ : isExpired
+ ? warrantyEnd
+ ? `Coverage ended on ${fmtDateLong(warrantyEnd.toISOString())}`
+ : 'Coverage has expired'
+ : warrantyEnd
+ ? `Active until ${fmtDateMedium(warrantyEnd.toISOString())}`
+ : 'Warranty is currently active'}
+
+
+
+ {canEdit && (
+
+ Edit Warranty
+
+ )}
+
+
+ {/* Progress */}
+
+
+ {/* Detail grid */}
+
+
+ {warrantyStart ? fmtDateMedium(warrantyStart.toISOString()) : '—'}
+
+
+ {warrantyEnd ? fmtDateMedium(warrantyEnd.toISOString()) : '—'}
+
+
+ {daysLabel(stats?.warrantyPeriod)}
+
+
+ {statusLabel}
+
+
+
+
+
+ )
+}
+
+// ─── Maintenance Card ─────────────────────────────────────────────────────────
+
+function MaintenanceCard({ stats }) {
+ const maintainedOn = parseDate(stats?.maintainedOn)
+ const nextMaint = addDays(maintainedOn, stats?.maintainancePeriod)
+ const daysLeft = daysUntil(nextMaint)
+ const remaining = calcRemaining(maintainedOn, nextMaint)
+
+ const isOverdue = daysLeft !== null && daysLeft <= 0
+ const isDueSoon = daysLeft !== null && daysLeft > 0 && daysLeft <= 30
+ const statusVariant = isOverdue ? 'danger' : isDueSoon ? 'warning' : 'neutral'
+ const progressColor = isOverdue
+ ? 'var(--color-danger)'
+ : isDueSoon
+ ? 'var(--color-warning)'
+ : 'var(--color-info)'
+
+ return (
+
+
+ {/* Title */}
+
+
+
+
+
+ Maintenance Schedule
+
+ {(isOverdue || isDueSoon) && (
+
+ {isOverdue ? 'Overdue' : 'Due Soon'}
+
+ )}
+
+
+ {/* Next service highlight */}
+ {nextMaint ? (
+
+
+
Next Scheduled Service
+
+ {fmtDateLong(nextMaint.toISOString())}
+
+
+
+
{isOverdue ? 'Overdue by' : 'In'}
+
+ {daysLeft != null ? `${Math.abs(daysLeft)} day${Math.abs(daysLeft) !== 1 ? 's' : ''}` : '—'}
+
+
+
+ ) : (
+
+
+ No maintenance schedule set
+
+
+ )}
+
+ {/* Progress bar */}
+
+
+ {/* Detail rows */}
+
+
+
+ )
+}
+
+// ─── Performance Metrics ──────────────────────────────────────────────────────
+
+function MetricTile({ icon, label, value, accent = 'var(--color-primary)', tag, tagVariant = 'info' }) {
+ return (
+
+
+
+ {icon}
+
+ {tag != null && (
+
{tag}
+ )}
+
+
+
+ {value}
+
+
+ {label}
+
+
+
+ )
+}
+
+function PerformanceMetrics({ device, stats }) {
+ const totalWarnings = Number.isFinite(stats?.totalWarningsGiven) ? stats.totalWarningsGiven : null
+ const melodiesCount = Array.isArray(device?.device_melodies_all) ? device.device_melodies_all.length : null
+
+ return (
+
+
+
+
+
+
+
+ Device Performance
+
+
+
+
+
+ {/* System Warnings */}
+
0 ? 'var(--color-danger)' : 'var(--color-success)'}
+ tag={totalWarnings !== null ? (totalWarnings === 0 ? 'Clean' : 'Flagged') : null}
+ tagVariant={totalWarnings === 0 ? 'success' : 'danger'}
+ value={totalWarnings !== null ? totalWarnings : '—'}
+ label="System Warnings"
+ icon={
+
+
+
+
+
+ }
+ />
+
+ {/* On-board Melodies */}
+
+
+
+
+
+ }
+ />
+
+ {/* Firmware Version */}
+
+
+
+
+
+ }
+ />
+
+ {/* Total Melodies (favorites) */}
+ 0 ? 'Saved' : null}
+ tagVariant="warning"
+ value={Array.isArray(device?.device_melodies_favorites) ? device.device_melodies_favorites.length : '—'}
+ label="Favourite Melodies"
+ icon={
+
+
+
+ }
+ />
+
+
+
+ )
+}
+
+// ─── WarrantyTab ──────────────────────────────────────────────────────────────
+
+export default function WarrantyTab({
+ device,
+ canEdit,
+ sub,
+ stats,
+ deviceUsers,
+ onEditSubscription,
+ onEditWarranty,
+}) {
+ return (
+
+
+ {/* Subscription hero — no section label above */}
+
+
+ {/* Warranty card — no section label above */}
+
+
+ {/* Masonry: Maintenance + Metrics side by side */}
+
+
+
+ )
+}
diff --git a/frontend/src/pages/bellcloud/devices/tabs/shared.jsx b/frontend/src/pages/bellcloud/devices/tabs/shared.jsx
new file mode 100644
index 0000000..0031a66
--- /dev/null
+++ b/frontend/src/pages/bellcloud/devices/tabs/shared.jsx
@@ -0,0 +1,171 @@
+// frontend/src/pages/bellcloud/devices/tabs/shared.jsx
+// Shared helpers, sub-components, and constants used across device tabs
+
+import Button from '@/components/ui/Button'
+import Card from '@/components/ui/Card'
+
+// ─── Date / time helpers ──────────────────────────────────────────────────────
+
+export function parseFirestoreDate(str) {
+ if (!str) return null
+ if (typeof str === 'object') {
+ const secs = str._seconds ?? str.seconds
+ if (typeof secs === 'number') return new Date(secs * 1000)
+ return null
+ }
+ const cleaned = str.replace(' at ', ' ').replace('UTC+0000', 'UTC').replace(/UTC\+(\d{4})/, 'UTC')
+ const d = new Date(cleaned)
+ return isNaN(d.getTime()) ? null : d
+}
+
+export function formatDate(str) {
+ const d = parseFirestoreDate(str)
+ if (!d) return str || '—'
+ const day = d.getUTCDate().toString().padStart(2, '0')
+ const month = (d.getUTCMonth() + 1).toString().padStart(2, '0')
+ return `${day}-${month}-${d.getUTCFullYear()}`
+}
+
+export function formatDateNice(d) {
+ if (!d) return '—'
+ const months = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']
+ return `${d.getUTCDate()} ${months[d.getUTCMonth()]} ${d.getUTCFullYear()}`
+}
+
+export function daysUntil(targetDate) {
+ return Math.ceil((targetDate.getTime() - Date.now()) / 86400000)
+}
+
+export function addDays(date, days) {
+ return new Date(date.getTime() + days * 86400000)
+}
+
+export function daysToDisplay(days) {
+ if (days >= 365 && days % 365 === 0) return `${days / 365} year${days / 365 > 1 ? 's' : ''} (${days} days)`
+ if (days >= 30) {
+ const months = Math.floor(days / 30)
+ const rem = days % 30
+ if (rem === 0) return `${months} month${months > 1 ? 's' : ''} (${days} days)`
+ return `${days} days (~${months} month${months > 1 ? 's' : ''})`
+ }
+ return `${days} days`
+}
+
+export function formatTimestamp(str) {
+ if (!str) return '—'
+ const d = parseFirestoreDate(str)
+ if (d) return `${d.getUTCHours().toString().padStart(2, '0')}:${d.getUTCMinutes().toString().padStart(2, '0')}`
+ const match = str.match(/(\d{1,2}):(\d{2})/)
+ return match ? `${match[1].padStart(2, '0')}:${match[2]}` : str
+}
+
+export function parseCoordinates(coordStr) {
+ if (!coordStr) return null
+ if (typeof coordStr === 'object') {
+ const lat = parseFloat(coordStr.lat ?? coordStr.latitude)
+ const lng = parseFloat(coordStr.lng ?? coordStr.longitude)
+ if (!isNaN(lat) && !isNaN(lng)) return { lat, lng }
+ return null
+ }
+ const numbers = coordStr.match(/-?\d+(?:\.\d+)?/g)
+ if (numbers?.length >= 2) {
+ let lat = parseFloat(numbers[0])
+ let lng = parseFloat(numbers[1])
+ if (!isNaN(lat) && !isNaN(lng)) {
+ if (/\bS\b/.test(coordStr)) lat = -Math.abs(lat)
+ if (/\bW\b/.test(coordStr)) lng = -Math.abs(lng)
+ return { lat, lng }
+ }
+ }
+ return null
+}
+
+export function formatCoordinates(coords) {
+ if (!coords) return '—'
+ const latDir = coords.lat >= 0 ? 'N' : 'S'
+ const lngDir = coords.lng >= 0 ? 'E' : 'W'
+ return `${Math.abs(coords.lat).toFixed(6)}° ${latDir}, ${Math.abs(coords.lng).toFixed(6)}° ${lngDir}`
+}
+
+export function formatLocale(v) {
+ if (!v || v === 'all') return 'Global'
+ if (v === 'orthodox') return 'Orthodox'
+ if (v === 'catholic') return 'Catholic'
+ return v
+}
+
+// ─── Constants ────────────────────────────────────────────────────────────────
+
+export const LOG_LEVEL_LABELS = { 0: 'Disabled', 1: 'Error', 2: 'Warning', 3: 'Info', 4: 'Debug', 5: 'Verbose' }
+export const LOG_LEVEL_VARIANTS = { 0: 'neutral', 1: 'danger', 2: 'warning', 3: 'info', 4: 'neutral', 5: 'neutral' }
+
+// ─── Shared sub-components ────────────────────────────────────────────────────
+
+export function Field({ label, children, mono = false }) {
+ const isEmpty = children == null || children === ''
+ return (
+
+
+ {label}
+
+
+ {isEmpty ? '—' : children}
+
+
+ )
+}
+
+export function FieldGrid({ children, cols = 2 }) {
+ return (
+
+ {children}
+
+ )
+}
+
+export function SectionTitle({ title, onEdit, canEdit }) {
+ return (
+
+
+ {title}
+
+ {canEdit && onEdit && (
+ Edit
+ )}
+
+ )
+}
+
+export function KpiCard({ label, value, variant }) {
+ const valueColor = variant === 'success'
+ ? 'var(--color-success)'
+ : variant === 'danger'
+ ? 'var(--color-danger)'
+ : variant === 'warning'
+ ? 'var(--color-warning)'
+ : 'var(--color-text-primary)'
+ return (
+
+
+ {label}
+
+ {value}
+
+
+
+ )
+}
+
+export function ProgressBar({ value, variant = 'primary' }) {
+ const width = Number.isFinite(value) ? Math.max(0, Math.min(100, value)) : 0
+ const color = variant === 'success' ? 'var(--color-success)' : variant === 'danger' ? 'var(--color-danger)' : variant === 'warning' ? 'var(--color-warning)' : 'var(--color-primary)'
+ return (
+
+ )
+}
diff --git a/frontend/src/pages/bellcloud/melodies/MelodyComposer.jsx b/frontend/src/pages/bellcloud/melodies/MelodyComposer.jsx
new file mode 100644
index 0000000..848c9bc
--- /dev/null
+++ b/frontend/src/pages/bellcloud/melodies/MelodyComposer.jsx
@@ -0,0 +1,766 @@
+// frontend/src/pages/melodies/MelodyComposer.jsx
+// Visual step-sequencer for building bell melodies.
+// Logic ported from _archive/melodies/MelodyComposer.jsx — no styling copied.
+
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import { useNavigate, useLocation } from 'react-router-dom'
+import PageHeader from '@/components/ui/PageHeader'
+import Button from '@/components/ui/Button'
+import Card from '@/components/ui/Card'
+import DeployArchetypeModal from '@/modals/bellcloud/melodies/DeployArchetypeModal'
+import { useToast } from '@/components/ui/Toast'
+import api from '@/lib/api'
+
+// ─── Constants ───────────────────────────────────────────────────────────────
+
+const MAX_NOTES = 16
+const NOTE_LABELS = 'ABCDEFGHIJKLMNOP'
+
+// ─── Pure helpers (logic only) ────────────────────────────────────────────────
+
+function bellFrequency(bellNumber) {
+ return 880 * Math.pow(2, -((bellNumber - 1) / 8))
+}
+
+function stepToNotation(stepValue) {
+ if (!stepValue) return '0'
+ const active = []
+ for (let bit = 0; bit < 16; bit++) {
+ if (stepValue & (1 << bit)) active.push(bit + 1)
+ }
+ return active.join('+')
+}
+
+function stepToHex(stepValue) {
+ return `0x${(stepValue >>> 0).toString(16).toUpperCase().padStart(4, '0')}`
+}
+
+function msToBpm(ms) {
+ return Math.max(1, Math.round(60000 / Math.max(1, Number(ms) || 1)))
+}
+
+function bpmToMs(bpm) {
+ return Math.max(20, Math.round(60000 / Math.max(1, Number(bpm) || 1)))
+}
+
+function interpolateHue(t) {
+ const stops = [[0.0, 190], [0.24, 140], [0.5, 56], [0.82, 30], [1.0, 0]]
+ for (let i = 0; i < stops.length - 1; i++) {
+ const [aPos, aHue] = stops[i]
+ const [bPos, bHue] = stops[i + 1]
+ if (t >= aPos && t <= bPos) {
+ const local = (t - aPos) / (bPos - aPos || 1)
+ return aHue + (bHue - aHue) * local
+ }
+ }
+ return stops[stops.length - 1][1]
+}
+
+function noteDotColor(noteNumber, palette) {
+ const n = Number(noteNumber || 1)
+ const custom = palette?.[n - 1]
+ if (custom) return custom
+ const t = Math.min(1, Math.max(0, (n - 1) / 15))
+ return `hsl(${interpolateHue(t)}, 78%, 68%)`
+}
+
+function noteDotGlow(noteNumber, palette) {
+ const n = Number(noteNumber || 1)
+ const custom = palette?.[n - 1]
+ if (custom) return `${custom}66`
+ const t = Math.min(1, Math.max(0, (n - 1) / 15))
+ return `hsla(${interpolateHue(t)}, 78%, 56%, 0.45)`
+}
+
+function playStep(audioCtx, stepValue, noteDurationMs) {
+ if (!audioCtx) return
+ const now = audioCtx.currentTime
+ const duration = Math.max(10, noteDurationMs) / 1000
+ const fadeIn = 0.005
+ const fadeOut = Math.min(0.03, duration / 2)
+
+ for (let bit = 0; bit < 16; bit++) {
+ if (stepValue & (1 << bit)) {
+ const freq = bellFrequency(bit + 1)
+ const osc = audioCtx.createOscillator()
+ const gain = audioCtx.createGain()
+ osc.type = 'sine'
+ osc.frequency.setValueAtTime(freq, now)
+ gain.gain.setValueAtTime(0, now)
+ gain.gain.linearRampToValueAtTime(0.3, now + fadeIn)
+ gain.gain.setValueAtTime(0.3, now + Math.max(duration - fadeOut, fadeIn + 0.001))
+ gain.gain.linearRampToValueAtTime(0, now + duration)
+ osc.connect(gain)
+ gain.connect(audioCtx.destination)
+ osc.start(now)
+ osc.stop(now + duration)
+ }
+ }
+}
+
+function csvToSteps(csv) {
+ if (!csv?.trim()) return null
+ return csv.trim().split(',').map((token) => {
+ const parts = token.split('+')
+ let val = 0
+ for (const p of parts) {
+ const n = parseInt(p.trim(), 10)
+ if (!isNaN(n) && n >= 1 && n <= 16) val |= (1 << (n - 1))
+ }
+ return val
+ })
+}
+
+// ─── Component ────────────────────────────────────────────────────────────────
+
+export default function MelodyComposer() {
+ const navigate = useNavigate()
+ const { state: routeState } = useLocation()
+ const { toast } = useToast()
+
+ const loadedArchetype = routeState?.archetype ?? null
+
+ // ── Initial state from archetype ──────────────────────────────────────────
+ const initialSteps = () => {
+ if (loadedArchetype?.steps) {
+ const parsed = csvToSteps(loadedArchetype.steps)
+ if (parsed?.length) return parsed
+ }
+ return Array.from({ length: 16 }, () => 0)
+ }
+
+ const initialNoteCount = () => {
+ if (loadedArchetype?.steps) {
+ const parsed = csvToSteps(loadedArchetype.steps)
+ if (parsed?.length) {
+ let maxBit = 0
+ for (const v of parsed) {
+ for (let b = 15; b >= 0; b--) {
+ if (v & (1 << b)) { maxBit = Math.max(maxBit, b + 1); break }
+ }
+ }
+ return Math.max(8, maxBit)
+ }
+ }
+ return 8
+ }
+
+ // ── State ─────────────────────────────────────────────────────────────────
+ const [steps, setSteps] = useState(initialSteps)
+ const [noteCount, setNoteCount] = useState(initialNoteCount)
+ const [stepDelayMs, setStepDelayMs] = useState(280)
+ const [noteDurationMs, setNoteDurationMs] = useState(110)
+ const [measureEvery, setMeasureEvery] = useState(4)
+ const [loopEnabled, setLoopEnabled] = useState(true)
+ const [isPlaying, setIsPlaying] = useState(false)
+ const [currentStep, setCurrentStep] = useState(-1)
+ const [noteColors, setNoteColors] = useState([])
+ const [stepMenuIndex, setStepMenuIndex] = useState(null)
+ const [deployOpen, setDeployOpen] = useState(false)
+ const [deployMode, setDeployMode] = useState('new')
+
+ // ── Refs ──────────────────────────────────────────────────────────────────
+ const audioCtxRef = useRef(null)
+ const playbackRef = useRef(null)
+ const stepsRef = useRef(steps)
+ const stepDelayRef = useRef(stepDelayMs)
+ const noteDurationRef = useRef(noteDurationMs)
+ const loopEnabledRef = useRef(loopEnabled)
+ const stepMenuRef = useRef(null)
+
+ // Keep refs in sync
+ useEffect(() => { stepsRef.current = steps }, [steps])
+ useEffect(() => { stepDelayRef.current = stepDelayMs }, [stepDelayMs])
+ useEffect(() => { noteDurationRef.current = noteDurationMs}, [noteDurationMs])
+ useEffect(() => { loopEnabledRef.current = loopEnabled }, [loopEnabled])
+
+ // Close step context menu on outside click
+ useEffect(() => {
+ if (stepMenuIndex == null) return
+ const onDocClick = (e) => {
+ if (stepMenuRef.current && !stepMenuRef.current.contains(e.target)) {
+ setStepMenuIndex(null)
+ }
+ }
+ document.addEventListener('mousedown', onDocClick)
+ return () => document.removeEventListener('mousedown', onDocClick)
+ }, [stepMenuIndex])
+
+ // Load custom note colours from melody settings
+ useEffect(() => {
+ let canceled = false
+ api.get('/settings/melody')
+ .then((s) => { if (!canceled) setNoteColors((s?.note_assignment_colors || []).slice(0, 16)) })
+ .catch(() => { if (!canceled) setNoteColors([]) })
+ return () => { canceled = true }
+ }, [])
+
+ // ── Playback ──────────────────────────────────────────────────────────────
+ const ensureAudioContext = useCallback(() => {
+ if (!audioCtxRef.current || audioCtxRef.current.state === 'closed') {
+ const AudioCtx = window.AudioContext || /** @type {any} */ (window).webkitAudioContext
+ audioCtxRef.current = new AudioCtx()
+ }
+ if (audioCtxRef.current.state === 'suspended') audioCtxRef.current.resume()
+ return audioCtxRef.current
+ }, [])
+
+ const stopPlayback = useCallback(() => {
+ if (playbackRef.current?.timer) clearTimeout(playbackRef.current.timer)
+ playbackRef.current = null
+ setIsPlaying(false)
+ setCurrentStep(-1)
+ }, [])
+
+ const scheduleStep = useCallback((stepIndex) => {
+ const currentSteps = stepsRef.current
+ if (!currentSteps.length) { stopPlayback(); return }
+
+ const ctx = ensureAudioContext()
+ const nextIndex = stepIndex % currentSteps.length
+ setCurrentStep(nextIndex)
+ playStep(ctx, currentSteps[nextIndex], noteDurationRef.current)
+
+ const isLastStep = nextIndex >= currentSteps.length - 1
+ const shouldContinue = !isLastStep || loopEnabledRef.current
+
+ if (!shouldContinue) {
+ playbackRef.current = { timer: setTimeout(() => stopPlayback(), stepDelayRef.current) }
+ return
+ }
+ playbackRef.current = {
+ timer: setTimeout(() => scheduleStep(isLastStep ? 0 : nextIndex + 1), stepDelayRef.current),
+ }
+ }, [ensureAudioContext, stopPlayback])
+
+ useEffect(() => () => stopPlayback(), [stopPlayback])
+
+ const handlePlay = () => {
+ if (!stepsRef.current.length) return
+ setIsPlaying(true)
+ scheduleStep(0)
+ }
+
+ // ── Grid editing ──────────────────────────────────────────────────────────
+ const toggleCell = (noteIndex, stepIndex) => {
+ const bit = 1 << noteIndex
+ setSteps((prev) => {
+ const next = [...prev]
+ next[stepIndex] = (next[stepIndex] || 0) ^ bit
+ return next
+ })
+ }
+
+ const addStep = () => setSteps((prev) => [...prev, 0])
+ const removeStep = () => setSteps((prev) => {
+ if (prev.length <= 1) return prev
+ const next = prev.slice(0, prev.length - 1)
+ if (currentStep >= next.length) setCurrentStep(next.length - 1)
+ return next
+ })
+ const addNote = () => setNoteCount((prev) => Math.min(MAX_NOTES, prev + 1))
+ const removeNote = () => setNoteCount((prev) => {
+ if (prev <= 1) return prev
+ const next = prev - 1
+ setSteps((s) => s.map((v) => v & ((1 << next) - 1)))
+ return next
+ })
+ const clearAll = () => setSteps((prev) => prev.map(() => 0))
+ const insertStepAt = (index) => {
+ setSteps((prev) => { const n = [...prev]; n.splice(index, 0, 0); return n })
+ setCurrentStep((prev) => (prev >= index ? prev + 1 : prev))
+ }
+ const deleteStepAt = (index) => {
+ setSteps((prev) => {
+ if (prev.length <= 1) return prev
+ const n = [...prev]; n.splice(index, 1); return n
+ })
+ setCurrentStep((prev) => {
+ if (prev < 0) return prev
+ if (prev === index) return -1
+ if (prev > index) return prev - 1
+ return prev
+ })
+ }
+
+ // ── Deploy ────────────────────────────────────────────────────────────────
+ const openDeploy = (mode) => { setDeployMode(mode); setDeployOpen(true) }
+
+ const handleDeploySuccess = (result, mode) => {
+ setDeployOpen(false)
+ toast.success(
+ mode === 'update' ? 'Archetype Updated' : 'Archetype Deployed',
+ `"${result.name}" saved successfully.`,
+ )
+ if (result?.id) navigate(`/melodies/archetypes/${result.id}`)
+ }
+
+ // ── Derived ───────────────────────────────────────────────────────────────
+ const speedBpm = msToBpm(stepDelayMs)
+
+ const activeBells = useMemo(() => {
+ if (currentStep < 0 || !steps[currentStep]) return []
+ const active = []
+ for (let bit = 0; bit < noteCount; bit++) {
+ if (steps[currentStep] & (1 << bit)) active.push(bit + 1)
+ }
+ return active
+ }, [currentStep, noteCount, steps])
+
+ const csvOutput = `{${steps.map(stepToNotation).join(',')}}`
+ const progmemOutput = `const uint16_t PROGMEM melody_builtin_custom[] = {\n ${steps.map(stepToHex).join(', ')}\n};`
+
+ // ─── Render ───────────────────────────────────────────────────────────────
+ return (
+
+
+ {/* ── Page header ─────────────────────────────────────────────────── */}
+
+ {loadedArchetype ? (
+ <>
+ openDeploy('new')}>Deploy as New
+ openDeploy('update')}>Update Archetype
+ >
+ ) : (
+ openDeploy('new')}>Deploy Archetype
+ )}
+
+
+ {/* ── Editing-archetype banner ─────────────────────────────────────── */}
+ {loadedArchetype && (
+
+
+ Editing Archetype
+
+
+ {loadedArchetype.name}
+
+ ·
+
+ {loadedArchetype.id}
+
+ navigate('/melodies/composer', { replace: true, state: null })}
+ >
+ Clear
+
+
+ )}
+
+ {/* ── Transport + controls ─────────────────────────────────────────── */}
+
+
+ {/* Row 1 — step/note controls + status + deploy */}
+
+ {/* Step controls */}
+
+ + Step
+ − Step
+
+
+
|
+
+ {/* Note controls */}
+
+ = MAX_NOTES}>+ Note
+ − Note
+
+
+
|
+
+
Clear All
+
+ {/* Status readout */}
+
+
+ {currentStep >= 0
+ ? `Step ${currentStep + 1}/${steps.length}${activeBells.length ? ` · bells ${activeBells.join(', ')}` : ' · silence'}`
+ : `${steps.length} steps · ${noteCount} notes`}
+
+
+
+
+ {/* Divider */}
+
+
+ {/* Row 2 — playback + sliders */}
+
+ {/* Play / Stop + Loop */}
+
+ {isPlaying ? (
+
}
+ >
+ Stop
+
+ ) : (
+
}
+ >
+ Play
+
+ )}
+
+
setLoopEnabled((v) => !v)}
+ >
+ Loop
+
+
+
+
|
+
+ {/* BPM slider */}
+
setStepDelayMs(bpmToMs(v))}
+ style={{ minWidth: 160, flex: '2 1 160px' }}
+ />
+
+ {/* Duration slider */}
+ setNoteDurationMs(v)}
+ style={{ minWidth: 140, flex: '2 1 140px' }}
+ />
+
+ {/* Measure slider */}
+ setMeasureEvery(v)}
+ style={{ minWidth: 100, flex: '1 1 100px' }}
+ />
+
+
+
+
+ {/* ── Step-sequencer grid ──────────────────────────────────────────── */}
+
+
+
+
+
+ {/* Corner cell */}
+
+ Note / Step
+
+
+ {/* Step headers */}
+ {steps.map((_, stepIndex) => {
+ const isCurrent = stepIndex === currentStep
+ const measureHit = measureEvery > 0 && (stepIndex + 1) % measureEvery === 0
+ const measureOdd = measureEvery > 0 && Math.floor(stepIndex / measureEvery) % 2 === 1
+
+ return (
+
+ {/* Clickable step number — opens context menu */}
+
+
{
+ e.stopPropagation()
+ setStepMenuIndex((prev) => (prev === stepIndex ? null : stepIndex))
+ }}
+ style={{
+ display: 'block', width: '100%',
+ padding: 'var(--space-2) var(--space-1)',
+ background: 'transparent', border: 'none',
+ color: 'inherit', cursor: 'pointer',
+ fontFamily: 'inherit', fontSize: 'inherit',
+ fontWeight: 'inherit',
+ }}
+ >
+ {stepIndex + 1}
+
+
+ {/* Context menu */}
+ {stepMenuIndex === stepIndex && (
+
+ {[
+ { label: 'Add After', action: () => { insertStepAt(stepIndex + 1); setStepMenuIndex(null) }, danger: false },
+ { label: 'Add Before', action: () => { insertStepAt(stepIndex); setStepMenuIndex(null) }, danger: false },
+ { label: 'Delete', action: () => { deleteStepAt(stepIndex); setStepMenuIndex(null) }, danger: true },
+ ].map(({ label, action, danger }) => (
+ {
+ e.currentTarget.style.backgroundColor = 'var(--color-bg-island)'
+ }}
+ onMouseLeave={(e) => {
+ e.currentTarget.style.backgroundColor = 'transparent'
+ }}
+ >
+ {label}
+
+ ))}
+
+ )}
+
+
+ )
+ })}
+
+
+
+
+ {Array.from({ length: noteCount }, (_, noteIndex) => (
+
+ {/* Note label */}
+
+
+ {NOTE_LABELS[noteIndex]}
+
+
+
+ {/* Step cells */}
+ {steps.map((stepValue, stepIndex) => {
+ const enabled = Boolean(stepValue & (1 << noteIndex))
+ const isCurrent = stepIndex === currentStep
+ const measureHit = measureEvery > 0 && (stepIndex + 1) % measureEvery === 0
+ const measureOdd = measureEvery > 0 && Math.floor(stepIndex / measureEvery) % 2 === 1
+ const dotColor = noteDotColor(noteIndex + 1, noteColors)
+ const dotGlow = noteDotGlow(noteIndex + 1, noteColors)
+
+ return (
+
+ toggleCell(noteIndex, stepIndex)}
+ style={{
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
+ width: '100%', height: '100%',
+ background: 'transparent', border: 'none',
+ cursor: 'pointer', outline: 'none',
+ }}
+ >
+
+
+
+ )
+ })}
+
+ ))}
+
+
+
+
+
+ {/* ── Output snippets ──────────────────────────────────────────────── */}
+
+
+
+ {csvOutput}
+
+
+
+
+
+ {progmemOutput}
+
+
+
+
+ {/* ── Deploy modal ─────────────────────────────────────────────────── */}
+
setDeployOpen(false)}
+ onSuccess={handleDeploySuccess}
+ mode={deployMode}
+ archetype={loadedArchetype}
+ stepsStr={steps.map(stepToNotation).join(',')}
+ />
+
+ )
+}
+
+// ─── SliderControl sub-component ─────────────────────────────────────────────
+// Inline because it's only used here; not a reusable design system component.
+
+function SliderControl({ label, valueLabel, secondaryLabel, min, max, step, value, onChange, style }) {
+ return (
+
+
+
+ {label}
+
+
+ {secondaryLabel && (
+
+ {secondaryLabel}
+
+ )}
+
+ {valueLabel}
+
+
+
+
onChange(Number(e.target.value))}
+ style={{ width: '100%', accentColor: 'var(--color-primary)', cursor: 'pointer' }}
+ />
+
+ )
+}
diff --git a/frontend/src/pages/bellcloud/melodies/MelodyDetail.jsx b/frontend/src/pages/bellcloud/melodies/MelodyDetail.jsx
new file mode 100644
index 0000000..495b85e
--- /dev/null
+++ b/frontend/src/pages/bellcloud/melodies/MelodyDetail.jsx
@@ -0,0 +1,739 @@
+// frontend/src/pages/melodies/MelodyDetail.jsx
+
+import { useState, useEffect } from 'react'
+import { useParams, useNavigate } from 'react-router-dom'
+import PageHeader from '@/components/ui/PageHeader'
+import Card from '@/components/ui/Card'
+import Button from '@/components/ui/Button'
+import StatusBadge from '@/components/ui/StatusBadge'
+import Spinner from '@/components/ui/Spinner'
+import ConfirmDialog from '@/components/ui/ConfirmDialog'
+import { useToast } from '@/components/ui/Toast'
+import { useAuth } from '@/hooks/useAuth'
+import api from '@/lib/api'
+import {
+ getLocalizedValue,
+ getLanguageName,
+ normalizeColor,
+ formatDuration,
+} from '@/lib/melodyUtils'
+import RowActions from '@/components/ui/RowActions'
+import PillButton from '@/components/ui/PillButton'
+import SpeedCalculatorModal from '@/modals/bellcloud/melodies/SpeedCalculatorModal'
+import SpeedIcon from '@/assets/global-icons/speed.svg?react'
+import PlaybackModal from '@/modals/bellcloud/melodies/PlaybackModal'
+import BinaryTableModal from '@/modals/bellcloud/melodies/BinaryTableModal'
+
+// ─── Helpers ─────────────────────────────────────────────────────────────────
+
+function formatBpm(ms) {
+ const v = Number(ms)
+ if (!v || v <= 0) return null
+ return Math.round(60000 / v)
+}
+
+function mapPercentageToStepDelay(percent, minSpeed, maxSpeed) {
+ const p = Math.max(0, Math.min(100, Number(percent || 0)))
+ const t = p / 100
+ const a = Number(minSpeed)
+ const b = Number(maxSpeed)
+ if (a <= 0 || b <= 0) return Math.round(a + (b - a) * t)
+ return Math.round(a * Math.pow(b / a, t))
+}
+
+function normalizeFileUrl(url) {
+ if (!url || typeof url !== 'string') return null
+ if (url.startsWith('http') || url.startsWith('/api')) return url
+ if (url.startsWith('/')) return `/api${url}`
+ return `/api/${url}`
+}
+
+function copyText(text, onSuccess) {
+ if (navigator.clipboard) {
+ navigator.clipboard.writeText(text).then(onSuccess).catch(() => fallbackCopy(text, onSuccess))
+ } else {
+ fallbackCopy(text, onSuccess)
+ }
+}
+
+function fallbackCopy(text, onSuccess) {
+ const ta = document.createElement('textarea')
+ ta.value = text
+ ta.style.cssText = 'position:fixed;top:0;left:0;opacity:0'
+ document.body.appendChild(ta)
+ ta.focus(); ta.select()
+ try { document.execCommand('copy'); onSuccess?.() } catch (_) {}
+ document.body.removeChild(ta)
+}
+
+// ─── Sub-components ──────────────────────────────────────────────────────────
+
+function DetailField({ label, children, span = 1 }) {
+ return (
+
+
+ {label}
+
+
+ {children ?? — }
+
+
+ )
+}
+
+function MonoField({ label, value, span = 1 }) {
+ const [copied, setCopied] = useState(false)
+ if (!value) return
+ return (
+
+
+ {label}
+
+
+
+ {value}
+
+ copyText(value, () => { setCopied(true); setTimeout(() => setCopied(false), 2000) })}
+ style={{
+ flexShrink: 0,
+ fontSize: '10px',
+ padding: '1px var(--space-2)',
+ borderRadius: 'var(--radius-full)',
+ backgroundColor: copied ? 'var(--color-success-bg)' : 'var(--color-bg-island)',
+ color: copied ? 'var(--color-success)' : 'var(--color-text-muted)',
+ border: `1px solid ${copied ? 'rgba(74,222,128,0.3)' : 'var(--color-border)'}`,
+ cursor: 'pointer',
+ transition: 'background 120ms, color 120ms',
+ lineHeight: 1.6,
+ }}
+ >
+ {copied ? 'Copied' : 'Copy'}
+
+
+
+ )
+}
+
+// ─── Component ────────────────────────────────────────────────────────────────
+
+export default function MelodyDetail() {
+ const { id } = useParams()
+ const navigate = useNavigate()
+ const { toast } = useToast()
+ const { hasPermission } = useAuth()
+ const canEdit = hasPermission('melodies', 'edit')
+
+ const [melody, setMelody] = useState(null)
+ const [files, setFiles] = useState({})
+ const [builtMelody, setBuiltMelody] = useState(null)
+ const [melodySettings, setMelodySettings] = useState(null)
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState('')
+ const [actionLoading, setActionLoading] = useState(false)
+ const [displayLang, setDisplayLang] = useState('en')
+
+ // Modal state
+ const [showDelete, setShowDelete] = useState(false)
+ const [showUnpublish, setShowUnpublish] = useState(false)
+ const [showSpeedCalc, setShowSpeedCalc] = useState(false)
+ const [showPlayback, setShowPlayback] = useState(false)
+ const [showBinaryView, setShowBinaryView] = useState(false)
+ const [codeCopied, setCodeCopied] = useState(false)
+
+ useEffect(() => {
+ api.get('/settings/melody').then((ms) => {
+ setMelodySettings(ms)
+ setDisplayLang(ms.primary_language || 'en')
+ }).catch(() => {})
+ }, [])
+
+ useEffect(() => { loadData() }, [id])
+
+ const loadData = async () => {
+ setLoading(true)
+ setError('')
+ try {
+ const [m, f] = await Promise.all([
+ api.get(`/melodies/${id}`),
+ api.get(`/melodies/${id}/files`),
+ ])
+ setMelody(m)
+ setFiles(f)
+ try {
+ const bm = await api.get(`/builder/melodies/for-melody/${id}`)
+ setBuiltMelody(bm || null)
+ } catch { setBuiltMelody(null) }
+ } catch (err) {
+ setError(err.message || 'Failed to load melody.')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const handleDelete = async () => {
+ try {
+ await api.delete(`/melodies/${id}`)
+ toast.success('Deleted', 'Melody removed.')
+ navigate('/melodies')
+ } catch (err) {
+ toast.danger('Error', err.message)
+ setShowDelete(false)
+ }
+ }
+
+ const handlePublish = async () => {
+ setActionLoading(true)
+ try {
+ await api.post(`/melodies/${id}/publish`)
+ await loadData()
+ toast.success('Published', 'Melody is now live.')
+ } catch (err) {
+ toast.danger('Error', err.message)
+ } finally {
+ setActionLoading(false)
+ }
+ }
+
+ const handleUnpublish = async () => {
+ setActionLoading(true)
+ try {
+ await api.post(`/melodies/${id}/unpublish`)
+ setShowUnpublish(false)
+ await loadData()
+ toast.success('Unpublished', 'Melody reverted to draft.')
+ } catch (err) {
+ toast.danger('Error', err.message)
+ setShowUnpublish(false)
+ } finally {
+ setActionLoading(false)
+ }
+ }
+
+ // ── Loading / error states ────────────────────────────────────────────────
+
+ if (loading) {
+ return (
+
+ )
+ }
+
+ if (error || !melody) {
+ return (
+
+
+
+ {error || 'Melody not found.'}
+
+
+ )
+ }
+
+ // ── Derived values ────────────────────────────────────────────────────────
+
+ const info = melody.information || {}
+ const settings = melody.default_settings || {}
+ const languages = melodySettings?.available_languages || ['en']
+ const displayName = getLocalizedValue(info.name, displayLang, 'Untitled Melody')
+ const isPublished = melody.status === 'published'
+ const speedMs = mapPercentageToStepDelay(settings.speed, info.minSpeed, info.maxSpeed)
+ const speedBpm = formatBpm(speedMs)
+ const minBpm = formatBpm(info.minSpeed)
+ const maxBpm = formatBpm(info.maxSpeed)
+ const missingArchetype = Boolean(melody.pid) && !builtMelody?.id
+
+ const binaryUrl = normalizeFileUrl(
+ (builtMelody?.binary_url ? `/api${builtMelody.binary_url}` : null) ||
+ melody.url ||
+ files.binary_url ||
+ null
+ )
+ const binaryPid = builtMelody?.pid || melody.pid || 'binary'
+ const binaryFilename = `${binaryPid}.bsm`
+
+ const handleBinaryDownload = async (e) => {
+ e.preventDefault()
+ const endpoint = `/api/melodies/${melody.id}/download/binary`
+ try {
+ const token = localStorage.getItem('access_token')
+ const res = await fetch(endpoint, { headers: token ? { Authorization: `Bearer ${token}` } : {} })
+ if (!res.ok) throw new Error(res.statusText)
+ const blob = await res.blob()
+ const url = URL.createObjectURL(blob)
+ const a = document.createElement('a'); a.href = url; a.download = binaryFilename; a.click()
+ URL.revokeObjectURL(url)
+ } catch {
+ if (binaryUrl) { const a = document.createElement('a'); a.href = binaryUrl; a.download = binaryFilename; a.rel = 'noopener noreferrer'; document.body.appendChild(a); a.click(); document.body.removeChild(a) }
+ }
+ }
+
+ // ─── Render ───────────────────────────────────────────────────────────────
+
+ return (
+
+
+ {/* ── Header ──────────────────────────────────────────────────────── */}
+
+ {displayName}
+
+ {isPublished ? 'Live' : 'Draft'}
+
+ {languages.length > 1 && (
+ ({ label: getLanguageName(l), value: l }))}
+ onChange={(val) => setDisplayLang(val)}
+ >
+ {getLanguageName(displayLang)}
+
+ )}
+
+ }
+ >
+
+ {canEdit && (
+ <>
+ {/* Playback — success icon-only */}
+ setShowPlayback(true)}
+ icon={
+
+
+
+ }
+ />
+
+ {/* Speed Calc — info icon-only (rabbit) */}
+ setShowSpeedCalc(true)}
+ icon={
+
+ }
+ />
+
+ {/* Actions dropdown — Edit, Publish/Unpublish, Delete */}
+ ,
+ onClick: () => navigate(`/melodies/${id}/edit`),
+ },
+ isPublished
+ ? {
+ label: 'Unpublish',
+ icon: ,
+ color: 'var(--color-warning)',
+ onClick: () => setShowUnpublish(true),
+ }
+ : {
+ label: 'Publish',
+ icon: ,
+ color: 'var(--color-success)',
+ onClick: handlePublish,
+ },
+ {
+ label: 'Delete',
+ icon: ,
+ color: 'var(--color-danger)',
+ divider: true,
+ onClick: () => setShowDelete(true),
+ },
+ ]}
+ />
+ >
+ )}
+
+
+ {/* ── Outdated archetype warning ───────────────────────────────────── */}
+ {info.outdated_archetype && (
+
+
+
+
+
+ Outdated Archetype — The archetype assigned to this melody has changed or been removed. Re-assign one in the editor to clear this warning.
+
+
+ )}
+
+ {/* ── Main grid — masonry layout ──────────────────────────────────── */}
+
+
+ {/* Melody Information — masonry item 1 */}
+
+
+ {/* Row 1: Color | Tone | Steps */}
+
+ {info.color ? (
+
+
+ {info.color}
+
+ ) : null}
+
+
+ {info.melodyTone ?? '—'}
+
+
{info.steps}
+
+ {/* Row 2: Min Speed | Max Speed | Unique Bells */}
+
+ {info.minSpeed ? (
+ {minBpm} bpm · {info.minSpeed} ms
+ ) : null}
+
+
+ {info.maxSpeed ? (
+ {maxBpm} bpm · {info.maxSpeed} ms
+ ) : null}
+
+
{info.totalActiveBells}
+
+ {/* Row 3: True Ring | Type | Tags */}
+
+ {info.isTrueRing ? 'Yes' : 'No'}
+
+
+ {melody.type ?? '—'}
+
+ {info.customTags?.length > 0 ? (
+
+
+ {info.customTags.map((tag) => (
+ {tag}
+ ))}
+
+
+ ) : (
+
+ —
+
+ )}
+
+ {/* Row 4: Description (full width) */}
+
+ {getLocalizedValue(info.description, displayLang) || null}
+
+
+
+
+ {/* Files — masonry item 2 */}
+
+
+
+ {/* Binary */}
+
+
+ Binary File
+
+ {binaryUrl ? (
+
+ {/* Main row: name + pills + note count */}
+
+
+ {builtMelody?.name || binaryFilename}
+
+ {missingArchetype && (
+ ⚠ Missing
+ )}
+ {/* Download — blue pill */}
+
+ Download
+
+ {/* View — neutral pill */}
+ setShowBinaryView(true)}
+ style={{
+ padding: '2px 10px',
+ borderRadius: 'var(--radius-full)',
+ fontSize: 'var(--font-size-xs)',
+ fontWeight: 'var(--font-weight-medium)',
+ color: 'var(--color-text-secondary)',
+ backgroundColor: 'var(--color-bg-elevated)',
+ border: '1px solid var(--color-border)',
+ cursor: 'pointer',
+ }}
+ >
+ View
+
+
+ {info.totalNotes ?? 0} active notes
+
+
+ {/* Subtitle: file: archetypename.bsm */}
+
+ file: {binaryFilename}
+
+
+ ) : (
+
Not uploaded
+ )}
+
+
+ {/* Audio preview */}
+
+
+ Audio Preview
+
+ {normalizeFileUrl(files.preview_url) ? (
+
+ ) : (
+
Not uploaded
+ )}
+
+
+
+
+ {/* Default Settings — masonry item 3 */}
+
+
+ {/* Row 1: Speed | Duration | Total Run Duration */}
+
+ {settings.speed != null ? (
+
+ {settings.speed}%
+ {speedBpm && · {speedBpm} bpm }
+ {speedMs && · {speedMs} ms }
+
+ ) : null}
+
+ {formatDuration(settings.duration ?? 0)}
+ {settings.totalRunDuration}
+
+ {/* Row 2: Pause Duration | Infinite Loop */}
+ {settings.pauseDuration}
+
+ {settings.infiniteLoop ? 'Yes' : 'No'}
+
+
+ {/* Row 3: Echo Ring */}
+
+ {settings.echoRing?.length ? (
+
+ {settings.echoRing.join(', ')}
+
+ ) : null}
+
+
+
+ {/* Note assignments */}
+ {settings.noteAssignments?.length > 0 && (
+
+
+ Note Assignments
+
+
+ {settings.noteAssignments.map((bell, i) => (
+
+
+ {String.fromCharCode(65 + i)}
+
+
+
0 ? 'var(--color-text-primary)' : 'var(--color-text-muted)', fontFamily: 'var(--font-family-mono)' }}>
+ {bell > 0 ? bell : '—'}
+
+
+ ))}
+
+
+ Top = Note label · Bottom = Assigned bell number
+
+
+ )}
+
+
+ {/* Identifiers — masonry item 4 */}
+
+
+
+
+ {melody.url && }
+
+
+
+ {/* History — masonry item 5 */}
+ {melody.metadata && (
+
+
+ {melody.metadata.dateCreated && (
+ {melody.metadata.dateCreated}
+ )}
+ {melody.metadata.createdBy && (
+ {melody.metadata.createdBy}
+ )}
+ {melody.metadata.dateEdited && (
+ {melody.metadata.dateEdited}
+ )}
+ {melody.metadata.lastEditedBy && (
+ {melody.metadata.lastEditedBy}
+ )}
+
+
+ )}
+
+ {/* Admin Notes — masonry item 5 */}
+
+ {(melody.metadata?.adminNotes?.length || 0) > 0 ? (
+
+ {melody.metadata.adminNotes.map((note, i) => (
+
+ {note}
+
+ ))}
+
+ ) : (
+
+ No admin notes yet. Add them in the editor.
+
+ )}
+
+
+
+
+ {/* ── Firmware code ────────────────────────────────────────────────── */}
+ {builtMelody?.progmem_code && (
+
+
+ copyText(builtMelody.progmem_code, () => { setCodeCopied(true); setTimeout(() => setCodeCopied(false), 2000) })}
+ >
+ {codeCopied ? 'Copied!' : 'Copy Code'}
+
+
+
+ {builtMelody.progmem_code}
+
+
+ )}
+
+ {/* ── Modals ───────────────────────────────────────────────────────── */}
+
setShowDelete(false)}
+ onConfirm={handleDelete}
+ variant="danger"
+ title="Delete Melody"
+ message={`Are you sure you want to delete "${displayName}"? This action cannot be undone.`}
+ confirmLabel="Delete"
+ />
+
+ setShowUnpublish(false)}
+ onConfirm={handleUnpublish}
+ variant="danger"
+ title="Unpublish Melody"
+ message={`Unpublish "${displayName}"? It will revert to draft and become unavailable to devices.`}
+ confirmLabel="Unpublish"
+ loading={actionLoading}
+ />
+
+ setShowSpeedCalc(false)}
+ melody={melody}
+ builtMelody={builtMelody}
+ archetypeCsv={info.archetype_csv || null}
+ onSaved={() => { setShowSpeedCalc(false); loadData() }}
+ />
+
+ setShowPlayback(false)}
+ melody={melody}
+ builtMelody={builtMelody}
+ files={files}
+ archetypeCsv={info.archetype_csv || null}
+ />
+
+ setShowBinaryView(false)}
+ melody={melody}
+ builtMelody={builtMelody}
+ files={files}
+ archetypeCsv={info.archetype_csv || null}
+ />
+
+ )
+}
diff --git a/frontend/src/pages/bellcloud/melodies/MelodyForm.jsx b/frontend/src/pages/bellcloud/melodies/MelodyForm.jsx
new file mode 100644
index 0000000..78a2eba
--- /dev/null
+++ b/frontend/src/pages/bellcloud/melodies/MelodyForm.jsx
@@ -0,0 +1,946 @@
+// frontend/src/pages/melodies/MelodyForm.jsx
+
+import { useState, useEffect, useRef } from 'react'
+import { useNavigate, useParams } from 'react-router-dom'
+import PageHeader from '@/components/ui/PageHeader'
+import Card from '@/components/ui/Card'
+import Button from '@/components/ui/Button'
+import FormField from '@/components/ui/FormField'
+import Spinner from '@/components/ui/Spinner'
+import { useAuth } from '@/hooks/useAuth'
+import api from '@/lib/api'
+import {
+ getLocalizedValue,
+ getLanguageName,
+ normalizeColor,
+ formatDuration,
+ parseLocalizedString,
+ serializeLocalizedString,
+} from '@/lib/melodyUtils'
+import TranslationModal from '@/modals/bellcloud/melodies/TranslationModal'
+import SelectArchetypeModal from '@/modals/bellcloud/melodies/SelectArchetypeModal'
+import BuildOnTheFlyModal from '@/modals/bellcloud/melodies/BuildOnTheFlyModal'
+import SpeedCalculatorModal from '@/modals/bellcloud/melodies/SpeedCalculatorModal'
+import PlaybackModal from '@/modals/bellcloud/melodies/PlaybackModal'
+import BinaryTableModal from '@/modals/bellcloud/melodies/BinaryTableModal'
+
+// ─── Constants ───────────────────────────────────────────────────────────────
+
+const MELODY_TYPES = ['orthodox', 'catholic', 'all']
+const MELODY_TONES = ['normal', 'festive', 'cheerful', 'lamentation']
+
+const defaultInfo = {
+ name: '', description: '', melodyTone: 'normal', customTags: [],
+ minSpeed: 0, maxSpeed: 0, totalNotes: 1, totalActiveBells: 0,
+ steps: 0, color: '', isTrueRing: false, previewURL: '',
+}
+
+const defaultSettings = {
+ speed: 50, duration: 0, totalRunDuration: 0, pauseDuration: 0,
+ infiniteLoop: false, echoRing: [], noteAssignments: [],
+}
+
+// ─── Helpers ─────────────────────────────────────────────────────────────────
+
+function formatBpm(ms) {
+ const v = Number(ms)
+ if (!v || v <= 0) return null
+ return Math.round(60000 / v)
+}
+
+function mapPercentageToStepDelay(percent, minSpeed, maxSpeed) {
+ const p = Math.max(0, Math.min(100, Number(percent || 0)))
+ const t = p / 100
+ const a = Number(minSpeed); const b = Number(maxSpeed)
+ if (a <= 0 || b <= 0) return Math.round(a + (b - a) * t)
+ return Math.round(a * Math.pow(b / a, t))
+}
+
+function normalizeFileUrl(url) {
+ if (!url || typeof url !== 'string') return null
+ if (url.startsWith('http') || url.startsWith('/api')) return url
+ if (url.startsWith('/')) return `/api${url}`
+ return `/api/${url}`
+}
+
+function computeActiveBells(assignments) {
+ return new Set((assignments || []).filter((v) => v > 0)).size
+}
+
+// ─── Section header helper ────────────────────────────────────────────────────
+
+function SectionLabel({ children }) {
+ return (
+
+ {children}
+
+ )
+}
+
+// ─── Component ────────────────────────────────────────────────────────────────
+
+export default function MelodyForm() {
+ const { id } = useParams()
+ const isEdit = Boolean(id)
+ const navigate = useNavigate()
+ const { user } = useAuth()
+
+ const [information, setInformation] = useState({ ...defaultInfo })
+ const [settings, setSettings] = useState({ ...defaultSettings })
+ const [type, setType] = useState('all')
+ const [url, setUrl] = useState('')
+ const [pid, setPid] = useState('')
+ const [melodyStatus, setMelodyStatus] = useState('draft')
+ const [previewFile, setPreviewFile] = useState(null)
+ const [existingFiles, setExistingFiles] = useState({})
+ const [uploading, setUploading] = useState(false)
+ const [loading, setLoading] = useState(false)
+ const [saving, setSaving] = useState(false)
+ const [error, setError] = useState('')
+ const [tagInput, setTagInput] = useState('')
+ const [melodySettings, setMelodySettings] = useState(null)
+ const [editLang, setEditLang] = useState('en')
+ const [adminNotes, setAdminNotes] = useState([])
+ const [newNote, setNewNote] = useState('')
+ const [savedMelodyId, setSavedMelodyId] = useState(null)
+ const [builtMelody, setBuiltMelody] = useState(null)
+ const [assignedBinaryPid, setAssignedBinaryPid] = useState(null)
+
+ // Modal state
+ const [translationModal, setTranslationModal] = useState({ open: false, field: '', fieldKey: '', multiline: false })
+ const [showSelectBuilt, setShowSelectBuilt] = useState(false)
+ const [showBuildOnTheFly,setShowBuildOnTheFly]= useState(false)
+ const [showSpeedCalc, setShowSpeedCalc] = useState(false)
+ const [showPlayback, setShowPlayback] = useState(false)
+ const [showBinaryView, setShowBinaryView] = useState(false)
+
+ const previewInputRef = useRef(null)
+
+ // ── Lifecycle ─────────────────────────────────────────────────────────────
+
+ useEffect(() => {
+ api.get('/settings/melody').then((ms) => {
+ setMelodySettings(ms)
+ setEditLang(ms.primary_language || 'en')
+ }).catch(() => {})
+ }, [])
+
+ useEffect(() => {
+ if (isEdit) loadMelody()
+ }, [id])
+
+ // Keep noteAssignments array in sync with totalNotes
+ useEffect(() => {
+ const count = information.totalNotes || 1
+ setSettings((prev) => {
+ const current = [...(prev.noteAssignments || [])]
+ if (current.length < count) {
+ while (current.length < count) current.push(0)
+ } else if (current.length > count) {
+ current.length = count
+ }
+ return { ...prev, noteAssignments: current }
+ })
+ }, [information.totalNotes])
+
+ const loadMelody = async () => {
+ setLoading(true)
+ try {
+ const [melody, files] = await Promise.all([
+ api.get(`/melodies/${id}`),
+ api.get(`/melodies/${id}/files`),
+ ])
+ setInformation({ ...defaultInfo, ...melody.information })
+ setSettings({ ...defaultSettings, ...melody.default_settings })
+ setType(melody.type || 'all')
+ setUrl(melody.url || '')
+ setPid(melody.pid || '')
+ setMelodyStatus(melody.status || 'published')
+ setExistingFiles(files)
+ setAdminNotes(melody.metadata?.adminNotes || [])
+ try {
+ const bm = await api.get(`/builder/melodies/for-melody/${id}`)
+ setBuiltMelody(bm || null)
+ setAssignedBinaryPid(bm?.pid || null)
+ } catch { setBuiltMelody(null) }
+ } catch (err) {
+ setError(err.message || 'Failed to load melody.')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ // ── Field helpers ─────────────────────────────────────────────────────────
+
+ const updateInfo = (field, value) => setInformation((prev) => ({ ...prev, [field]: value }))
+ const updateSettings = (field, value) => setSettings((prev) => ({ ...prev, [field]: value }))
+
+ const updateLocalizedField = (fieldKey, text) => {
+ const dict = parseLocalizedString(information[fieldKey])
+ dict[editLang] = text
+ updateInfo(fieldKey, serializeLocalizedString(dict))
+ }
+
+ const addTag = () => {
+ const tag = tagInput.trim()
+ if (tag && !information.customTags.includes(tag))
+ updateInfo('customTags', [...information.customTags, tag])
+ setTagInput('')
+ }
+
+ const removeTag = (tag) =>
+ updateInfo('customTags', information.customTags.filter((t) => t !== tag))
+
+ const parseIntList = (str) => {
+ if (!str.trim()) return []
+ return str.split(',').map((s) => parseInt(s.trim(), 10)).filter((n) => !isNaN(n))
+ }
+
+ // ── File helpers ──────────────────────────────────────────────────────────
+
+ const getEffectiveBinary = () => {
+ const effectiveUrl = normalizeFileUrl(
+ (builtMelody?.binary_url ? `/api${builtMelody.binary_url}` : null) ||
+ url || existingFiles.binary_url || null
+ )
+ const fixedName = builtMelody?.pid || assignedBinaryPid || pid || null
+ const effectiveName = fixedName ? `${fixedName}.bsm` : (effectiveUrl ? 'binary.bsm' : null)
+ return { effectiveUrl, effectiveName }
+ }
+
+ const uploadFiles = async (melodyId) => {
+ if (previewFile) {
+ setUploading(true)
+ await api.upload(`/melodies/${melodyId}/upload/preview`, previewFile)
+ setUploading(false)
+ }
+ }
+
+ // ── Ensure melody exists before opening binary modals ─────────────────────
+
+ const ensureSaved = async () => {
+ if (isEdit || savedMelodyId) return isEdit ? id : savedMelodyId
+ setSaving(true)
+ setError('')
+ try {
+ const now = new Date().toISOString()
+ const userName = user?.name || 'Unknown'
+ const body = buildBody({ dateCreated: now, dateEdited: now, createdBy: userName, lastEditedBy: userName, adminNotes })
+ const created = await api.post('/melodies?publish=false', body)
+ setSavedMelodyId(created.id)
+ return created.id
+ } catch (err) {
+ setError(err.message)
+ return null
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ // ── Build request body ────────────────────────────────────────────────────
+
+ const buildBody = (metadata) => {
+ const { notes: _notes, ...infoWithoutNotes } = information
+ const totalActiveBells = computeActiveBells(settings.noteAssignments)
+ const body = {
+ information: { ...infoWithoutNotes, totalActiveBells },
+ default_settings: settings,
+ type, pid,
+ metadata,
+ }
+ if (url) body.url = url
+ return body
+ }
+
+ // ── Save / publish ────────────────────────────────────────────────────────
+
+ const handleSave = async (publish) => {
+ setSaving(true)
+ setError('')
+ try {
+ const now = new Date().toISOString()
+ const userName = user?.name || 'Unknown'
+ let melodyId = id
+
+ if (isEdit) {
+ await api.put(`/melodies/${id}`, buildBody({ dateEdited: now, lastEditedBy: userName, adminNotes }))
+ } else if (savedMelodyId) {
+ melodyId = savedMelodyId
+ await api.put(`/melodies/${savedMelodyId}`, buildBody({ dateEdited: now, lastEditedBy: userName, adminNotes }))
+ if (publish) await api.post(`/melodies/${savedMelodyId}/publish`)
+ } else {
+ const body = buildBody({ dateCreated: now, dateEdited: now, createdBy: userName, lastEditedBy: userName, adminNotes })
+ const created = await api.post(`/melodies?publish=${publish}`, body)
+ melodyId = created.id
+ setSavedMelodyId(melodyId)
+ }
+ await uploadFiles(melodyId)
+ navigate(`/melodies/${melodyId}`)
+ } catch (err) {
+ setError(err.message || 'Save failed.')
+ setUploading(false)
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ const handlePublishAction = async () => {
+ setSaving(true)
+ setError('')
+ try {
+ const now = new Date().toISOString()
+ const userName = user?.name || 'Unknown'
+ await api.put(`/melodies/${id}`, buildBody({ dateEdited: now, lastEditedBy: userName, adminNotes }))
+ await uploadFiles(id)
+ await api.post(`/melodies/${id}/publish`)
+ navigate(`/melodies/${id}`)
+ } catch (err) {
+ setError(err.message)
+ setUploading(false)
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ const handleUnpublishAction = async () => {
+ setSaving(true)
+ setError('')
+ try {
+ await api.post(`/melodies/${id}/unpublish`)
+ navigate(`/melodies/${id}`)
+ } catch (err) {
+ setError(err.message)
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ // ── Derived ───────────────────────────────────────────────────────────────
+
+ const languages = melodySettings?.available_languages || ['en']
+ const durationValues = melodySettings?.duration_values || [0]
+ const quickColors = melodySettings?.quick_colors || []
+ const durationIndex = durationValues.indexOf(settings.duration)
+ const currentDurIdx = durationIndex >= 0 ? durationIndex : 0
+ const speedMs = mapPercentageToStepDelay(settings.speed, information.minSpeed, information.maxSpeed)
+ const speedBpm = formatBpm(speedMs)
+ const minBpm = formatBpm(information.minSpeed)
+ const maxBpm = formatBpm(information.maxSpeed)
+ const { effectiveUrl: binaryUrl, effectiveName: binaryName } = getEffectiveBinary()
+ const missingArchetype = Boolean(pid) && !builtMelody?.id
+ const isBusy = saving || uploading
+
+ // ── Loading state ─────────────────────────────────────────────────────────
+
+ if (loading) {
+ return (
+
+ )
+ }
+
+ // ─── Render ───────────────────────────────────────────────────────────────
+
+ return (
+
+
+ {/* ── Header ──────────────────────────────────────────────────────── */}
+
+ {isEdit ? (
+ <>
+ setShowPlayback(true)}>Playback
+ setShowSpeedCalc(true)}>Speed Calc
+ navigate(`/melodies/${id}`)}>Cancel
+ {melodyStatus === 'draft' ? (
+ <>
+ handleSave(false)}>
+ Save Draft
+
+
+ Publish
+
+ >
+ ) : (
+ <>
+ handleSave(false)}>
+ {uploading ? 'Uploading…' : 'Update'}
+
+
+ Unpublish
+
+ >
+ )}
+ >
+ ) : (
+ <>
+ {
+ if (savedMelodyId) {
+ try { await api.delete(`/melodies/${savedMelodyId}`) } catch { /* best-effort */ }
+ }
+ navigate('/melodies')
+ }}>
+ Cancel
+
+ handleSave(false)}>
+ Save Draft
+
+ handleSave(true)}>
+ Publish Melody
+
+ >
+ )}
+
+
+ {/* ── Error banner ────────────────────────────────────────────────── */}
+ {error && (
+
+ {error}
+
+ )}
+
+ {/* ── Form body ───────────────────────────────────────────────────── */}
+
+
+ {/* ── Modals ───────────────────────────────────────────────────────── */}
+
setTranslationModal((p) => ({ ...p, open: false }))}
+ field={translationModal.field}
+ fieldKey={translationModal.fieldKey}
+ multiline={translationModal.multiline}
+ value={information[translationModal.fieldKey]}
+ languages={languages}
+ primaryLanguage={melodySettings?.primary_language || 'en'}
+ onChange={(fieldKey, serialized) => updateInfo(fieldKey, serialized)}
+ />
+
+ setShowSelectBuilt(false)}
+ melodyId={isEdit ? id : savedMelodyId}
+ currentMelody={{ id: isEdit ? id : savedMelodyId, information, default_settings: settings, type, pid, url }}
+ currentBuiltMelody={builtMelody}
+ onAssigned={(bm) => {
+ setBuiltMelody(bm)
+ setAssignedBinaryPid(bm?.pid || null)
+ setShowSelectBuilt(false)
+ if (isEdit) loadMelody()
+ }}
+ />
+
+ setShowBuildOnTheFly(false)}
+ melodyId={isEdit ? id : savedMelodyId}
+ currentMelody={{ id: isEdit ? id : savedMelodyId, information, default_settings: settings, type, pid, url }}
+ currentBuiltMelody={builtMelody}
+ defaultName={getLocalizedValue(information.name, 'en', '')}
+ defaultPid={pid}
+ onAssigned={(bm) => {
+ setBuiltMelody(bm)
+ setAssignedBinaryPid(bm?.pid || null)
+ setShowBuildOnTheFly(false)
+ if (isEdit) loadMelody()
+ }}
+ />
+
+ setShowSpeedCalc(false)}
+ melody={{ id: isEdit ? id : savedMelodyId, information, default_settings: settings, type, pid, url }}
+ builtMelody={builtMelody}
+ archetypeCsv={information.archetype_csv || null}
+ onSaved={() => { setShowSpeedCalc(false); if (isEdit) loadMelody() }}
+ />
+
+ setShowPlayback(false)}
+ melody={{ id: isEdit ? id : savedMelodyId, information, default_settings: settings, type, pid, url }}
+ builtMelody={builtMelody}
+ files={existingFiles}
+ archetypeCsv={information.archetype_csv || null}
+ />
+
+ setShowBinaryView(false)}
+ melody={{ id: isEdit ? id : savedMelodyId, information, default_settings: settings, type, pid, url }}
+ builtMelody={builtMelody}
+ files={existingFiles}
+ archetypeCsv={information.archetype_csv || null}
+ />
+
+ )
+}
diff --git a/frontend/src/pages/bellcloud/melodies/MelodyList.jsx b/frontend/src/pages/bellcloud/melodies/MelodyList.jsx
new file mode 100644
index 0000000..7ef72e1
--- /dev/null
+++ b/frontend/src/pages/bellcloud/melodies/MelodyList.jsx
@@ -0,0 +1,1247 @@
+// frontend/src/pages/melodies/MelodyList.jsx
+
+import { useState, useEffect, useRef, useMemo } from 'react'
+import { useNavigate } from 'react-router-dom'
+import api from '@/lib/api'
+import { useAuth } from '@/hooks/useAuth'
+import PageHeader from '@/components/ui/PageHeader'
+import Button from '@/components/ui/Button'
+import DataTable from '@/components/ui/DataTable'
+import StatusBadge from '@/components/ui/StatusBadge'
+import Pagination from '@/components/ui/Pagination'
+import SearchBar from '@/components/ui/SearchBar'
+import Select from '@/components/ui/Select'
+import RowActions from '@/components/ui/RowActions'
+import Icon from '@/components/ui/Icon'
+import ConfirmDialog from '@/components/ui/ConfirmDialog'
+import { fmtRelative, fmtDateTime as fmtDateTimeCentral } from '@/lib/formatters'
+
+// ─── Utilities ────────────────────────────────────────────────────────────────
+
+function parseLocalizedString(value) {
+ if (!value) return {}
+ if (typeof value === 'object') return value
+ try {
+ const parsed = JSON.parse(value)
+ if (typeof parsed === 'object' && parsed !== null) return parsed
+ } catch {
+ return { en: value }
+ }
+ return {}
+}
+
+function getLocalizedValue(value, lang, fallback = '') {
+ const dict = parseLocalizedString(value)
+ return dict[lang] || dict['en'] || Object.values(dict)[0] || fallback
+}
+
+function normalizeColor(val) {
+ if (!val) return null
+ if (val.startsWith('0x') || val.startsWith('0X')) {
+ const hex = val.slice(2)
+ return `#${hex.length === 8 ? hex.slice(2) : hex}`
+ }
+ if (val.startsWith('#')) return val
+ return `#${val}`
+}
+
+function formatBpm(ms) {
+ const value = Number(ms)
+ if (!value || value <= 0) return null
+ return Math.round(60000 / value)
+}
+
+function formatRelativeTime(isoValue) { return fmtRelative(isoValue) }
+
+function formatDateShort(isoValue) {
+ const d = new Date(isoValue)
+ if (Number.isNaN(d.getTime())) return { date: '—', time: '—' }
+ const dd = String(d.getDate()).padStart(2, '0')
+ const mm = String(d.getMonth() + 1).padStart(2, '0')
+ const yyyy = d.getFullYear()
+ const hh = String(d.getHours()).padStart(2, '0')
+ const min = String(d.getMinutes()).padStart(2, '0')
+ return { date: `${dd}/${mm}/${yyyy}`, time: `${hh}:${min}` }
+}
+
+function parseDateValue(isoValue) {
+ if (!isoValue) return 0
+ const t = new Date(isoValue).getTime()
+ return Number.isNaN(t) ? 0 : t
+}
+
+// ─── Column definitions ───────────────────────────────────────────────────────
+
+const ALL_COLUMNS = [
+ { key: 'color', label: '', pickerLabel: 'Colour', defaultOn: true, width: '16px' },
+ { key: 'name', label: 'Name', defaultOn: true, alwaysOn: true, sortable: true },
+ { key: 'status', label: 'Status', defaultOn: true, align: 'center' },
+ { key: 'type', label: 'Type', defaultOn: true, align: 'center' },
+ { key: 'tone', label: 'Tone', defaultOn: true },
+ { key: 'totalActiveBells', label: 'Unique Bells', defaultOn: true, sortable: true, align: 'center' },
+ { key: 'isTrueRing', label: 'True Ring', defaultOn: true },
+ { key: 'minSpeed', label: 'Min Speed', defaultOn: false },
+ { key: 'maxSpeed', label: 'Max Speed', defaultOn: false },
+ { key: 'tags', label: 'Tags', defaultOn: false },
+ { key: 'description', label: 'Description', defaultOn: false },
+ { key: 'speed', label: 'Speed', defaultOn: false },
+ { key: 'duration', label: 'Duration', defaultOn: false },
+ { key: 'totalRunDuration', label: 'Total Run', defaultOn: false },
+ { key: 'pauseDuration', label: 'Pause', defaultOn: false },
+ { key: 'infiniteLoop', label: 'Infinite', defaultOn: false },
+ { key: 'noteAssignments', label: 'Note Assignments', defaultOn: false },
+ { key: 'binaryFile', label: 'Binary File', defaultOn: false },
+ { key: 'dateCreated', label: 'Created', defaultOn: false, sortable: true },
+ { key: 'dateEdited', label: 'Last Edited', defaultOn: false, sortable: true },
+ { key: 'createdBy', label: 'Created By', defaultOn: false },
+ { key: 'lastEditedBy', label: 'Last Edited By', defaultOn: false },
+ { key: 'pid', label: 'PID', defaultOn: false },
+ { key: 'docId', label: 'Doc ID', defaultOn: false },
+]
+
+const STORAGE_KEY = 'melodyListColumnPrefs_v2'
+
+function loadColumnPrefs() {
+ try {
+ const raw = localStorage.getItem(STORAGE_KEY)
+ if (raw) {
+ const prefs = JSON.parse(raw)
+ if (prefs.order && prefs.visible) return prefs
+ }
+ } catch { /* ignore */ }
+ return {
+ order: ALL_COLUMNS.map((c) => c.key),
+ visible: ALL_COLUMNS.filter((c) => c.defaultOn).map((c) => c.key),
+ }
+}
+
+function saveColumnPrefs(order, visible) {
+ try {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify({ order, visible }))
+ } catch { /* ignore */ }
+}
+
+const LANG_NAMES = {
+ en: 'English', el: 'Greek', sr: 'Serbian', bg: 'Bulgarian',
+ ro: 'Romanian', ru: 'Russian', uk: 'Ukrainian', ar: 'Arabic',
+ fr: 'French', de: 'German', es: 'Spanish', it: 'Italian',
+}
+
+// ─── Progress bar colour helpers ──────────────────────────────────────────────
+
+function speedBarColor(speedPercent) {
+ const v = Math.max(0, Math.min(100, Number(speedPercent || 0)))
+ if (v <= 50) {
+ const t = v / 50
+ const hue = 120 + (210 - 120) * t // green → blue
+ return `hsl(${hue}, 85%, 46%)`
+ }
+ const t = (v - 50) / 50
+ const hue = 210 + (0 - 210) * t // blue → red
+ return `hsl(${hue}, 85%, 46%)`
+}
+
+function durationBarColor(percent) {
+ const v = Math.max(0, Math.min(100, Number(percent || 0)))
+ const hue = 220 + (0 - 220) * (v / 100) // blue → red
+ return `hsl(${hue}, 85%, 46%)`
+}
+
+function mapPercentageToStepDelay(percent, minSpeed, maxSpeed) {
+ if (minSpeed == null || maxSpeed == null) return null
+ const p = Math.max(0, Math.min(100, Number(percent || 0)))
+ const t = p / 100
+ const a = Number(minSpeed)
+ const b = Number(maxSpeed)
+ if (a <= 0 || b <= 0) return Math.round(a + (b - a) * t)
+ return Math.round(a * Math.pow(b / a, t))
+}
+
+function formatDurationVerbose(seconds) {
+ const total = Number(seconds || 0)
+ const mins = Math.floor(total / 60)
+ const secs = total % 60
+ return `${mins} min ${secs} sec`
+}
+
+// ─── DocIdCell — hover highlight + click to copy ─────────────────────────────
+
+function DocIdCell({ id }) {
+ const [copied, setCopied] = useState(false)
+ const [hovered, setHovered] = useState(false)
+
+ function handleClick(e) {
+ e.stopPropagation()
+ if (!id) return
+ const fn = () => { setCopied(true); setTimeout(() => setCopied(false), 1800) }
+ if (navigator.clipboard) {
+ navigator.clipboard.writeText(id).then(fn).catch(() => {
+ const ta = document.createElement('textarea')
+ ta.value = id; ta.style.cssText = 'position:fixed;opacity:0'
+ document.body.appendChild(ta); ta.select()
+ try { document.execCommand('copy'); fn() } catch (_) {}
+ document.body.removeChild(ta)
+ })
+ }
+ }
+
+ return (
+
+ setHovered(true)}
+ onMouseLeave={() => setHovered(false)}
+ title="Click to copy Doc ID"
+ style={{
+ fontFamily: 'var(--font-family-mono)',
+ fontSize: 'var(--font-size-xs)',
+ color: hovered ? 'var(--color-primary)' : 'var(--color-text-muted)',
+ cursor: 'pointer',
+ transition: 'color 120ms ease',
+ userSelect: 'none',
+ }}
+ >
+ {id || '—'}
+
+ {copied && (
+
+ DOC ID Copied !
+
+ )}
+
+
+ )
+}
+
+// ─── MelodyList ───────────────────────────────────────────────────────────────
+
+export default function MelodyList() {
+ const navigate = useNavigate()
+ const { hasPermission } = useAuth()
+ const canEdit = hasPermission('melodies', 'edit')
+ const canAdd = hasPermission('melodies', 'add')
+
+ // Data
+ const [melodies, setMelodies] = useState([])
+ const [allCount, setAllCount] = useState(0)
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState('')
+ const [melodySettings, setMelodySettings] = useState(null)
+
+ // Filters
+ const [search, setSearch] = useState('')
+ const [typeFilter, setTypeFilter] = useState('')
+ const [toneFilter, setToneFilter] = useState('')
+ const [statusFilter, setStatusFilter] = useState('')
+
+ // Sort & pagination
+ const [sortKey, setSortKey] = useState('dateCreated')
+ const [sortDir, setSortDir] = useState('desc')
+ const [page, setPage] = useState(1)
+ const [pageSize, setPageSize] = useState(20)
+
+ // Display
+ const [displayLang, setDisplayLang] = useState('en')
+
+ // Columns
+ const [colPrefs, setColPrefs] = useState(loadColumnPrefs)
+
+ // Actions
+ const [deleteTarget, setDeleteTarget] = useState(null)
+ const [unpublishTarget, setUnpublishTarget] = useState(null)
+ const [actionLoading, setActionLoading] = useState(null)
+
+ // Multi-select
+ const [multiSelectMode, setMultiSelectMode] = useState(false)
+ const [selectedIds, setSelectedIds] = useState(new Set())
+ const [actionsOpen, setActionsOpen] = useState(false)
+ const [bulkDeleteConfirm, setBulkDeleteConfirm] = useState(false)
+ const [bulkActionLoading, setBulkActionLoading] = useState(false)
+ const actionsBtnRef = useRef(null)
+ const actionsMenuRef = useRef(null)
+
+ // Close actions dropdown on outside click
+ useEffect(() => {
+ if (!actionsOpen) return
+ function onMouseDown(e) {
+ if (actionsBtnRef.current?.contains(e.target)) return
+ if (actionsMenuRef.current?.contains(e.target)) return
+ setActionsOpen(false)
+ }
+ document.addEventListener('mousedown', onMouseDown)
+ return () => document.removeEventListener('mousedown', onMouseDown)
+ }, [actionsOpen])
+
+ const exitMultiSelect = () => {
+ setMultiSelectMode(false)
+ setSelectedIds(new Set())
+ setActionsOpen(false)
+ }
+
+ const toggleSelected = (id) => {
+ setSelectedIds((prev) => {
+ const next = new Set(prev)
+ if (next.has(id)) {
+ next.delete(id)
+ } else {
+ next.add(id)
+ }
+ // Auto-exit if we had at least 1 selected and now have 0
+ if (prev.size > 0 && next.size === 0) {
+ setMultiSelectMode(false)
+ setActionsOpen(false)
+ }
+ return next
+ })
+ }
+
+ // ── Settings ───────────────────────────────────────────────────────────────
+ useEffect(() => {
+ api.get('/settings/melody').then((ms) => {
+ if (ms) {
+ setMelodySettings(ms)
+ setDisplayLang(ms.primary_language || 'en')
+ }
+ }).catch(() => {})
+ }, [])
+
+ // ── Fetch ──────────────────────────────────────────────────────────────────
+ const fetchAllCount = async () => {
+ try {
+ const allData = await api.get('/melodies')
+ setAllCount(allData.total || (allData.melodies || []).length || 0)
+ } catch { /* ignore */ }
+ }
+
+ const fetchMelodies = async () => {
+ setLoading(true)
+ setError('')
+ try {
+ const params = new URLSearchParams()
+ if (search) params.set('search', search)
+ if (typeFilter) params.set('type', typeFilter)
+ if (toneFilter) params.set('tone', toneFilter)
+ if (statusFilter) params.set('status', statusFilter)
+ const qs = params.toString()
+ const data = await api.get(`/melodies${qs ? `?${qs}` : ''}`)
+ setMelodies(data.melodies || [])
+ } catch (err) {
+ setError(err.message || 'Failed to load melodies')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ useEffect(() => {
+ fetchAllCount()
+ }, []) // eslint-disable-line react-hooks/exhaustive-deps
+
+ useEffect(() => {
+ fetchMelodies()
+ setPage(1)
+ }, [search, typeFilter, toneFilter, statusFilter]) // eslint-disable-line react-hooks/exhaustive-deps
+
+ // ── Column management ──────────────────────────────────────────────────────
+ const handleColumnChange = (nextVisible) => {
+ setColPrefs(() => {
+ const newPrefs = { order: nextVisible, visible: nextVisible }
+ saveColumnPrefs(newPrefs.order, newPrefs.visible)
+ return newPrefs
+ })
+ }
+
+ // ── Sort ───────────────────────────────────────────────────────────────────
+ const getSortValue = (row, key) => {
+ const info = row?.information || {}
+ const metadata = row?.metadata || {}
+ switch (key) {
+ case 'name': return getLocalizedValue(info.name, displayLang, 'Untitled').toLowerCase()
+ case 'totalActiveBells': return Number(info.totalActiveBells || 0)
+ case 'dateCreated': return parseDateValue(metadata.dateCreated)
+ case 'dateEdited': return parseDateValue(metadata.dateEdited)
+ default: return 0
+ }
+ }
+
+ const handleSort = (key, dir) => {
+ setSortKey(key)
+ setSortDir(dir)
+ }
+
+ // ── Derived rows ───────────────────────────────────────────────────────────
+ const sortedRows = useMemo(() => {
+ return [...melodies].sort((a, b) => {
+ const av = getSortValue(a, sortKey)
+ const bv = getSortValue(b, sortKey)
+ if (typeof av === 'string' && typeof bv === 'string') {
+ return sortDir === 'asc' ? av.localeCompare(bv) : bv.localeCompare(av)
+ }
+ return sortDir === 'asc' ? av - bv : bv - av
+ })
+ }, [melodies, sortKey, sortDir, displayLang]) // eslint-disable-line react-hooks/exhaustive-deps
+
+ const total = sortedRows.length
+ const totalPages = Math.max(1, Math.ceil(total / pageSize))
+ const safePage = Math.min(page, totalPages)
+ const pagedRows = sortedRows.slice((safePage - 1) * pageSize, safePage * pageSize)
+ const hasFilter = Boolean(search || typeFilter || toneFilter || statusFilter)
+
+ // ── Actions ────────────────────────────────────────────────────────────────
+ const handleDelete = async () => {
+ if (!deleteTarget) return
+ try {
+ await api.delete(`/melodies/${deleteTarget.id}`)
+ setDeleteTarget(null)
+ fetchMelodies()
+ fetchAllCount()
+ } catch (err) {
+ setError(err.message)
+ setDeleteTarget(null)
+ }
+ }
+
+ const handlePublish = async (row) => {
+ setActionLoading(row.id)
+ try {
+ await api.post(`/melodies/${row.id}/publish`)
+ fetchMelodies()
+ } catch (err) {
+ setError(err.message)
+ } finally {
+ setActionLoading(null)
+ }
+ }
+
+ const handleUnpublish = async () => {
+ if (!unpublishTarget) return
+ setActionLoading(unpublishTarget.id)
+ try {
+ await api.post(`/melodies/${unpublishTarget.id}/unpublish`)
+ setUnpublishTarget(null)
+ fetchMelodies()
+ } catch (err) {
+ setError(err.message)
+ setUnpublishTarget(null)
+ } finally {
+ setActionLoading(null)
+ }
+ }
+
+ // ── Bulk actions ───────────────────────────────────────────────────────────
+ const handleBulkPublish = async () => {
+ setActionsOpen(false)
+ setBulkActionLoading(true)
+ const ids = [...selectedIds]
+ const drafts = melodies.filter((m) => ids.includes(m.id) && m.status !== 'published')
+ try {
+ await Promise.all(drafts.map((m) => api.post(`/melodies/${m.id}/publish`)))
+ fetchMelodies()
+ } catch (err) {
+ setError(err.message)
+ } finally {
+ setBulkActionLoading(false)
+ }
+ }
+
+ const handleBulkUnpublish = async () => {
+ setActionsOpen(false)
+ setBulkActionLoading(true)
+ const ids = [...selectedIds]
+ const published = melodies.filter((m) => ids.includes(m.id) && m.status === 'published')
+ try {
+ await Promise.all(published.map((m) => api.post(`/melodies/${m.id}/unpublish`)))
+ fetchMelodies()
+ } catch (err) {
+ setError(err.message)
+ } finally {
+ setBulkActionLoading(false)
+ }
+ }
+
+ const handleBulkDelete = async () => {
+ setBulkDeleteConfirm(false)
+ setBulkActionLoading(true)
+ const ids = [...selectedIds]
+ try {
+ await Promise.all(ids.map((id) => api.delete(`/melodies/${id}`)))
+ exitMultiSelect()
+ fetchMelodies()
+ fetchAllCount()
+ } catch (err) {
+ setError(err.message)
+ } finally {
+ setBulkActionLoading(false)
+ }
+ }
+
+ const downloadBinary = async (e, row) => {
+ if (e?.stopPropagation) e.stopPropagation()
+ const token = localStorage.getItem('access_token')
+ try {
+ const res = await fetch(`/api/melodies/${row.id}/download/binary`, {
+ headers: token ? { Authorization: `Bearer ${token}` } : {},
+ })
+ if (!res.ok) throw new Error(`Download failed: ${res.statusText}`)
+ const blob = await res.blob()
+ const url = URL.createObjectURL(blob)
+ const a = document.createElement('a')
+ a.href = url
+ a.download = row.pid ? `${row.pid}.bsm` : 'melody.bsm'
+ a.click()
+ URL.revokeObjectURL(url)
+ } catch (err) {
+ setError(err.message)
+ }
+ }
+
+ // ── Cell renderer ──────────────────────────────────────────────────────────
+ const renderCell = (key, row) => {
+ const info = row.information || {}
+ const metadata = row.metadata || {}
+
+ switch (key) {
+ case 'color': {
+ const hex = normalizeColor(info.color)
+ return (
+
+ )
+ }
+
+ case 'name': {
+ const isOutdated = Boolean(info.outdated_archetype)
+ const desc = colPrefs.visible.includes('description')
+ ? getLocalizedValue(info.description, displayLang)
+ : null
+ return (
+
+
+ {getLocalizedValue(info.name, displayLang, 'Untitled')}
+
+ {desc && (
+
+ {desc}
+
+ )}
+ {info.customTags?.length > 0 && (
+
+ {info.customTags.slice(0, 3).map((tag) => (
+
+ {tag}
+
+ ))}
+
+ )}
+
+ )
+ }
+
+ case 'status': {
+ const isOutdated = Boolean(info.outdated_archetype)
+ return (
+
+
+ {row.status === 'published' ? 'Live' : 'Draft'}
+
+ {isOutdated && (
+
+ Outdated
+
+ )}
+
+ )
+ }
+
+ case 'type': {
+ const typeVariant = { orthodox: 'info', catholic: 'danger', all: 'success' }
+ if (!row.type) return —
+ return (
+
+ {row.type.charAt(0).toUpperCase() + row.type.slice(1)}
+
+ )
+ }
+
+ case 'tone': {
+ if (!info.melodyTone) return —
+ return (
+
+ {info.melodyTone}
+
+ )
+ }
+
+ case 'totalActiveBells':
+ return (
+
+ {info.totalActiveBells ?? '—'}
+
+ )
+
+ case 'isTrueRing':
+ return (
+
+ {info.isTrueRing ? 'Yes' : 'No'}
+
+ )
+
+ case 'minSpeed':
+ if (!info.minSpeed) return —
+ return (
+
+ {formatBpm(info.minSpeed)} bpm
+
+ )
+
+ case 'maxSpeed':
+ if (!info.maxSpeed) return —
+ return (
+
+ {formatBpm(info.maxSpeed)} bpm
+
+ )
+
+ case 'tags':
+ if (!info.customTags?.length) return —
+ return (
+
+ {info.customTags.map((tag) => (
+
+ {tag}
+
+ ))}
+
+ )
+
+ case 'dateCreated': {
+ if (!metadata.dateCreated) return —
+ const parts = formatDateShort(metadata.dateCreated)
+ return (
+
+ {parts.date}
+ {parts.time}
+
+ )
+ }
+
+ case 'dateEdited':
+ return metadata.dateEdited
+ ? {formatRelativeTime(metadata.dateEdited)}
+ : —
+
+ case 'createdBy':
+ return {metadata.createdBy || '—'}
+
+ case 'lastEditedBy':
+ return {metadata.lastEditedBy || metadata.editedBy || '—'}
+
+ case 'description': {
+ const desc = getLocalizedValue(info.description, displayLang)
+ return desc
+ ? {desc}
+ : —
+ }
+
+ case 'speed': {
+ const ds = row.default_settings || {}
+ if (ds.speed == null) return —
+ const speedMs = mapPercentageToStepDelay(ds.speed, info.minSpeed, info.maxSpeed)
+ const bpm = formatBpm(speedMs)
+ return (
+
+
+ {ds.speed}%{bpm ? ` · ${bpm} bpm` : ''}
+
+
+
+ )
+ }
+
+ case 'duration': {
+ const ds = row.default_settings || {}
+ if (ds.duration == null) return —
+ if (Number(ds.duration) === 0) {
+ return (
+
+ Infinite
+
+ )
+ }
+ const allDurations = melodySettings?.duration_values || []
+ const nonZero = allDurations.filter((v) => Number(v) > 0)
+ const idx = nonZero.indexOf(Number(ds.duration))
+ const percent = idx >= 0 && nonZero.length > 0
+ ? Math.round(((idx + 1) / nonZero.length) * 100)
+ : 0
+ return (
+
+
+ {formatDurationVerbose(ds.duration)}
+
+
+
+ )
+ }
+
+ case 'totalRunDuration': {
+ const trd = info.totalRunDuration ?? info.runDuration
+ if (!trd) return —
+ return {trd} ms
+ }
+
+ case 'pauseDuration': {
+ const pd = info.pauseDuration
+ if (!pd) return —
+ return {pd} ms
+ }
+
+ case 'infiniteLoop':
+ return (
+
+ {info.infiniteLoop ? 'Yes' : 'No'}
+
+ )
+
+ case 'noteAssignments': {
+ const assignments = info.noteAssignments || info.note_assignments
+ if (!assignments || !Object.keys(assignments).length)
+ return —
+ return (
+
+ {Object.keys(assignments).length} notes
+
+ )
+ }
+
+ case 'binaryFile': {
+ const hasBinary = row.has_binary_file ?? row.hasBinaryFile ?? false
+ return (
+
+ {hasBinary ? 'Available' : 'None'}
+
+ )
+ }
+
+ case 'pid':
+ return {row.pid || '—'}
+
+ case 'docId':
+ return
+
+ default:
+ return —
+ }
+ }
+
+ // ── Build table columns ────────────────────────────────────────────────────
+ const checkboxCol = {
+ key: '__checkbox',
+ label: '',
+ width: '40px',
+ render: (row) => (
+ e.stopPropagation()}
+ >
+ toggleSelected(row.id)}
+ style={{ accentColor: 'var(--color-primary)', width: 15, height: 15, cursor: 'pointer' }}
+ />
+
+ ),
+ }
+
+ const tableColumns = [
+ ...(multiSelectMode ? [checkboxCol] : []),
+ ...colPrefs.visible
+ .filter((key) => key !== 'description') // rendered inline inside the 'name' cell
+ .map((key) => {
+ const col = ALL_COLUMNS.find((c) => c.key === key)
+ return col ? { ...col, render: (row) => renderCell(key, row) } : null
+ }).filter(Boolean),
+ ...(!multiSelectMode ? [{
+ key: '__actions',
+ label: '',
+ width: '90px',
+ align: 'right',
+ render: (row) => (
+ ,
+ onClick: () => navigate(`/melodies/${row.id}`),
+ },
+ ...(canEdit ? [
+ {
+ label: 'Edit',
+ icon: ,
+ onClick: () => navigate(`/melodies/${row.id}/edit`),
+ },
+ row.status === 'published'
+ ? {
+ label: 'Unpublish',
+ icon: ,
+ onClick: () => setUnpublishTarget(row),
+ divider: true,
+ }
+ : {
+ label: 'Publish',
+ icon: ,
+ onClick: () => handlePublish(row),
+ divider: true,
+ },
+ {
+ label: 'Download Binary',
+ icon: ,
+ onClick: (e) => downloadBinary(e, row),
+ },
+ {
+ label: 'Delete',
+ icon: ,
+ color: 'var(--color-danger)',
+ onClick: () => setDeleteTarget(row),
+ divider: true,
+ },
+ ] : []),
+ ]}
+ />
+ ),
+ }] : []),
+ ]
+
+ const languages = melodySettings?.available_languages || ['en']
+
+ // ─────────────────────────────────────────────────────────────────────────
+
+ return (
+
+
+ navigate('/melodies/settings')}>
+ Settings
+
+ navigate('/melodies/archetypes')}>
+ Archetypes
+
+ {canAdd && (
+ navigate('/melodies/new')}>
+
+ Add Melody
+
+ )}
+
+
+ {/* Filters row */}
+
+
+
+
+
+
+
setTypeFilter(e.target.value)} placeholder="All Types" style={{ width: '130px', flexShrink: 0 }}>
+ All Types
+ Orthodox
+ Catholic
+ All
+
+
+
setToneFilter(e.target.value)} placeholder="All Tones" style={{ width: '145px', flexShrink: 0 }}>
+ All Tones
+ Normal
+ Festive
+ Cheerful
+ Lamentation
+
+
+
setStatusFilter(e.target.value)} placeholder="All Statuses" style={{ width: '145px', flexShrink: 0 }}>
+ All Statuses
+ Published (Live)
+ Drafts
+
+
+ {languages.length > 1 && (
+
setDisplayLang(e.target.value)} style={{ width: '120px', flexShrink: 0 }}>
+ {languages.map((l) => (
+ {LANG_NAMES[l] || l}
+ ))}
+
+ )}
+
+ {/* Multi-select / Actions button */}
+ {canEdit && (
+
+ {/* Fixed-width wrapper — never resizes; both states share the same box */}
+
+ {!multiSelectMode ? (
+ setMultiSelectMode(true)}
+ >
+ Multi-select
+
+ ) : (
+ setActionsOpen((v) => !v)}
+ loading={bulkActionLoading}
+ >
+ Actions{selectedIds.size > 0 ? ` (${selectedIds.size})` : ''}
+
+ )}
+
+
+ {actionsOpen && (
+
+ {[
+ {
+ label: 'Select All',
+ icon: (
+
+
+
+
+ ),
+ onClick: () => {
+ setSelectedIds(new Set(sortedRows.map((r) => r.id)))
+ setActionsOpen(false)
+ },
+ },
+ {
+ label: 'Deselect All',
+ icon: (
+
+
+
+
+ ),
+ onClick: () => {
+ setSelectedIds(new Set())
+ setActionsOpen(false)
+ },
+ },
+ { divider: true },
+ {
+ label: 'Publish',
+ disabled: selectedIds.size === 0,
+ icon: (
+
+
+
+
+ ),
+ onClick: handleBulkPublish,
+ },
+ {
+ label: 'Unpublish',
+ disabled: selectedIds.size === 0,
+ icon: (
+
+
+
+
+ ),
+ onClick: handleBulkUnpublish,
+ },
+ { divider: true },
+ {
+ label: 'Delete',
+ danger: true,
+ disabled: selectedIds.size === 0,
+ icon: (
+
+
+
+ ),
+ onClick: () => { setActionsOpen(false); setBulkDeleteConfirm(true) },
+ },
+ { divider: true },
+ {
+ label: 'Cancel',
+ icon: (
+
+
+
+ ),
+ onClick: () => { setActionsOpen(false); exitMultiSelect() },
+ },
+ ].map((item, i) => {
+ if (item.divider) {
+ return (
+
+ )
+ }
+ return (
+
{ if (!item.disabled) e.currentTarget.style.background = 'var(--color-bg-island)' }}
+ onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent' }}
+ >
+ {item.icon && (
+
+ {item.icon}
+
+ )}
+ {item.label}
+
+ )
+ })}
+
+ )}
+
+ )}
+
+ {hasFilter && (
+
{
+ setSearch('')
+ setTypeFilter('')
+ setToneFilter('')
+ setStatusFilter('')
+ }}
+ >
+ Clear filters
+
+ )}
+
+
+ {/* Error banner */}
+ {error && (
+
+ {error}
+
+ )}
+
+ {/* Table */}
+
toggleSelected(row.id) : (row) => navigate(`/melodies/${row.id}`)}
+ onRowMiddleClick={multiSelectMode ? undefined : (row) => window.open(`/melodies/${row.id}`, '_blank', 'noopener,noreferrer')}
+ selectedIds={selectedIds}
+ allColumns={[...ALL_COLUMNS, { key: '__actions', label: '', pickerLabel: 'Actions', alwaysOn: true }]}
+ visibleKeys={colPrefs.visible}
+ onColumnChange={handleColumnChange}
+ footer={
+ { setPageSize(size); setPage(1) }}
+ />
+ }
+ />
+
+ {/* Delete confirm (single) */}
+ setDeleteTarget(null)}
+ />
+
+ {/* Unpublish confirm (single) */}
+ setUnpublishTarget(null)}
+ />
+
+ {/* Bulk delete confirm */}
+ setBulkDeleteConfirm(false)}
+ />
+
+ )
+}
diff --git a/frontend/src/pages/bellcloud/melodies/MelodySettings.jsx b/frontend/src/pages/bellcloud/melodies/MelodySettings.jsx
new file mode 100644
index 0000000..c32f6ca
--- /dev/null
+++ b/frontend/src/pages/bellcloud/melodies/MelodySettings.jsx
@@ -0,0 +1,498 @@
+// frontend/src/pages/melodies/MelodySettings.jsx
+
+import { useState, useEffect } from 'react'
+import PageHeader from '@/components/ui/PageHeader'
+import Card from '@/components/ui/Card'
+import Button from '@/components/ui/Button'
+import FormField from '@/components/ui/FormField'
+import Spinner from '@/components/ui/Spinner'
+import Modal from '@/components/ui/Modal'
+import { useToast } from '@/components/ui/Toast'
+import api from '@/lib/api'
+import {
+ LANGUAGE_MASTER_LIST,
+ getLanguageName,
+ formatDuration,
+ normalizeColor,
+} from '@/lib/melodyUtils'
+
+// ─── Constants ───────────────────────────────────────────────────────────────
+
+const DEFAULT_NOTE_COLORS = [
+ '#67E8F9', '#5EEAD4', '#6EE7B7', '#86EFAC',
+ '#BEF264', '#FDE68A', '#FCD34D', '#FBBF24',
+ '#FDBA74', '#FB923C', '#F97316', '#FB7185',
+ '#F87171', '#EF4444', '#DC2626', '#B91C1C',
+]
+
+const NOTE_LABELS = 'ABCDEFGHIJKLMNOP'
+
+// ─── Component ────────────────────────────────────────────────────────────────
+
+export default function MelodySettings() {
+ const { toast } = useToast()
+
+ const [settings, setSettings] = useState(null)
+ const [loading, setLoading] = useState(true)
+ const [saving, setSaving] = useState(false)
+ const [fetchError, setFetchError] = useState('')
+
+ // Note color modal
+ const [colorModalBell, setColorModalBell] = useState(null)
+ const [modalColor, setModalColor] = useState('#67E8F9')
+ const [modalHexInput, setModalHexInput] = useState('#67E8F9')
+
+ // Add language
+ const [langToAdd, setLangToAdd] = useState('')
+
+ // Add quick color
+ const [quickColorPicker, setQuickColorPicker] = useState('#c0c1ff')
+ const [quickColorHex, setQuickColorHex] = useState('#c0c1ff')
+
+ // Add duration
+ const [durationInput, setDurationInput] = useState('')
+
+ useEffect(() => { loadSettings() }, [])
+
+ const loadSettings = async () => {
+ setLoading(true)
+ setFetchError('')
+ try {
+ const data = await api.get('/settings/melody')
+ setSettings({
+ ...data,
+ note_assignment_colors: (data.note_assignment_colors || DEFAULT_NOTE_COLORS).slice(0, 16),
+ })
+ } catch (err) {
+ setFetchError(err.message || 'Failed to load settings.')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const save = async (updated) => {
+ setSaving(true)
+ try {
+ const result = await api.put('/settings/melody', updated)
+ setSettings(result)
+ toast.success('Saved', 'Settings updated.')
+ } catch (err) {
+ toast.danger('Error', err.message || 'Failed to save.')
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ // ── Languages ─────────────────────────────────────────────────────────────
+
+ const addLanguage = () => {
+ if (!langToAdd || settings.available_languages.includes(langToAdd)) return
+ save({ ...settings, available_languages: [...settings.available_languages, langToAdd] })
+ setLangToAdd('')
+ }
+
+ const removeLanguage = (code) => {
+ if (settings.available_languages.length <= 1) return
+ save({
+ ...settings,
+ available_languages: settings.available_languages.filter((c) => c !== code),
+ primary_language:
+ settings.primary_language === code
+ ? settings.available_languages.find((c) => c !== code)
+ : settings.primary_language,
+ })
+ }
+
+ const setPrimaryLanguage = (code) => save({ ...settings, primary_language: code })
+
+ // ── Quick colors ──────────────────────────────────────────────────────────
+
+ const addQuickColor = () => {
+ const color = quickColorHex.startsWith('#') ? quickColorHex : `#${quickColorHex}`
+ if (!/^#[0-9A-Fa-f]{6}$/.test(color)) return
+ if (settings.quick_colors.includes(color)) return
+ save({ ...settings, quick_colors: [...settings.quick_colors, color] })
+ }
+
+ const removeQuickColor = (color) =>
+ save({ ...settings, quick_colors: settings.quick_colors.filter((c) => c !== color) })
+
+ // ── Duration presets ──────────────────────────────────────────────────────
+
+ const addDuration = () => {
+ const val = parseInt(durationInput, 10)
+ if (isNaN(val) || val < 0) return
+ if (settings.duration_values.includes(val)) return
+ save({ ...settings, duration_values: [...settings.duration_values, val].sort((a, b) => a - b) })
+ setDurationInput('')
+ }
+
+ const removeDuration = (val) =>
+ save({ ...settings, duration_values: settings.duration_values.filter((v) => v !== val) })
+
+ // ── Note assignment colors ────────────────────────────────────────────────
+
+ const openNoteColorModal = (index) => {
+ const current = settings.note_assignment_colors?.[index] || DEFAULT_NOTE_COLORS[index]
+ setModalColor(current)
+ setModalHexInput(current)
+ setColorModalBell(index)
+ }
+
+ const applyNoteColor = () => {
+ if (colorModalBell == null) return
+ const candidate = modalHexInput.startsWith('#') ? modalHexInput : `#${modalHexInput}`
+ if (!/^#[0-9A-Fa-f]{6}$/.test(candidate)) return
+ const next = [...(settings.note_assignment_colors || DEFAULT_NOTE_COLORS)]
+ next[colorModalBell] = candidate
+ save({ ...settings, note_assignment_colors: next.slice(0, 16) })
+ setColorModalBell(null)
+ }
+
+ const resetNoteColor = () => {
+ const fallback = DEFAULT_NOTE_COLORS[colorModalBell]
+ setModalColor(fallback)
+ setModalHexInput(fallback)
+ }
+
+ // ── Render ────────────────────────────────────────────────────────────────
+
+ if (loading) {
+ return (
+
+ )
+ }
+
+ if (fetchError || !settings) {
+ return (
+
+
+
+ {fetchError || 'Failed to load settings.'}
+
+
+ )
+ }
+
+ const availableToAdd = LANGUAGE_MASTER_LIST.filter(
+ (l) => !settings.available_languages.includes(l.code)
+ )
+
+ return (
+
+
+
+
+
+ {/* ── Languages ─────────────────────────────────────────────────── */}
+
+
+ {settings.available_languages.map((code) => (
+
+
+
+ {code}
+
+
+ {getLanguageName(code)}
+
+ {settings.primary_language === code && (
+
+ Primary
+
+ )}
+
+
+ {settings.primary_language !== code && (
+ setPrimaryLanguage(code)}>
+ Set Primary
+
+ )}
+ {settings.available_languages.length > 1 && (
+ removeLanguage(code)}>
+ Remove
+
+ )}
+
+
+ ))}
+
+
+
+
+ setLangToAdd(e.target.value)}
+ >
+ Select language…
+ {availableToAdd.map((l) => (
+ {l.name} ({l.code})
+ ))}
+
+
+
+
+ Add
+
+
+
+
+
+ {/* ── Quick Colors ──────────────────────────────────────────────── */}
+
+
+ {settings.quick_colors.map((color) => (
+
+
+
removeQuickColor(color)}
+ style={{
+ position: 'absolute', top: -6, right: -6,
+ width: 18, height: 18,
+ borderRadius: '50%',
+ backgroundColor: 'var(--color-danger)',
+ color: 'var(--color-text-inverse)',
+ border: 'none', cursor: 'pointer',
+ fontSize: 11, lineHeight: 1,
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
+ opacity: 0,
+ transition: 'opacity 120ms',
+ }}
+ className="group-hover:opacity-100"
+ >
+ ×
+
+
+ ))}
+
+
+
+
{ setQuickColorPicker(e.target.value); setQuickColorHex(e.target.value) }}
+ style={{ width: 42, height: 42, borderRadius: 'var(--radius-md)', border: '1px solid var(--color-border)', cursor: 'pointer', padding: 2, backgroundColor: 'var(--color-bg-abyss)' }}
+ />
+
+ {
+ setQuickColorHex(e.target.value)
+ if (/^#[0-9A-Fa-f]{6}$/.test(e.target.value)) setQuickColorPicker(e.target.value)
+ }}
+ placeholder="#RRGGBB"
+ inputProps={{ style: { fontFamily: 'var(--font-family-mono)' } }}
+ />
+
+
+ Add
+
+
+
+
+ {/* ── Duration Presets ──────────────────────────────────────────── */}
+
+
+ {settings.duration_values.map((val) => (
+
+
+ {formatDuration(val)}
+
+
+ {val}s
+
+ removeDuration(val)}
+ style={{
+ background: 'transparent', border: 'none',
+ color: 'var(--color-danger)', cursor: 'pointer',
+ fontSize: 'var(--font-size-sm)', lineHeight: 1,
+ padding: 0,
+ }}
+ >
+ ×
+
+
+ ))}
+
+
+
+
+ setDurationInput(e.target.value)}
+ placeholder="e.g. 45"
+ inputProps={{
+ min: 0,
+ onKeyDown: (e) => { if (e.key === 'Enter') { e.preventDefault(); addDuration() } },
+ }}
+ />
+
+
+ Add
+
+
+
+
+ {/* ── Note Assignment Colors ─────────────────────────────────────── */}
+
+
+ {Array.from({ length: 16 }, (_, idx) => {
+ const color = settings.note_assignment_colors?.[idx] || DEFAULT_NOTE_COLORS[idx]
+ return (
+ openNoteColorModal(idx)}
+ style={{
+ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 'var(--space-2)',
+ padding: 'var(--space-3) var(--space-2)',
+ backgroundColor: 'var(--color-bg-elevated)',
+ border: '1px solid var(--color-border)',
+ borderRadius: 'var(--radius-lg)',
+ cursor: 'pointer',
+ transition: 'background 120ms, border-color 120ms',
+ }}
+ onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = 'var(--color-bg-island)'; e.currentTarget.style.borderColor = 'var(--color-border-strong)' }}
+ onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = 'var(--color-bg-elevated)'; e.currentTarget.style.borderColor = 'var(--color-border)' }}
+ title={`Bell ${idx + 1} — ${color}`}
+ >
+
+ {NOTE_LABELS[idx]}
+
+
+ {color.toLowerCase()}
+
+
+ )
+ })}
+
+
+
+
+
+ {/* ── Note Color Modal ──────────────────────────────────────────────── */}
+
setColorModalBell(null)}
+ title={colorModalBell != null ? `Bell ${colorModalBell + 1} — ${NOTE_LABELS[colorModalBell]} Color` : ''}
+ size="sm"
+ footer={
+
+
Reset Default
+
+ setColorModalBell(null)}>Cancel
+ Apply
+
+
+ }
+ >
+
+
+ Pick a custom color for this bell note in the composer and playback views.
+
+
+
+
{ setModalColor(e.target.value); setModalHexInput(e.target.value) }}
+ style={{ width: 56, height: 44, borderRadius: 'var(--radius-md)', border: '1px solid var(--color-border)', cursor: 'pointer', padding: 2, backgroundColor: 'var(--color-bg-abyss)', flexShrink: 0 }}
+ />
+
+ {
+ setModalHexInput(e.target.value)
+ if (/^#[0-9A-Fa-f]{6}$/.test(e.target.value)) setModalColor(e.target.value)
+ }}
+ inputProps={{ style: { fontFamily: 'var(--font-family-mono)' } }}
+ />
+
+ {/* Preview swatch */}
+
+
+
+
+
+ )
+}
diff --git a/frontend/src/pages/bellcloud/melodies/archetypes/ArchetypeForm.jsx b/frontend/src/pages/bellcloud/melodies/archetypes/ArchetypeForm.jsx
new file mode 100644
index 0000000..ce8800e
--- /dev/null
+++ b/frontend/src/pages/bellcloud/melodies/archetypes/ArchetypeForm.jsx
@@ -0,0 +1,590 @@
+// frontend/src/pages/melodies/archetypes/ArchetypeForm.jsx
+
+import { useState, useEffect, useRef } from 'react'
+import { useParams, useNavigate, Link } from 'react-router-dom'
+import PageHeader from '@/components/ui/PageHeader'
+import Card from '@/components/ui/Card'
+import Button from '@/components/ui/Button'
+import FormField from '@/components/ui/FormField'
+import StatusBadge from '@/components/ui/StatusBadge'
+import Spinner from '@/components/ui/Spinner'
+import ConfirmDialog from '@/components/ui/ConfirmDialog'
+import { useToast } from '@/components/ui/Toast'
+import { useAuth } from '@/hooks/useAuth'
+import api from '@/lib/api'
+import { getLocalizedValue } from '@/lib/melodyUtils'
+import PlaybackModal from '@/modals/bellcloud/melodies/PlaybackModal'
+
+// ─── Helpers ─────────────────────────────────────────────────────────────────
+
+function countSteps(stepsStr) {
+ if (!stepsStr?.trim()) return 0
+ return stepsStr.trim().split(',').length
+}
+
+function validateSteps(stepsStr) {
+ if (!stepsStr?.trim()) return 'Steps are required.'
+ const tokens = stepsStr.trim().split(',')
+ for (const token of tokens) {
+ const parts = token.split('+')
+ for (const part of parts) {
+ const trimmed = part.trim()
+ if (trimmed === '') return `Invalid token near: "${token.trim()}" — empty part found.`
+ const n = parseInt(trimmed, 10)
+ if (isNaN(n)) return `Invalid step value: "${trimmed}" — must be a number 0–16.`
+ if (n < 0 || n > 16) return `Step value "${trimmed}" is out of range (0–16).`
+ }
+ }
+ return null
+}
+
+async function downloadBinary(binaryUrl, filename) {
+ const token = localStorage.getItem('access_token')
+ const res = await fetch(`/api${binaryUrl}`, {
+ headers: token ? { Authorization: `Bearer ${token}` } : {},
+ })
+ if (!res.ok) throw new Error(`Download failed: ${res.statusText}`)
+ const blob = await res.blob()
+ const url = URL.createObjectURL(blob)
+ const a = document.createElement('a')
+ a.href = url
+ a.download = filename
+ a.click()
+ URL.revokeObjectURL(url)
+}
+
+function copyText(text, onSuccess) {
+ if (navigator.clipboard) {
+ navigator.clipboard.writeText(text).then(onSuccess).catch(() => fallbackCopy(text, onSuccess))
+ } else {
+ fallbackCopy(text, onSuccess)
+ }
+}
+
+function fallbackCopy(text, onSuccess) {
+ const ta = document.createElement('textarea')
+ ta.value = text
+ ta.style.cssText = 'position:fixed;top:0;left:0;opacity:0'
+ document.body.appendChild(ta)
+ ta.focus(); ta.select()
+ try { document.execCommand('copy'); onSuccess?.() } catch (_) {}
+ document.body.removeChild(ta)
+}
+
+// ─── Component ────────────────────────────────────────────────────────────────
+
+export default function ArchetypeForm() {
+ const { id } = useParams()
+ const isEdit = Boolean(id)
+ const navigate = useNavigate()
+ const toast = useToast()
+ const { hasPermission } = useAuth()
+ const canEdit = hasPermission('melodies', 'edit')
+
+ // Form state
+ const [name, setName] = useState('')
+ const [pid, setPid] = useState('')
+ const [steps, setSteps] = useState('')
+ const [isBuiltin, setIsBuiltin] = useState(false)
+ const [savedPid, setSavedPid] = useState('')
+
+ // Load state
+ const [loading, setLoading] = useState(false)
+ const [saving, setSaving] = useState(false)
+ const [error, setError] = useState('')
+
+ // Build output
+ const [binaryBuilt, setBinaryBuilt] = useState(false)
+ const [binaryUrl, setBinaryUrl] = useState(null)
+ const [progmemCode, setProgmemCode] = useState('')
+ const [codeCopied, setCodeCopied] = useState(false)
+ const [hasUnsaved, setHasUnsaved] = useState(false)
+
+ // Assigned melodies
+ const [assignedMelodies, setAssignedMelodies] = useState([])
+ const [loadingAssigned, setLoadingAssigned] = useState(false)
+ const [primaryLang, setPrimaryLang] = useState('en')
+
+ // Delete
+ const [showDelete, setShowDelete] = useState(false)
+ const [showDeleteWarning, setShowDeleteWarning] = useState(false)
+
+ // Playback
+ const [showPlayback, setShowPlayback] = useState(false)
+ const [currentBuiltMelody, setCurrentBuiltMelody] = useState(null)
+
+ const codeRef = useRef(null)
+
+ useEffect(() => {
+ api.get('/settings/melody')
+ .then((ms) => setPrimaryLang(ms.primary_language || 'en'))
+ .catch(() => {})
+ if (isEdit) loadArchetype()
+ }, [id])
+
+ const loadArchetype = async () => {
+ setLoading(true)
+ setError('')
+ try {
+ const data = await api.get(`/builder/melodies/${id}`)
+ setName(data.name || '')
+ setPid(data.pid || '')
+ setSteps(data.steps || '')
+ setIsBuiltin(Boolean(data.is_builtin))
+ setSavedPid(data.pid || '')
+ setBinaryBuilt(Boolean(data.binary_path))
+ setBinaryUrl(data.binary_url || null)
+ setProgmemCode(data.progmem_code || '')
+ setCurrentBuiltMelody(data)
+ setHasUnsaved(false)
+ if (data.assigned_melody_ids?.length > 0) {
+ fetchAssignedMelodies(data.assigned_melody_ids)
+ }
+ } catch (err) {
+ setError(err.message || 'Failed to load archetype.')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const fetchAssignedMelodies = async (ids) => {
+ setLoadingAssigned(true)
+ try {
+ const results = await Promise.allSettled(ids.map((mid) => api.get(`/melodies/${mid}`)))
+ setAssignedMelodies(
+ results
+ .filter((r) => r.status === 'fulfilled' && r.value)
+ .map((r) => ({ id: r.value.id, nameRaw: r.value.information?.name }))
+ )
+ } catch { /* best-effort */ }
+ finally { setLoadingAssigned(false) }
+ }
+
+ const handleSave = async () => {
+ setError('')
+ if (!name.trim()) { setError('Name is required.'); return }
+ if (!pid.trim()) { setError('PID is required.'); return }
+ const stepsError = validateSteps(steps)
+ if (stepsError) { setError(stepsError); return }
+
+ setSaving(true)
+ try {
+ // Duplicate check
+ const { melodies: list = [] } = await api.get('/builder/melodies')
+ const dupName = list.find((m) => m.id !== id && m.name.toLowerCase() === name.trim().toLowerCase())
+ if (dupName) { setError(`An archetype named "${name.trim()}" already exists.`); return }
+ const dupPid = list.find((m) => m.id !== id && m.pid?.toLowerCase() === pid.trim().toLowerCase())
+ if (dupPid) { setError(`A PID "${pid.trim()}" is already taken.`); return }
+
+ const body = { name: name.trim(), pid: pid.trim(), steps: steps.trim(), is_builtin: isBuiltin }
+
+ if (isEdit) {
+ const updated = await api.put(`/builder/melodies/${id}`, body)
+ setSavedPid(pid.trim())
+ setBinaryBuilt(Boolean(updated.binary_path))
+ setBinaryUrl(updated.binary_url || null)
+ setProgmemCode(updated.progmem_code || '')
+ setCurrentBuiltMelody(updated)
+ setHasUnsaved(false)
+ toast.success('Saved', 'Archetype rebuilt and saved.')
+ } else {
+ const created = await api.post('/builder/melodies', body)
+ toast.success('Created', 'Archetype built successfully.')
+ navigate(`/melodies/archetypes/${created.id}`)
+ }
+ } catch (err) {
+ setError(err.message || 'Save failed.')
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ const handleDelete = async () => {
+ try {
+ await api.delete(`/builder/melodies/${id}`)
+ toast.success('Deleted', 'Archetype removed.')
+ navigate('/melodies/archetypes')
+ } catch (err) {
+ toast.danger('Error', err.message)
+ setShowDelete(false)
+ setShowDeleteWarning(false)
+ }
+ }
+
+ const assignedCount = assignedMelodies.length
+
+ const playbackBuiltMelody = currentBuiltMelody
+ ? { ...currentBuiltMelody, steps }
+ : { id: 'preview', name: name || 'Preview', pid: pid || '', steps, binary_url: null }
+
+ // ── Loading ───────────────────────────────────────────────────────────────
+
+ if (loading) {
+ return (
+
+ )
+ }
+
+ // ── Render ────────────────────────────────────────────────────────────────
+
+ return (
+
+
+
+ {/* Built-in toggle */}
+ { setIsBuiltin((v) => !v); setHasUnsaved(true) }}
+ icon={
+
+
+
+ }
+ >
+ {isBuiltin ? 'Built-in' : 'Built-in'}
+
+
+ {/* Play preview */}
+ setShowPlayback(true)}
+ icon={
+
+
+
+ }
+ >
+ Preview
+
+
+ {/* Cancel */}
+ navigate('/melodies/archetypes')}>
+ Cancel
+
+
+ {/* Delete */}
+ {isEdit && canEdit && (
+ assignedCount > 0 ? setShowDeleteWarning(true) : setShowDelete(true)}
+ >
+ Delete
+
+ )}
+
+ {/* Save */}
+ {canEdit && (
+
+ {isEdit ? 'Rebuild & Save' : 'Create & Build'}
+
+ )}
+
+
+ {/* ── Error banner ──────────────────────────────────────────────────── */}
+ {error && (
+
+ {error}
+
+ )}
+
+ {/* ── Archetype Info ────────────────────────────────────────────────── */}
+
+
+
+ { setName(e.target.value); setHasUnsaved(true) }}
+ placeholder="e.g. Doksologia_3k"
+ />
+ { setPid(e.target.value.toLowerCase().replace(/[^a-z0-9_]/g, '')); setHasUnsaved(true) }}
+ placeholder="e.g. builtin_doksologia_3k"
+ hint="Lowercase letters, numbers, and underscores only. Must be unique."
+ />
+
+
+
+
+
+ Steps *
+
+
+ {countSteps(steps)} steps
+
+
+
+
+
+
+ {/* ── Build Output ─────────────────────────────────────────────────── */}
+ {isEdit && (
+
+
+
+ {/* Binary */}
+
+
+
+
+ Binary (.bsm)
+
+
+ For SD card playback on the controller
+
+
+
+ {binaryBuilt ? 'Built' : 'Not built'}
+
+
+ {binaryUrl && (
+
downloadBinary(binaryUrl, `${savedPid}.bsm`).catch((e) => setError(e.message))}
+ icon={
+
+
+
+ }
+ >
+ Download {savedPid}.bsm
+
+ )}
+
+
+ {/* PROGMEM */}
+
+
+
+
+ PROGMEM Code
+
+
+ For built-in firmware (ESP32 PROGMEM)
+
+
+
+ {progmemCode ? 'Generated' : 'Not generated'}
+
+
+
+
+
+ {/* PROGMEM code block */}
+ {progmemCode && (
+
+
+
+ PROGMEM C code — copy into firmware
+
+ copyText(progmemCode, () => { setCodeCopied(true); setTimeout(() => setCodeCopied(false), 2000) })}
+ >
+ {codeCopied ? 'Copied!' : 'Copy Code'}
+
+
+
+ {progmemCode}
+
+
+ )}
+
+ )}
+
+ {/* ── Assigned Melodies ─────────────────────────────────────────────── */}
+ {isEdit && (
+
+ {loadingAssigned ? (
+
+
+
+ ) : assignedMelodies.length === 0 ? (
+
+ No melodies are currently using this archetype.
+
+ ) : (
+
+ {assignedMelodies.map((m) => (
+ e.currentTarget.style.backgroundColor = 'var(--color-bg-island)'}
+ onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'var(--color-bg-elevated)'}
+ >
+
+ {getLocalizedValue(m.nameRaw, primaryLang, m.id)}
+
+
+ Open →
+
+
+ ))}
+
+ )}
+
+ )}
+
+ {/* ── New: build note ───────────────────────────────────────────────── */}
+ {!isEdit && (
+
+ Binary and PROGMEM code will be generated automatically when you click "Create & Build".
+
+ )}
+
+ {/* ── Modals ───────────────────────────────────────────────────────── */}
+
+ {/* Delete warning — archetype is in use */}
+ {showDeleteWarning && (
+
+
+
+ Archetype In Use
+
+
+ "{name}" is assigned to {assignedCount} {assignedCount === 1 ? 'melody' : 'melodies'} . Deleting it will flag those melodies as outdated .
+
+
+ Do you still want to delete this archetype?
+
+
+ setShowDeleteWarning(false)}>Cancel
+ { setShowDeleteWarning(false); setShowDelete(true) }}>
+ Yes, Delete Anyway
+
+
+
+
+ )}
+
+
setShowDelete(false)}
+ onConfirm={handleDelete}
+ variant="danger"
+ title="Delete Archetype"
+ message={`Are you sure you want to permanently delete "${name}"? This will also delete the .bsm binary file. This action cannot be undone.`}
+ confirmLabel="Delete"
+ />
+
+ setShowPlayback(false)}
+ melody={null}
+ builtMelody={playbackBuiltMelody}
+ files={null}
+ archetypeCsv={steps}
+ />
+
+ )
+}
diff --git a/frontend/src/pages/bellcloud/melodies/archetypes/ArchetypeList.jsx b/frontend/src/pages/bellcloud/melodies/archetypes/ArchetypeList.jsx
new file mode 100644
index 0000000..881f1f2
--- /dev/null
+++ b/frontend/src/pages/bellcloud/melodies/archetypes/ArchetypeList.jsx
@@ -0,0 +1,569 @@
+// frontend/src/pages/melodies/archetypes/ArchetypeList.jsx
+
+import { useState, useEffect, useMemo } from 'react'
+import { useNavigate, Link } from 'react-router-dom'
+import api from '@/lib/api'
+import { useAuth } from '@/hooks/useAuth'
+import PageHeader from '@/components/ui/PageHeader'
+import Button from '@/components/ui/Button'
+import DataTable from '@/components/ui/DataTable'
+import Pagination from '@/components/ui/Pagination'
+import SearchBar from '@/components/ui/SearchBar'
+import RowActions from '@/components/ui/RowActions'
+import Icon from '@/components/ui/Icon'
+import ConfirmDialog from '@/components/ui/ConfirmDialog'
+import Modal from '@/components/ui/Modal'
+import UploadArchetypeModal from '@/modals/bellcloud/melodies/UploadArchetypeModal'
+import CodeSnippetModal from '@/modals/bellcloud/melodies/CodeSnippetModal'
+import GenerateBuiltinModal from '@/modals/bellcloud/melodies/GenerateBuiltinModal'
+import { fmtRelative, fmtDateTime as fmtDateTimeCentral } from '@/lib/formatters'
+
+// ─── Helpers ──────────────────────────────────────────────────────────────────
+
+function countSteps(s) {
+ if (!s) return 0
+ return s.split(',').length
+}
+
+function formatRelativeTime(isoValue) { return fmtRelative(isoValue) }
+
+function getLocalizedValue(value, lang, fallback = '') {
+ if (!value) return fallback
+ if (typeof value === 'object') return value[lang] || value['en'] || Object.values(value)[0] || fallback
+ try {
+ const parsed = JSON.parse(value)
+ if (typeof parsed === 'object') return parsed[lang] || parsed['en'] || Object.values(parsed)[0] || fallback
+ } catch { return value || fallback }
+ return fallback
+}
+
+// ─── Column definitions ───────────────────────────────────────────────────────
+
+const ALL_COLUMNS = [
+ { key: 'name', label: 'Name', defaultOn: true, alwaysOn: true, sortable: true },
+ { key: 'pid', label: 'PID', defaultOn: true, sortable: true },
+ { key: 'steps', label: 'Steps', defaultOn: true, sortable: true, align: 'center' },
+ { key: 'builtin', label: 'Built-in', defaultOn: true, align: 'center' },
+ { key: 'binary', label: 'Binary', defaultOn: true, align: 'center' },
+ { key: 'progmem', label: 'C++ Code', defaultOn: true, align: 'center' },
+ { key: 'assigned', label: 'Assigned', defaultOn: true, sortable: true, align: 'center' },
+ { key: 'updated', label: 'Updated', defaultOn: true, sortable: true },
+]
+
+const STORAGE_KEY = 'archetypeListColumnPrefs_v2'
+
+function loadColumnPrefs() {
+ try {
+ const raw = localStorage.getItem(STORAGE_KEY)
+ if (raw) {
+ const prefs = JSON.parse(raw)
+ if (prefs.order && prefs.visible) return prefs
+ }
+ } catch { /* ignore */ }
+ return {
+ order: ALL_COLUMNS.map((c) => c.key),
+ visible: ALL_COLUMNS.filter((c) => c.defaultOn).map((c) => c.key),
+ }
+}
+
+function saveColumnPrefs(order, visible) {
+ try { localStorage.setItem(STORAGE_KEY, JSON.stringify({ order, visible })) } catch { /* ignore */ }
+}
+
+// ─── Assigned Melodies Modal ──────────────────────────────────────────────────
+
+function AssignedMelodiesModal({ data, primaryLang, onClose }) {
+ if (!data) return null
+ const { archetype, melodyDetails } = data
+ return (
+ Close}
+ >
+ {melodyDetails.length === 0 ? (
+
+ No melodies assigned.
+
+ ) : (
+
+ {melodyDetails.map((m) => (
+ e.currentTarget.style.backgroundColor = 'var(--color-bg-elevated)'}
+ onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'var(--color-bg-surface)'}
+ >
+
+ {getLocalizedValue(m.nameRaw, primaryLang, m.id)}
+
+ View →
+
+ ))}
+
+ )}
+
+ )
+}
+
+// ─── ArchetypeList ────────────────────────────────────────────────────────────
+
+export default function ArchetypeList() {
+ const navigate = useNavigate()
+ const { hasPermission } = useAuth()
+ const canEdit = hasPermission('melodies', 'edit')
+
+ // Data
+ const [archetypes, setArchetypes] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState('')
+ const [primaryLang, setPrimaryLang] = useState('en')
+
+ // Filters / sort / pagination
+ const [search, setSearch] = useState('')
+ const [sortKey, setSortKey] = useState('updated')
+ const [sortDir, setSortDir] = useState('desc')
+ const [page, setPage] = useState(1)
+ const [pageSize, setPageSize] = useState(20)
+
+ // Columns
+ const [colPrefs, setColPrefs] = useState(loadColumnPrefs)
+
+ // Modals / actions
+ const [deleteTarget, setDeleteTarget] = useState(null)
+ const [assignedModal, setAssignedModal] = useState(null)
+ const [codeSnippetTarget, setCodeSnippetTarget] = useState(null)
+ const [showUpload, setShowUpload] = useState(false)
+ const [showGenerate, setShowGenerate] = useState(false)
+ const [togglingBuiltin, setTogglingBuiltin] = useState(null)
+ const [loadingAssigned, setLoadingAssigned] = useState(false)
+
+ // ── Load ───────────────────────────────────────────────────────────────────
+ useEffect(() => {
+ api.get('/settings/melody').then((ms) => setPrimaryLang(ms?.primary_language || 'en')).catch(() => {})
+ loadArchetypes({ verify: true })
+ }, []) // eslint-disable-line react-hooks/exhaustive-deps
+
+ const loadArchetypes = async ({ verify = false } = {}) => {
+ setLoading(true)
+ setError('')
+ try {
+ const data = await api.get('/builder/melodies')
+ const list = data.melodies || []
+ setArchetypes(list)
+
+ if (verify) {
+ let didPrune = false
+ await Promise.all(list.map(async (archetype) => {
+ const ids = archetype.assigned_melody_ids || []
+ if (!ids.length) return
+ const results = await Promise.allSettled(ids.map((mid) => api.get(`/melodies/${mid}`)))
+ const missingIds = results.map((r, i) => (!r.value || r.status === 'rejected' ? ids[i] : null)).filter(Boolean)
+ for (const mid of missingIds) {
+ try { await api.post(`/builder/melodies/${archetype.id}/unassign?firestore_melody_id=${mid}`); didPrune = true }
+ catch { /* best-effort */ }
+ }
+ }))
+ if (didPrune) {
+ const refreshed = await api.get('/builder/melodies')
+ setArchetypes(refreshed.melodies || [])
+ }
+ }
+ } catch (err) {
+ setError(err.message || 'Failed to load archetypes')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ // ── Actions ────────────────────────────────────────────────────────────────
+ const handleDelete = async () => {
+ if (!deleteTarget) return
+ try {
+ await api.delete(`/builder/melodies/${deleteTarget.id}`)
+ setDeleteTarget(null)
+ loadArchetypes()
+ } catch (err) {
+ setError(err.message)
+ setDeleteTarget(null)
+ }
+ }
+
+ const handleToggleBuiltin = async (e, archetype) => {
+ e.stopPropagation()
+ if (togglingBuiltin === archetype.id) return
+ setTogglingBuiltin(archetype.id)
+ try {
+ const updated = await api.post(`/builder/melodies/${archetype.id}/toggle-builtin`)
+ setArchetypes((prev) => prev.map((a) => a.id === archetype.id ? { ...a, is_builtin: updated.is_builtin } : a))
+ } catch (err) {
+ setError(err.message)
+ } finally {
+ setTogglingBuiltin(null)
+ }
+ }
+
+ const handleShowAssigned = async (e, archetype) => {
+ e.stopPropagation()
+ const ids = archetype.assigned_melody_ids || []
+ if (!ids.length) return
+ setLoadingAssigned(true)
+ try {
+ const results = await Promise.allSettled(ids.map((mid) => api.get(`/melodies/${mid}`)))
+ const melodyDetails = results
+ .filter((r) => r.status === 'fulfilled' && r.value)
+ .map((r) => ({ id: r.value.id, nameRaw: r.value.information?.name }))
+ const missingIds = results.map((r, i) => (r.status === 'rejected' || !r.value ? ids[i] : null)).filter(Boolean)
+ for (const mid of missingIds) {
+ try { await api.post(`/builder/melodies/${archetype.id}/unassign?firestore_melody_id=${mid}`) }
+ catch { /* best-effort */ }
+ }
+ if (missingIds.length) loadArchetypes()
+ setAssignedModal({ archetype, melodyDetails })
+ } catch (err) {
+ setError(err.message)
+ } finally {
+ setLoadingAssigned(false)
+ }
+ }
+
+ const handleDownloadBinary = async (e, archetype) => {
+ e.stopPropagation()
+ const token = localStorage.getItem('access_token')
+ try {
+ const res = await fetch(`/api/builder/melodies/${archetype.id}/download`, {
+ headers: token ? { Authorization: `Bearer ${token}` } : {},
+ })
+ if (!res.ok) throw new Error(`Download failed: ${res.statusText}`)
+ const blob = await res.blob()
+ const url = URL.createObjectURL(blob)
+ const a = document.createElement('a')
+ a.href = url; a.download = `${archetype.name || archetype.id}.bsm`; a.click()
+ URL.revokeObjectURL(url)
+ } catch (err) {
+ setError(err.message)
+ }
+ }
+
+ // ── Column management ──────────────────────────────────────────────────────
+ const handleColumnChange = (nextVisible) => {
+ setColPrefs(() => {
+ const next = { order: nextVisible, visible: nextVisible }
+ saveColumnPrefs(next.order, next.visible)
+ return next
+ })
+ }
+
+ // ── Sort & filter ──────────────────────────────────────────────────────────
+ const handleSort = (key, dir) => { setSortKey(key); setSortDir(dir) }
+
+ const filteredRows = useMemo(() => {
+ let rows = archetypes
+ if (search) {
+ const q = search.toLowerCase()
+ rows = rows.filter((a) => (a.name || '').toLowerCase().includes(q) || (a.pid || '').toLowerCase().includes(q))
+ }
+ return [...rows].sort((a, b) => {
+ let av, bv
+ switch (sortKey) {
+ case 'name': av = (a.name || '').toLowerCase(); bv = (b.name || '').toLowerCase(); break
+ case 'pid': av = (a.pid || '').toLowerCase(); bv = (b.pid || '').toLowerCase(); break
+ case 'steps': av = countSteps(a.steps); bv = countSteps(b.steps); break
+ case 'assigned': av = (a.assigned_melody_ids || []).length; bv = (b.assigned_melody_ids || []).length; break
+ case 'updated':
+ default: av = a.updated_at || ''; bv = b.updated_at || ''
+ }
+ if (av < bv) return sortDir === 'asc' ? -1 : 1
+ if (av > bv) return sortDir === 'asc' ? 1 : -1
+ return 0
+ })
+ }, [archetypes, search, sortKey, sortDir])
+
+ const total = filteredRows.length
+ const totalPages = Math.max(1, Math.ceil(total / pageSize))
+ const safePage = Math.min(page, totalPages)
+ const pagedRows = filteredRows.slice((safePage - 1) * pageSize, safePage * pageSize)
+
+ // ── Cell renderer ──────────────────────────────────────────────────────────
+ const renderCell = (key, row) => {
+ switch (key) {
+ case 'name':
+ return (
+
+ {row.name || '—'}
+
+ )
+
+ case 'pid':
+ return (
+
+ {row.pid || '—'}
+
+ )
+
+ case 'steps':
+ return (
+
+ {countSteps(row.steps)}
+
+ )
+
+ case 'builtin':
+ return (
+ e.stopPropagation()}>
+ handleToggleBuiltin(e, row)}
+ disabled={togglingBuiltin === row.id}
+ title={row.is_builtin ? 'Click to unmark as built-in' : 'Click to mark as built-in'}
+ style={{
+ padding: '2px 10px',
+ borderRadius: 'var(--radius-full)',
+ fontSize: 'var(--font-size-xs)',
+ fontFamily: 'var(--font-family-base)',
+ cursor: 'pointer',
+ border: row.is_builtin ? '1px solid rgba(139,92,246,0.35)' : '1px solid var(--color-border)',
+ backgroundColor: row.is_builtin ? 'rgba(139,92,246,0.12)' : 'var(--color-bg-elevated)',
+ color: row.is_builtin ? '#a78bfa' : 'var(--color-text-muted)',
+ opacity: togglingBuiltin === row.id ? 0.5 : 1,
+ transition: 'opacity var(--transition-fast)',
+ }}
+ >
+ {row.is_builtin ? 'Built-in' : '—'}
+
+
+ )
+
+ case 'binary':
+ if (!row.binary_path) return —
+ return (
+ handleDownloadBinary(e, row)}
+ >
+ Download
+
+ )
+
+ case 'progmem':
+ if (!row.progmem_code) return —
+ return (
+ { e.stopPropagation(); setCodeSnippetTarget(row) }}
+ >
+ View Code
+
+ )
+
+ case 'assigned': {
+ const count = (row.assigned_melody_ids || []).length
+ if (count === 0) return —
+ return (
+ handleShowAssigned(e, row)}
+ >
+ {count} {count === 1 ? 'melody' : 'melodies'}
+
+ )
+ }
+
+ case 'updated':
+ return (
+
+ {formatRelativeTime(row.updated_at)}
+
+ )
+
+ default:
+ return —
+ }
+ }
+
+ // ── Table columns ──────────────────────────────────────────────────────────
+ const tableColumns = [
+ ...colPrefs.visible.map((key) => {
+ const col = ALL_COLUMNS.find((c) => c.key === key)
+ return col ? { ...col, render: (row) => renderCell(key, row) } : null
+ }).filter(Boolean),
+ {
+ key: '__actions',
+ label: '',
+ width: '90px',
+ align: 'right',
+ render: (row) => (
+ ,
+ onClick: () => navigate(`/melodies/archetypes/${row.id}`),
+ },
+ {
+ label: 'Load to Composer',
+ icon: ,
+ onClick: () => navigate('/melodies/composer', { state: { archetype: row } }),
+ },
+ {
+ label: 'Download Binary',
+ icon: ,
+ onClick: (e) => handleDownloadBinary(e || { stopPropagation: () => {} }, row),
+ },
+ ...(canEdit ? [
+ {
+ label: 'Delete',
+ icon: ,
+ color: 'var(--color-danger)',
+ onClick: () => setDeleteTarget(row),
+ divider: true,
+ },
+ ] : []),
+ ]}
+ />
+ ),
+ },
+ ]
+
+ // ─────────────────────────────────────────────────────────────────────────
+
+ return (
+
+
+ setShowGenerate(true)}>
+ Generate Built-in List
+
+ setShowUpload(true)}>
+ Upload Archetype
+
+ {canEdit && (
+ navigate('/melodies/archetypes/new')}>
+
+ New Archetype
+
+ )}
+
+
+ {/* Filter toolbar */}
+
+
+
+
+ {search && (
+
setSearch('')}>
+ Clear
+
+ )}
+
+
+ {/* Error banner */}
+ {error && (
+
+ {error}
+
+ )}
+
+ {/* Table */}
+
navigate(`/melodies/archetypes/${row.id}`)}
+ allColumns={[...ALL_COLUMNS, { key: '__actions', label: '', pickerLabel: 'Actions', alwaysOn: true }]}
+ visibleKeys={colPrefs.visible}
+ onColumnChange={handleColumnChange}
+ footer={
+ { setPageSize(size); setPage(1) }}
+ />
+ }
+ />
+
+ {/* Modals */}
+ setShowUpload(false)}
+ onUploaded={() => loadArchetypes()}
+ />
+
+ setShowGenerate(false)}
+ />
+
+ setCodeSnippetTarget(null)}
+ />
+
+ setAssignedModal(null)}
+ />
+
+ setDeleteTarget(null)}
+ />
+
+ )
+}
diff --git a/frontend/src/pages/bellcloud/mqtt/CommandPanel.jsx b/frontend/src/pages/bellcloud/mqtt/CommandPanel.jsx
new file mode 100644
index 0000000..3ebc320
--- /dev/null
+++ b/frontend/src/pages/bellcloud/mqtt/CommandPanel.jsx
@@ -0,0 +1,4 @@
+// TODO: implement
+export default function CommandPanel() {
+ return null
+}
diff --git a/frontend/src/pages/bellcloud/mqtt/LogViewer.jsx b/frontend/src/pages/bellcloud/mqtt/LogViewer.jsx
new file mode 100644
index 0000000..bd23c71
--- /dev/null
+++ b/frontend/src/pages/bellcloud/mqtt/LogViewer.jsx
@@ -0,0 +1,4 @@
+// TODO: implement
+export default function LogViewer() {
+ return null
+}
diff --git a/frontend/src/pages/bellcloud/mqtt/MqttDashboard.jsx b/frontend/src/pages/bellcloud/mqtt/MqttDashboard.jsx
new file mode 100644
index 0000000..a03de6d
--- /dev/null
+++ b/frontend/src/pages/bellcloud/mqtt/MqttDashboard.jsx
@@ -0,0 +1,4 @@
+// TODO: implement
+export default function MqttDashboard() {
+ return null
+}
diff --git a/frontend/src/pages/bellcloud/users/UserDetail.jsx b/frontend/src/pages/bellcloud/users/UserDetail.jsx
new file mode 100644
index 0000000..28246e8
--- /dev/null
+++ b/frontend/src/pages/bellcloud/users/UserDetail.jsx
@@ -0,0 +1,892 @@
+// frontend/src/pages/users/UserDetail.jsx
+
+import { useState, useEffect, useRef, useCallback } from 'react'
+import { useParams, useNavigate } from 'react-router-dom'
+import { createPortal } from 'react-dom'
+import api from '@/lib/api'
+import { useAuth } from '@/hooks/useAuth'
+import { useToast } from '@/components/ui/Toast'
+import { ProfilePageHeaderActions } from '@/components/ui/ProfilePageHeader'
+import Button from '@/components/ui/Button'
+import Card from '@/components/ui/Card'
+import StatusBadge from '@/components/ui/StatusBadge'
+import Spinner from '@/components/ui/Spinner'
+import Select from '@/components/ui/Select'
+import Icon from '@/components/ui/Icon'
+import ConfirmDialog from '@/components/ui/ConfirmDialog'
+import { fmtDateMedium } from '@/lib/formatters'
+import DeleteUserModal from '@/modals/bellcloud/users/DeleteUserModal'
+import EntryFormModal from '@/modals/crm/helpdesk/EntryFormModal'
+
+// ─── Helpers ──────────────────────────────────────────────────────────────────
+
+function statusVariant(status) {
+ if (status === 'active') return 'success'
+ if (status === 'blocked') return 'danger'
+ return 'neutral'
+}
+
+// ─── ActionMenu — primary "Actions" dropdown for page headers ─────────────────
+// Reuses the existing .row-actions-menu / .row-actions-item CSS classes.
+
+function ActionMenu({ actions = [], loading = false }) {
+ const [open, setOpen] = useState(false)
+ const [pos, setPos] = useState({ top: 0, right: 0 })
+ const btnRef = useRef(null)
+ const menuRef = useRef(null)
+
+ useEffect(() => {
+ if (!open) return
+ function onMouseDown(e) {
+ if (btnRef.current?.contains(e.target)) return
+ if (menuRef.current?.contains(e.target)) return
+ setOpen(false)
+ }
+ function onScroll() { setOpen(false) }
+ document.addEventListener('mousedown', onMouseDown)
+ document.addEventListener('scroll', onScroll, true)
+ return () => {
+ document.removeEventListener('mousedown', onMouseDown)
+ document.removeEventListener('scroll', onScroll, true)
+ }
+ }, [open])
+
+ function handleToggle(e) {
+ e.stopPropagation()
+ if (!open && btnRef.current) {
+ const r = btnRef.current.getBoundingClientRect()
+ setPos({ top: r.bottom + 6, right: window.innerWidth - r.right })
+ }
+ setOpen((v) => !v)
+ }
+
+ return (
+ <>
+
+
+
+ }
+ >
+ Actions
+
+
+ {open && createPortal(
+
+ {actions.map((action) => (
+ {
+ e.stopPropagation()
+ setOpen(false)
+ action.onClick?.()
+ }}
+ >
+ {action.icon && {action.icon} }
+ {action.label}
+
+ ))}
+
,
+ document.body
+ )}
+ >
+ )
+}
+
+// ─── Page-internal sub-components ─────────────────────────────────────────────
+
+function Field({ label, children, mono = false }) {
+ const isEmpty = children == null || children === ''
+ return (
+
+
{label}
+
+ {isEmpty ? '—' : children}
+
+
+ )
+}
+
+
+// Eye / EyeOff inline SVGs — Icon component doesn't have these
+function EyeIcon({ off = false, size = 16 }) {
+ return off ? (
+
+
+
+
+
+ ) : (
+
+
+
+
+ )
+}
+
+function PinField({ label, value }) {
+ const [revealed, setRevealed] = useState(false)
+ return (
+
+
+ {label}
+
+
+ {value ? (
+ <>
+
+ {revealed ? value : '•'.repeat(String(value).length)}
+
+ setRevealed((v) => !v)}
+ aria-label={revealed ? 'Hide PIN' : 'Show PIN'}
+ style={{
+ background: 'none',
+ border: 'none',
+ cursor: 'pointer',
+ color: revealed ? 'var(--color-primary)' : 'var(--color-text-muted)',
+ padding: 'var(--space-1)',
+ display: 'flex',
+ alignItems: 'center',
+ transition: 'color 0.15s',
+ flexShrink: 0,
+ }}
+ >
+
+
+ >
+ ) : (
+ Not set
+ )}
+
+
+ )
+}
+
+function ActivityDot({ color }) {
+ return (
+
+ )
+}
+
+function OnlineDot({ isOnline }) {
+ return (
+
+ )
+}
+
+// ─── Status pill (reused from IssuesTab pattern) ──────────────────────────────
+
+const STATUS_META = {
+ open: { label: 'Open', color: 'var(--color-danger)', bg: 'var(--color-danger-bg)' },
+ researching: { label: 'Researching', color: 'var(--color-warning)', bg: 'var(--color-warning-bg)' },
+ resolved: { label: 'Resolved', color: 'var(--color-success)', bg: 'var(--color-success-bg)' },
+}
+
+function StatusPill({ status }) {
+ const m = STATUS_META[status] || { label: status, color: 'var(--color-text-muted)', bg: 'var(--color-bg-elevated)' }
+ return (
+
+
+ {m.label}
+
+ )
+}
+
+// ─── Notes & Issues card (Postgres /notes system) ─────────────────────────────
+
+function NotesCard({ userId, userName, canEdit }) {
+ const { toast } = useToast()
+ const [entries, setEntries] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [entryModal, setEntryModal] = useState({ open: false, entry: null, defaultType: 'note' })
+ const [deleteId, setDeleteId] = useState(null)
+ const [deleting, setDeleting] = useState(false)
+
+ const load = useCallback(async () => {
+ setLoading(true)
+ try {
+ const data = await api.get(`/notes/by-entity/app_user/${userId}`)
+ // newest first
+ setEntries(Array.isArray(data) ? [...data].reverse() : [])
+ } catch { setEntries([]) }
+ finally { setLoading(false) }
+ }, [userId])
+
+ useEffect(() => { load() }, [load])
+
+ const handleDelete = async () => {
+ setDeleting(true)
+ try {
+ await api.delete(`/notes/${deleteId}`)
+ toast.success('Deleted', 'Entry removed.')
+ setDeleteId(null)
+ load()
+ } catch (err) {
+ toast.danger('Error', err.message || 'Failed to delete.')
+ } finally { setDeleting(false) }
+ }
+
+ const openCreate = (defaultType) => setEntryModal({ open: true, entry: null, defaultType })
+ const openEdit = (entry) => setEntryModal({ open: true, entry, defaultType: entry.type })
+
+ const issues = entries.filter(e => e.type === 'issue')
+ const notes = entries.filter(e => e.type === 'note')
+ const total = entries.length
+
+ return (
+ <>
+ }
+ padding={false}
+ action={canEdit && (
+
+ openCreate('note')}>+ Note
+ openCreate('issue')}>+ Issue
+
+ )}
+ >
+ {/* card-body-flush makes this a flex:1 container — we manage all padding */}
+
+
+ {/* Summary pills */}
+ {issues.length > 0 && notes.length > 0 && (
+
+ i.status !== 'resolved') ? 'danger' : 'success'}>
+ {issues.length} issue{issues.length !== 1 ? 's' : ''}
+
+ {notes.length} note{notes.length !== 1 ? 's' : ''}
+
+ )}
+
+ {/* Scrollable list */}
+
+ {loading ? (
+
+
+
+ ) : entries.length === 0 ? (
+
+ No notes or issues linked to this user.
+
+ ) : (
+ entries.map((entry) => (
+
canEdit && openEdit(entry)}
+ style={{
+ display: 'flex',
+ alignItems: 'flex-start',
+ gap: 'var(--space-3)',
+ padding: 'var(--space-3) var(--space-4)',
+ borderRadius: 'var(--radius-lg)',
+ backgroundColor: 'var(--color-bg-abyss)',
+ border: '1px solid var(--color-border)',
+ cursor: canEdit ? 'pointer' : 'default',
+ transition: 'background-color 0.15s',
+ flexShrink: 0,
+ }}
+ onMouseEnter={e => { if (canEdit) e.currentTarget.style.backgroundColor = 'var(--color-bg-elevated)' }}
+ onMouseLeave={e => { e.currentTarget.style.backgroundColor = 'var(--color-bg-abyss)' }}
+ >
+
+
+ {entry.type === 'issue'
+ ?
+ : Note
+ }
+
+ {entry.title}
+
+
+ {entry.body && (
+
+ {entry.body.replace(/<[^>]*>/g, '')}
+
+ )}
+
+ {[entry.author_name, entry.created_at ? fmtDateMedium(entry.created_at) : null].filter(Boolean).join(' · ')}
+
+
+ {canEdit && (
+
{ e.stopPropagation(); setDeleteId(entry.id) }}
+ aria-label="Delete entry"
+ style={{
+ background: 'none', border: 'none', cursor: 'pointer',
+ color: 'var(--color-text-muted)', padding: 'var(--space-1)',
+ display: 'flex', alignItems: 'center', flexShrink: 0,
+ transition: 'color 0.15s',
+ }}
+ onMouseEnter={e => { e.currentTarget.style.color = 'var(--color-danger)' }}
+ onMouseLeave={e => { e.currentTarget.style.color = 'var(--color-text-muted)' }}
+ >
+
+
+ )}
+
+ ))
+ )}
+
+
+
+
+ {/* Entry form modal */}
+ setEntryModal({ open: false, entry: null, defaultType: 'note' })}
+ onSaved={() => { setEntryModal({ open: false, entry: null, defaultType: 'note' }); load() }}
+ onDelete={canEdit ? (id) => { setEntryModal({ open: false, entry: null, defaultType: 'note' }); setDeleteId(id) } : null}
+ />
+
+ {/* Delete confirm */}
+ setDeleteId(null)}
+ loading={deleting}
+ />
+ >
+ )
+}
+
+// ─── UserDetail ───────────────────────────────────────────────────────────────
+
+export default function UserDetail() {
+ const { id } = useParams()
+ const navigate = useNavigate()
+ const { hasPermission } = useAuth()
+ const { toast } = useToast()
+ const canEdit = hasPermission('app_users', 'edit')
+ const photoInputRef = useRef(null)
+
+ const [user, setUser] = useState(null)
+ const [devices, setDevices] = useState([])
+ const [allDevices, setAllDevices] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState('')
+ const [blocking, setBlocking] = useState(false)
+ const [showDelete, setShowDelete] = useState(false)
+ const [deleteError, setDeleteError] = useState('')
+ const [uploadingPhoto, setUploadingPhoto] = useState(false)
+
+ // Device assignment
+ const [showAssignPanel, setShowAssignPanel] = useState(false)
+ const [selectedDeviceId, setSelectedDeviceId] = useState('')
+ const [assigningDevice, setAssigningDevice] = useState(false)
+
+ // ── Load ───────────────────────────────────────────────────────────────────
+ const loadData = useCallback(async () => {
+ setLoading(true)
+ setError('')
+ try {
+ const [u, d] = await Promise.all([
+ api.get(`/users/${id}`),
+ api.get(`/users/${id}/devices`),
+ ])
+ setUser(u)
+ setDevices(Array.isArray(d) ? d : [])
+ } catch (err) {
+ setError(err.message || 'Failed to load user.')
+ } finally {
+ setLoading(false)
+ }
+ }, [id])
+
+ useEffect(() => { loadData() }, [loadData])
+
+ const loadAllDevices = async () => {
+ try {
+ const data = await api.get('/devices')
+ setAllDevices(data.devices ?? [])
+ } catch { /* silent */ }
+ }
+
+ // ── Actions ────────────────────────────────────────────────────────────────
+ const handleDelete = async () => {
+ setDeleteError('')
+ try {
+ await api.delete(`/users/${id}`)
+ toast.success('User deleted')
+ navigate('/users')
+ } catch (err) {
+ setDeleteError(err.message || 'Failed to delete user.')
+ }
+ }
+
+ const handleBlockToggle = async () => {
+ setBlocking(true)
+ try {
+ const endpoint = user.status === 'blocked' ? `/users/${id}/unblock` : `/users/${id}/block`
+ const updated = await api.post(endpoint)
+ setUser(updated)
+ toast.success(user.status === 'blocked' ? 'User unblocked' : 'User blocked')
+ } catch (err) {
+ toast.danger('Error', err.message || 'Failed to update user status.')
+ } finally {
+ setBlocking(false)
+ }
+ }
+
+ const handleAssignDevice = async () => {
+ if (!selectedDeviceId) return
+ setAssigningDevice(true)
+ try {
+ await api.post(`/users/${id}/devices/${selectedDeviceId}`)
+ const d = await api.get(`/users/${id}/devices`)
+ setDevices(Array.isArray(d) ? d : [])
+ setSelectedDeviceId('')
+ setShowAssignPanel(false)
+ toast.success('Device assigned')
+ } catch (err) {
+ toast.danger('Error', err.message || 'Failed to assign device.')
+ } finally {
+ setAssigningDevice(false)
+ }
+ }
+
+ const handleUnassignDevice = async (deviceId) => {
+ try {
+ await api.delete(`/users/${id}/devices/${deviceId}`)
+ const d = await api.get(`/users/${id}/devices`)
+ setDevices(Array.isArray(d) ? d : [])
+ toast.success('Device removed')
+ } catch (err) {
+ toast.danger('Error', err.message || 'Failed to remove device.')
+ }
+ }
+
+ const handlePhotoUpload = async (e) => {
+ const file = e.target.files?.[0]
+ if (!file) return
+ setUploadingPhoto(true)
+ try {
+ const result = await api.upload(`/users/${id}/photo`, file)
+ setUser((prev) => ({ ...prev, photo_url: result.photo_url }))
+ toast.success('Photo updated')
+ } catch (err) {
+ toast.danger('Error', err.message || 'Failed to upload photo.')
+ } finally {
+ setUploadingPhoto(false)
+ if (photoInputRef.current) photoInputRef.current.value = ''
+ }
+ }
+
+ // ── Loading / error states ─────────────────────────────────────────────────
+ if (loading) {
+ return (
+
+ )
+ }
+
+ if (error && !user) {
+ return (
+
+ )
+ }
+
+ if (!user) return null
+
+ const isBlocked = user.status === 'blocked'
+ const assignedDeviceIds = new Set(devices.map((d) => d.id))
+ const availableDevices = allDevices.filter((d) => !assignedDeviceIds.has(d.id))
+ const friendCount = user.friendsList?.length ?? 0
+ const invitedCount = user.friendsInvited?.length ?? 0
+
+ // Actions menu items
+ const actionItems = canEdit ? [
+ {
+ label: isBlocked ? 'Unblock User' : 'Block User',
+ icon: ,
+ onClick: handleBlockToggle,
+ },
+ {
+ label: 'Edit User',
+ icon: ,
+ onClick: () => navigate(`/users/${id}/edit`),
+ },
+ {
+ label: 'Change Photo',
+ icon: ,
+ onClick: () => photoInputRef.current?.click(),
+ },
+ {
+ label: 'Delete User',
+ icon: ,
+ color: 'var(--color-danger)',
+ divider: true,
+ onClick: () => { setDeleteError(''); setShowDelete(true) },
+ },
+ ] : []
+
+ return (
+
+
+ {/* Page header — no breadcrumbs */}
+
photoInputRef.current?.click() : undefined}
+ badge={
+
+ {user.status ? user.status.charAt(0).toUpperCase() + user.status.slice(1) : 'Unknown'}
+
+ }
+ >
+ {canEdit && }
+
+
+ {/* Hidden photo input */}
+
+
+ {/* Inline error banner */}
+ {error && (
+
+ {error}
+
+ )}
+
+ {/* ── Layout grid ────────────────────────────────────────────────────── */}
+
+
+ {/* ── ROW 1 ── Account Information (span 2) + Security Access (span 1) */}
+ {/* Both cells use display:flex+flex-col+height:100% to match heights */}
+
+
+
}>
+
+ {user.phone_number}
+ {user.email}
+ {user.userTitle}
+
+
+ {user.uid || user.id || '—'}
+
+
+ {/* Bio — always present, full width, 3rd row */}
+
+
+ {user.bio ? (
+
+ {user.bio}
+
+ ) : (
+
+ No bio
+
+ )}
+
+
+
+
+
+
+
+
+ {/* ── ROW 2 ── System Activity (span 1) + Notes (span 2, scrollable) ── */}
+
+
+
}>
+
+
+
+
+
+ Created
+
+
+ {user.created_time || user.createdAt || '—'}
+
+
+
+
+
+
+
+ Last Active
+
+
+ {user.lastActive || '—'}
+
+
+
+
+
+
+
+ {/* Notes — spans 2 cols, row 2 is 320px tall, card fills and scrolls */}
+
+
+
+
+ {/* ── ROW 3 ── Social (span 1) + Linked Devices (span 2) ─────────────── */}
+
+
}>
+
+
+ {friendCount}
+
+
+
+ Invited Members
+ {invitedCount}
+
+
+
+
+
}
+ action={canEdit && (
+
{ setShowAssignPanel((v) => !v); if (!showAssignPanel) loadAllDevices() }}>
+ {showAssignPanel ? 'Cancel' : 'Assign Device'}
+
+ )}
+ >
+
+ {/* Assign device inline panel */}
+ {showAssignPanel && (
+
+
+ setSelectedDeviceId(e.target.value)} placeholder="Choose a device…">
+ Choose a device…
+ {availableDevices.map((d) => (
+
+ {d.device_name || 'Unnamed'} ({d.device_id || d.id})
+
+ ))}
+
+
+
+ Assign
+
+
+ )}
+
+ {/* Device list */}
+ {devices.length === 0 ? (
+
No devices assigned.
+ ) : (
+
+ {devices.map((device) => (
+
+
+
+
+
navigate(`/devices/${device.id}`)}
+ style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 0, fontSize: 'var(--font-size-sm)', fontWeight: 'var(--font-weight-semibold)', color: 'var(--color-text-accent)', textAlign: 'left', display: 'block', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '160px' }}
+ >
+ {device.device_name || 'Unnamed Device'}
+
+
+ {device.device_id || device.id}
+
+
+
+ {canEdit && (
+
handleUnassignDevice(device.id)}>Remove
+ )}
+
+ ))}
+
+ )}
+
+
+
+
+
+ {/* Responsive grid */}
+
+
+ {/* Delete modal */}
+
{ setShowDelete(false); setDeleteError('') }}
+ error={deleteError}
+ />
+
+
+ )
+}
diff --git a/frontend/src/pages/bellcloud/users/UserForm.jsx b/frontend/src/pages/bellcloud/users/UserForm.jsx
new file mode 100644
index 0000000..e9660d8
--- /dev/null
+++ b/frontend/src/pages/bellcloud/users/UserForm.jsx
@@ -0,0 +1,513 @@
+// frontend/src/pages/users/UserForm.jsx
+
+import { useState, useEffect, useRef } from 'react'
+import { useParams, useNavigate } from 'react-router-dom'
+import api from '@/lib/api'
+import { useToast } from '@/components/ui/Toast'
+import PageHeader from '@/components/ui/PageHeader'
+import Button from '@/components/ui/Button'
+import Card from '@/components/ui/Card'
+import FormField from '@/components/ui/FormField'
+import Spinner from '@/components/ui/Spinner'
+import Icon from '@/components/ui/Icon'
+
+// ─── Status dual-button ──────────────────────────────────────────────────────
+
+function StatusToggle({ value, onChange }) {
+ const opts = [
+ { value: 'active', label: 'Active', activeBg: 'var(--color-success-bg)', activeFg: 'var(--color-success)' },
+ { value: 'blocked', label: 'Blocked', activeBg: 'var(--color-danger-bg)', activeFg: 'var(--color-danger)' },
+ ]
+ return (
+
+
+ Status
+
+ {/* Match the width of one column in the 2-col grid above */}
+
+
+ {opts.map((opt, i) => {
+ const active = value === opt.value
+ return (
+ onChange(opt.value)}
+ style={{
+ flex: 1,
+ padding: 'var(--space-3) var(--space-4)',
+ fontSize: 'var(--font-size-base)',
+ fontWeight: active ? 'var(--font-weight-semibold)' : 'var(--font-weight-normal)',
+ cursor: 'pointer',
+ background: active ? opt.activeBg : 'transparent',
+ color: active ? opt.activeFg : 'var(--color-text-muted)',
+ borderRight: i === 0 ? '1px solid var(--color-border-strong)' : 'none',
+ transition: 'background var(--transition-fast), color var(--transition-fast)',
+ whiteSpace: 'nowrap',
+ }}
+ >
+ {opt.label}
+
+ )
+ })}
+
+
+
+ )
+}
+
+// ─── Photo upload field ──────────────────────────────────────────────────────
+
+function PhotoField({ value, onChange, userId }) {
+ const fileInputRef = useRef(null)
+ const toast = useToast()
+ const [uploading, setUploading] = useState(false)
+ const [hovered, setHovered] = useState(false)
+
+ const handleFileChange = async (e) => {
+ const file = e.target.files?.[0]
+ if (!file) return
+ if (!file.type.startsWith('image/')) {
+ toast.danger('Invalid file', 'Please select an image file.')
+ return
+ }
+ setUploading(true)
+ try {
+ const result = await api.upload(`/users/${userId}/photo`, file)
+ onChange(result.url || result.photo_url || '')
+ toast.success('Photo updated', 'Profile photo has been uploaded.')
+ } catch (err) {
+ toast.danger('Upload failed', err.message || 'Could not upload photo.')
+ } finally {
+ setUploading(false)
+ e.target.value = ''
+ }
+ }
+
+ return (
+
+ {/* Clickable thumbnail — same height as the URL input + hint beside it */}
+
fileInputRef.current?.click()}
+ disabled={uploading}
+ onMouseEnter={() => setHovered(true)}
+ onMouseLeave={() => setHovered(false)}
+ style={{
+ position: 'relative',
+ flexShrink: 0,
+ width: 72,
+ borderRadius: 'var(--radius-lg)',
+ border: `1px solid ${hovered && !uploading ? 'var(--color-primary)' : 'var(--color-border-strong)'}`,
+ overflow: 'hidden',
+ cursor: uploading ? 'wait' : 'pointer',
+ background: 'var(--color-bg-abyss)',
+ transition: 'border-color var(--transition-fast)',
+ }}
+ >
+ {value ? (
+ { e.currentTarget.style.display = 'none' }}
+ />
+ ) : (
+
+
+
+ )}
+
+ {uploading ? : }
+
+
+
+
+ onChange(e.target.value)}
+ placeholder="https://..."
+ hint="Paste a URL or click the photo to upload a file."
+ />
+
+
+
+
+ )
+}
+
+// ─── Password section ────────────────────────────────────────────────────────
+
+function EyeToggle({ show, onToggle }) {
+ return (
+
+
+
+ )
+}
+
+function PasswordCard({ userId, hasUid }) {
+ const toast = useToast()
+ const [password, setPassword] = useState('')
+ const [confirm, setConfirm] = useState('')
+ const [showPass, setShowPass] = useState(false)
+ const showConf = showPass
+ const [saving, setSaving] = useState('') // 'set' | 'reset' | ''
+
+ const mismatch = confirm && password !== confirm
+
+ const handleSet = async () => {
+ if (!password || mismatch) return
+ setSaving('set')
+ try {
+ await api.post(`/users/${userId}/set-password`, { password })
+ toast.success('Password updated', 'The new password is active immediately.')
+ setPassword('')
+ setConfirm('')
+ } catch (err) {
+ toast.danger('Failed', err.message || 'Could not set password.')
+ } finally {
+ setSaving('')
+ }
+ }
+
+ const handleReset = async () => {
+ setSaving('reset')
+ try {
+ await api.post(`/users/${userId}/reset-password`, {})
+ toast.success('Password reset', 'Password has been reset to the default.')
+ } catch (err) {
+ toast.danger('Failed', err.message || 'Could not reset password.')
+ } finally {
+ setSaving('')
+ }
+ }
+
+ return (
+
+ {!hasUid ? (
+
+ This user has no Firebase Auth UID. They must sign up via the app before their password can be managed here.
+
+ ) : (
+
+
+ Set a new password — active immediately in the mobile app.
+
+
+
+ {/* New password */}
+
+ setPassword(e.target.value)}
+ placeholder="Min. 6 characters"
+ inputProps={{ style: { paddingRight: 'var(--space-8)' } }}
+ />
+ setShowPass(v => !v)} />
+
+
+ {/* Confirm password */}
+
+ setConfirm(e.target.value)}
+ placeholder="Re-enter password"
+ error={mismatch ? 'Passwords do not match.' : undefined}
+ inputProps={{ style: { paddingRight: 'var(--space-8)' } }}
+ />
+ setShowPass(v => !v)} />
+
+
+
+
+
+ Set Password
+
+
+ Reset to Default
+
+
+
+ )}
+
+ )
+}
+
+// ─── Main ────────────────────────────────────────────────────────────────────
+
+export default function UserForm() {
+ const { id } = useParams()
+ const navigate = useNavigate()
+ const toast = useToast()
+ const isEdit = Boolean(id)
+
+ const [form, setForm] = useState({
+ email: '',
+ display_name: '',
+ photo_url: '',
+ phone_number: '',
+ status: 'active',
+ bio: '',
+ userTitle: '',
+ settingsPIN: '',
+ quickSettingsPIN: '',
+ })
+ const [uid, setUid] = useState('')
+ const [loading, setLoading] = useState(false)
+ const [saving, setSaving] = useState(false)
+ const [error, setError] = useState('')
+
+ useEffect(() => {
+ if (!isEdit) return
+ setLoading(true)
+ api.get(`/users/${id}`)
+ .then((user) => {
+ setUid(user.uid || '')
+ setForm({
+ email: user.email || '',
+ display_name: user.display_name || '',
+ photo_url: user.photo_url || '',
+ phone_number: user.phone_number || '',
+ status: user.status || 'active',
+ bio: user.bio || '',
+ userTitle: user.userTitle || '',
+ settingsPIN: user.settingsPIN || '',
+ quickSettingsPIN: user.quickSettingsPIN || '',
+ })
+ })
+ .catch((err) => {
+ setError(err.message)
+ toast.danger('Failed to load', err.message)
+ })
+ .finally(() => setLoading(false))
+ }, [id])
+
+ const set = (key) => (e) => setForm((f) => ({ ...f, [key]: e.target.value }))
+ const setVal = (key) => (val) => setForm((f) => ({ ...f, [key]: val }))
+ const setPin = (key) => (e) => setForm((f) => ({ ...f, [key]: e.target.value.replace(/\D/g, '') }))
+
+ const handleSubmit = async (e) => {
+ e.preventDefault()
+ setError('')
+ setSaving(true)
+ try {
+ if (isEdit) {
+ await api.put(`/users/${id}`, form)
+ toast.success('Saved', 'User updated successfully.')
+ navigate(`/users/${id}`)
+ } else {
+ const created = await api.post('/users', form)
+ toast.success('Created', `${form.display_name || 'User'} has been added.`)
+ navigate(`/users/${created.id}`)
+ }
+ } catch (err) {
+ setError(err.message || 'Something went wrong.')
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ if (loading) {
+ return (
+
+ )
+ }
+
+ return (
+
+
+ navigate(isEdit ? `/users/${id}` : '/users')}>
+ Cancel
+
+
+ {isEdit ? 'Save Changes' : 'Create User'}
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+
+ )
+}
diff --git a/frontend/src/pages/bellcloud/users/UserList.jsx b/frontend/src/pages/bellcloud/users/UserList.jsx
new file mode 100644
index 0000000..b328a11
--- /dev/null
+++ b/frontend/src/pages/bellcloud/users/UserList.jsx
@@ -0,0 +1,379 @@
+// frontend/src/pages/bellcloud/users/UserList.jsx
+
+import { useState, useEffect, useCallback } from 'react'
+import { useNavigate } from 'react-router-dom'
+import api from '@/lib/api'
+import { useAuth } from '@/hooks/useAuth'
+import usePreferences from '@/hooks/usePreferences'
+import PageHeader from '@/components/ui/PageHeader'
+import Button from '@/components/ui/Button'
+import DataTable from '@/components/ui/DataTable'
+import StatusBadge from '@/components/ui/StatusBadge'
+import Pagination from '@/components/ui/Pagination'
+import SearchBar from '@/components/ui/SearchBar'
+import Select from '@/components/ui/Select'
+import RowActions from '@/components/ui/RowActions'
+import Icon from '@/components/ui/Icon'
+import DeleteUserModal from '@/modals/bellcloud/users/DeleteUserModal'
+import UserListCardView from '@/pages/bellcloud/users/UserListCardView'
+
+// ─── Constants ────────────────────────────────────────────────────────────────
+
+const PREFS_KEY = 'bellcloud_users'
+
+// ─── Column definitions ───────────────────────────────────────────────────────
+
+const ALL_COLUMNS = [
+ { key: 'status', label: '', pickerLabel: 'Status', defaultOn: true, width: '80px' },
+ { key: 'name', label: 'Name', defaultOn: true, alwaysOn: true },
+ { key: 'email', label: 'Email', defaultOn: true },
+ { key: 'phone', label: 'Phone', defaultOn: true },
+ { key: 'title', label: 'Title', defaultOn: true },
+ { key: 'lastActive', label: 'Last Active', defaultOn: true },
+]
+
+// ─── Helpers ──────────────────────────────────────────────────────────────────
+
+function statusVariant(status) {
+ if (status === 'active') return 'success'
+ if (status === 'blocked') return 'danger'
+ return 'neutral'
+}
+
+function Muted({ children }) {
+ return {children}
+}
+
+// ─── UserList ─────────────────────────────────────────────────────────────────
+
+export default function UserList() {
+ const navigate = useNavigate()
+ const { hasPermission } = useAuth()
+ const canEdit = hasPermission('app_users', 'edit')
+
+ const { prefs, setPref, ready } = usePreferences(PREFS_KEY)
+
+ // Data
+ const [users, setUsers] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState('')
+
+ // Filters
+ const [search, setSearch] = useState('')
+ const [statusFilter, setStatusFilter] = useState('')
+
+ // Pagination
+ const [page, setPage] = useState(1)
+ const [pageSize, setPageSize] = useState(20)
+
+ // View mode: 'table' | 'card'
+ const [viewMode, setViewMode] = useState('table')
+
+ // Delete modal
+ const [deleteTarget, setDeleteTarget] = useState(null)
+ const [deleteError, setDeleteError] = useState('')
+
+ // ── Load prefs once ready ──────────────────────────────────────────────────
+ useEffect(() => {
+ if (!ready) return
+ if (prefs.status_filter !== undefined) setStatusFilter(prefs.status_filter)
+ if (prefs.page_size !== undefined) setPageSize(prefs.page_size)
+ if (prefs.view_mode !== undefined) setViewMode(prefs.view_mode)
+ }, [ready]) // eslint-disable-line react-hooks/exhaustive-deps
+
+ // ── Fetch ──────────────────────────────────────────────────────────────────
+ const fetchUsers = useCallback(async () => {
+ setLoading(true)
+ setError('')
+ try {
+ const params = new URLSearchParams()
+ if (search) params.set('search', search)
+ if (statusFilter) params.set('status', statusFilter)
+ const qs = params.toString()
+ const data = await api.get(`/users${qs ? `?${qs}` : ''}`)
+ setUsers(data.users ?? [])
+ } catch (err) {
+ setError(err.message || 'Failed to load users.')
+ } finally {
+ setLoading(false)
+ }
+ }, [search, statusFilter])
+
+ // Silent refresh — re-fetches data without triggering the loading skeleton.
+ // Used after in-place actions like status changes so cards update without flashing.
+ const silentRefreshUsers = useCallback(async () => {
+ try {
+ const params = new URLSearchParams()
+ if (search) params.set('search', search)
+ if (statusFilter) params.set('status', statusFilter)
+ const qs = params.toString()
+ const data = await api.get(`/users${qs ? `?${qs}` : ''}`)
+ setUsers(data.users ?? [])
+ } catch {
+ // swallow — a silent refresh failure isn't worth an error banner
+ }
+ }, [search, statusFilter])
+
+ useEffect(() => { fetchUsers() }, [fetchUsers])
+ useEffect(() => { setPage(1) }, [search, statusFilter])
+
+ // ── Handlers with pref persistence ────────────────────────────────────────
+ function handleStatusFilter(value) {
+ setStatusFilter(value)
+ setPref('status_filter', value)
+ }
+
+ function handleViewMode(mode) {
+ setViewMode(mode)
+ setPref('view_mode', mode)
+ }
+
+ function handlePageSize(size) {
+ setPageSize(size)
+ setPage(1)
+ setPref('page_size', size)
+ }
+
+ // ── Delete ─────────────────────────────────────────────────────────────────
+ const handleDelete = async () => {
+ if (!deleteTarget) return
+ setDeleteError('')
+ try {
+ await api.delete(`/users/${deleteTarget.id}`)
+ setDeleteTarget(null)
+ fetchUsers()
+ } catch (err) {
+ setDeleteError(err.message || 'Failed to delete user.')
+ }
+ }
+
+ // ── Pagination ─────────────────────────────────────────────────────────────
+ const total = users.length
+ const pagedUsers = users.slice((page - 1) * pageSize, page * pageSize)
+
+ // ── Table columns ──────────────────────────────────────────────────────────
+ const tableColumns = ALL_COLUMNS.map((c) => ({
+ key: c.key,
+ label: c.label,
+ width: c.width,
+ render: (user) => {
+ switch (c.key) {
+ case 'status':
+ return (
+
+ {user.status ? user.status.charAt(0).toUpperCase() + user.status.slice(1) : 'Unknown'}
+
+ )
+ case 'name':
+ return (
+
+ {user.display_name || 'Unnamed User'}
+
+ )
+ case 'email':
+ return user.email || —
+ case 'phone':
+ return user.phone_number || —
+ case 'title':
+ return user.userTitle || —
+ case 'lastActive':
+ return user.lastActive
+ ? {user.lastActive}
+ : —
+ default:
+ return —
+ }
+ },
+ }))
+
+ // Actions column — appended, never draggable
+ tableColumns.push({
+ key: '__actions',
+ label: '',
+ width: '56px',
+ align: 'right',
+ render: (user) => (
+ e.stopPropagation()} style={{ display: 'flex', justifyContent: 'flex-end' }}>
+ ,
+ onClick: () => navigate(`/users/${user.id}`),
+ },
+ ...(canEdit ? [
+ {
+ label: 'Edit',
+ icon: ,
+ color: 'var(--color-info)',
+ onClick: () => navigate(`/users/${user.id}/edit`),
+ },
+ {
+ label: 'Delete',
+ icon: ,
+ color: 'var(--color-danger)',
+ divider: true,
+ onClick: () => { setDeleteError(''); setDeleteTarget(user) },
+ },
+ ] : []),
+ ]}
+ />
+
+ ),
+ })
+
+ // ─────────────────────────────────────────────────────────────────────────
+
+ return (
+
+
+ {/* Page header */}
+
+ {canEdit && (
+ navigate('/users/new')}>
+ Add User
+
+ )}
+
+
+ {/* Filter toolbar */}
+
+
+
+
+
+
handleStatusFilter(e.target.value)}
+ placeholder="All Status"
+ style={{ width: '140px', flexShrink: 0 }}
+ >
+ All Status
+ Active
+ Blocked
+
+
+ {/* View toggle — height matches input via .icon-btn-group__btn padding override */}
+
+
handleViewMode('table')}
+ icon={
+
+
+
+
+ }
+ />
+ handleViewMode('card')}
+ icon={
+
+
+
+
+
+
+ }
+ />
+
+
+
+ {/* Error banner */}
+ {error && (
+
+ {error}
+
+ )}
+
+ {/* Data table / Card view */}
+ {viewMode === 'card' ? (
+ <>
+
navigate(`/users/${user.id}`)}
+ onEdit={(user) => navigate(`/users/${user.id}/edit`)}
+ onDelete={(user) => { setDeleteError(''); setDeleteTarget(user) }}
+ onRefresh={silentRefreshUsers}
+ onMiddleClick={(user) => window.open(`/users/${user.id}`, '_blank', 'noopener,noreferrer')}
+ />
+ {total > 0 && (
+
+ )}
+ >
+ ) : (
+ navigate(`/users/${user.id}`)}
+ onRowMiddleClick={(user) => window.open(`/users/${user.id}`, '_blank', 'noopener,noreferrer')}
+ skeletonRows={8}
+ footer={
+ total > 0 && (
+
+ )
+ }
+ />
+ )}
+
+ {/* Delete confirmation modal */}
+ { setDeleteTarget(null); setDeleteError('') }}
+ error={deleteError}
+ />
+
+
+ )
+}
diff --git a/frontend/src/pages/bellcloud/users/UserListCardView.jsx b/frontend/src/pages/bellcloud/users/UserListCardView.jsx
new file mode 100644
index 0000000..a9b3ba8
--- /dev/null
+++ b/frontend/src/pages/bellcloud/users/UserListCardView.jsx
@@ -0,0 +1,1153 @@
+// frontend/src/pages/bellcloud/users/UserListCardView.jsx
+//
+// Card-based view of the App Users list.
+// All colours via design tokens only.
+
+import { useState, useRef, useEffect, useCallback } from 'react'
+import { createPortal } from 'react-dom'
+import { useNavigate } from 'react-router-dom'
+import Icon from '@/components/ui/Icon'
+import Spinner from '@/components/ui/Spinner'
+import StatusBadge from '@/components/ui/StatusBadge'
+import Modal from '@/components/ui/Modal'
+import api from '@/lib/api'
+
+// ─── Helpers ─────────────────────────────────────────────────────────────────
+
+function statusVariant(status) {
+ if (status === 'active') return 'success'
+ if (status === 'blocked') return 'danger'
+ return 'neutral'
+}
+
+// Maps variant → CSS color token for the watermark tint
+const VARIANT_COLOR = {
+ success: 'var(--color-success)',
+ danger: 'var(--color-danger)',
+ neutral: 'var(--color-text-muted)',
+}
+
+// Icon paths per status (inline SVG, 24x24 viewBox)
+function StatusWatermarkIcon({ status }) {
+ const variant = statusVariant(status)
+ const color = VARIANT_COLOR[variant] ?? VARIANT_COLOR.neutral
+
+ const paths = status === 'active' ? (
+ <>
+
+
+ >
+ ) : status === 'blocked' ? (
+ <>
+
+
+ >
+ ) : (
+ <>
+
+
+ >
+ )
+
+ return (
+
+ {paths}
+
+ )
+}
+
+function initials(name) {
+ if (!name) return '?'
+ const parts = name.trim().split(/\s+/)
+ if (parts.length === 1) return parts[0][0].toUpperCase()
+ return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase()
+}
+
+// ─── Phone SVG (not in Icon set) ─────────────────────────────────────────────
+
+function PhoneIcon({ size = 13 }) {
+ return (
+
+
+
+ )
+}
+
+// ─── Copy helper ─────────────────────────────────────────────────────────────
+
+function copyToClipboard(val) {
+ try {
+ navigator.clipboard.writeText(val)
+ } catch {
+ const el = document.createElement('textarea')
+ el.value = val
+ document.body.appendChild(el)
+ el.select()
+ document.execCommand('copy')
+ document.body.removeChild(el)
+ }
+}
+
+// ─── Copyable info cell (email / phone — with background + "Copied!" flash) ──
+
+function FieldTooltip({ value, icon, label, empty = '—' }) {
+ const [copied, setCopied] = useState(false)
+ const [visible, setVisible] = useState(false)
+ const [pos, setPos] = useState({ top: 0, left: 0 })
+ const cellRef = useRef(null)
+ const hideTimer = useRef(null)
+ const showTimer = useRef(null)
+
+ function show() {
+ clearTimeout(hideTimer.current)
+ clearTimeout(showTimer.current)
+ showTimer.current = setTimeout(() => {
+ if (cellRef.current) {
+ const r = cellRef.current.getBoundingClientRect()
+ setPos({ top: r.bottom + 6, left: r.left })
+ }
+ setVisible(true)
+ }, 500)
+ }
+ function hide() {
+ clearTimeout(showTimer.current)
+ hideTimer.current = setTimeout(() => setVisible(false), 80)
+ }
+
+ useEffect(() => {
+ if (!visible) return
+ function onScroll() { setVisible(false) }
+ document.addEventListener('scroll', onScroll, true)
+ return () => document.removeEventListener('scroll', onScroll, true)
+ }, [visible])
+
+ const hasValue = Boolean(value)
+
+ function handleClick(e) {
+ e.stopPropagation()
+ if (!hasValue) return
+ copyToClipboard(value)
+ setCopied(true)
+ setTimeout(() => setCopied(false), 1500)
+ }
+
+ return (
+ <>
+
+ {icon}
+
+ {hasValue ? value : {empty} }
+
+ {copied && Copied! }
+
+
+ {visible && hasValue && createPortal(
+ clearTimeout(hideTimer.current)}
+ onMouseLeave={hide}
+ >
+ {label}
+ {value}
+
,
+ document.body
+ )}
+ >
+ )
+}
+
+// ─── Copyable cell — click copies value ──────────────────────────────────────
+
+function CopyCell({ value, icon, label, empty = '—' }) {
+ const [copied, setCopied] = useState(false)
+
+ const hasValue = Boolean(value)
+
+ function handleClick(e) {
+ e.stopPropagation()
+ if (!hasValue) return
+ copyToClipboard(value)
+ setCopied(true)
+ setTimeout(() => setCopied(false), 1500)
+ }
+
+ return (
+
+ {icon}
+ {label}
+
+ {hasValue ? value : {empty} }
+
+ {copied && Copied! }
+
+ )
+}
+
+// ─── Stat cell — click opens mini modal ──────────────────────────────────────
+
+function StatCell({ icon, label, onClick }) {
+ return (
+ { e.stopPropagation(); onClick() }}
+ onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.stopPropagation(); onClick() } }}
+ >
+
{icon}
+
{label}
+ {/* Eye icon — "view list" */}
+
+
+
+
+
+ )
+}
+
+// ─── Status change dropdown ───────────────────────────────────────────────────
+
+function StatusDropdown({ userId, status, canEdit, onStatusChanged }) {
+ const [open, setOpen] = useState(false)
+ const [loading, setLoading] = useState(false)
+ const ref = useRef(null)
+
+ useEffect(() => {
+ if (!open) return
+ function onOutside(e) {
+ if (ref.current && !ref.current.contains(e.target)) setOpen(false)
+ }
+ document.addEventListener('mousedown', onOutside)
+ return () => document.removeEventListener('mousedown', onOutside)
+ }, [open])
+
+ async function changeStatus(newStatus) {
+ setOpen(false)
+ if (newStatus === status) return
+ setLoading(true)
+ try {
+ const endpoint = newStatus === 'blocked'
+ ? `/users/${userId}/block`
+ : `/users/${userId}/unblock`
+ await api.post(endpoint, {})
+ onStatusChanged?.()
+ } catch (err) {
+ console.error('Failed to change user status:', err)
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ function handleClick(e) {
+ e.stopPropagation()
+ if (!canEdit) return
+ setOpen((v) => !v)
+ }
+
+ const variant = statusVariant(status)
+ const label = status ? status.charAt(0).toUpperCase() + status.slice(1) : 'Unknown'
+
+ return (
+
+
+ {loading
+ ?
+ : {label}
+ }
+
+
+ {open && createPortal(
+
setOpen(false)}
+ />,
+ document.body
+ )}
+
+ )
+}
+
+function StatusDropdownMenu({ anchorRef, currentStatus, onSelect, onClose }) {
+ const [pos, setPos] = useState({ top: 0, left: 0 })
+
+ useEffect(() => {
+ if (anchorRef.current) {
+ const r = anchorRef.current.getBoundingClientRect()
+ setPos({ top: r.bottom + 4, left: r.right - 140 })
+ }
+ }, [anchorRef])
+
+ useEffect(() => {
+ function onScroll() { onClose() }
+ document.addEventListener('scroll', onScroll, true)
+ return () => document.removeEventListener('scroll', onScroll, true)
+ }, [onClose])
+
+ const options = [
+ { value: 'active', label: 'Active', variant: 'success' },
+ { value: 'blocked', label: 'Blocked', variant: 'danger' },
+ ]
+
+ return (
+ e.stopPropagation()}
+ >
+ {options.map((opt) => (
+
onSelect(opt.value)}
+ >
+ {opt.label}
+ {currentStatus === opt.value && (
+
+
+
+ )}
+
+ ))}
+
+ )
+}
+
+// ─── Linked Devices mini modal ────────────────────────────────────────────────
+
+function LinkedDevicesModal({ userId, userName, open, onClose }) {
+ const [devices, setDevices] = useState([])
+ const [loading, setLoading] = useState(false)
+ const navigate = useNavigate()
+
+ useEffect(() => {
+ if (!open || !userId) return
+ setLoading(true)
+ api.get(`/users/${userId}/devices`)
+ .then((d) => setDevices(Array.isArray(d) ? d : []))
+ .catch(() => setDevices([]))
+ .finally(() => setLoading(false))
+ }, [open, userId])
+
+ return (
+
+ {loading ? (
+
+
+
+ ) : devices.length === 0 ? (
+
+ No linked devices.
+
+ ) : (
+
+ {devices.map((d) => (
+
{ onClose(); navigate(`/devices/${d.id}`) }}
+ style={{
+ display: 'flex', alignItems: 'center', gap: 'var(--space-3)',
+ padding: 'var(--space-3)',
+ borderRadius: 'var(--radius-md)',
+ background: 'var(--color-bg-elevated)',
+ border: '1px solid var(--color-border)',
+ fontSize: 'var(--font-size-sm)',
+ cursor: 'pointer',
+ transition: 'background 150ms ease, border-color 150ms ease',
+ }}
+ onMouseEnter={e => {
+ e.currentTarget.style.background = 'var(--color-bg-hover)'
+ e.currentTarget.style.borderColor = 'var(--color-border-strong)'
+ }}
+ onMouseLeave={e => {
+ e.currentTarget.style.background = 'var(--color-bg-elevated)'
+ e.currentTarget.style.borderColor = 'var(--color-border)'
+ }}
+ >
+
+
+
+ {d.device_name || d.device_id || d.id}
+
+ {d.device_id && (
+
+ {d.device_id}
+
+ )}
+
+
+
+ ))}
+
+ )}
+
+ )
+}
+
+// ─── User Notes mini modal ────────────────────────────────────────────────────
+
+function UserNotesModal({ userId, userName, open, onClose }) {
+ const [notes, setNotes] = useState([])
+ const [loading, setLoading] = useState(false)
+
+ useEffect(() => {
+ if (!open || !userId) return
+ setLoading(true)
+ api.get(`/equipment/notes?user_id=${userId}`)
+ .then((d) => setNotes(d.notes ?? []))
+ .catch(() => setNotes([]))
+ .finally(() => setLoading(false))
+ }, [open, userId])
+
+ return (
+
+ {loading ? (
+
+
+
+ ) : notes.length === 0 ? (
+
+ No notes recorded.
+
+ ) : (
+
+ {notes.map((n) => (
+
+ {n.title && (
+
+ {n.title}
+
+ )}
+
+ {n.content}
+
+
+ ))}
+
+ )}
+
+ )
+}
+
+// ─── Single User Card ─────────────────────────────────────────────────────────
+
+function UserCard({ user, canEdit, onView, onEdit, onStatusChanged, onMiddleClick }) {
+ const name = user.display_name || 'Unnamed User'
+ const status = user.status || 'unknown'
+
+ const [devicesOpen, setDevicesOpen] = useState(false)
+ const [notesOpen, setNotesOpen] = useState(false)
+
+ const deviceCount = user.device_count ?? user.linked_device_count ?? null
+ const noteCount = user.note_count ?? null
+
+ return (
+ <>
+ { if (e.button === 1) { e.preventDefault(); onMiddleClick?.() } }}
+ >
+ {/* ── Background watermark icon ── */}
+
+
+ {/* ── Status badge: absolute top-right ── */}
+ e.stopPropagation()}>
+
+
+
+
+ {/* ── Header: avatar + identity ── */}
+
+
+ {/* ── Info section ── */}
+ e.stopPropagation()}>
+
+ {/* Row 1: Email + Phone (with background) */}
+
+ }
+ empty="No email"
+ />
+ }
+ empty="No phone"
+ />
+
+
+ {/* Row 2: UID (full width, transparent, copy on click) */}
+
+
+
+
+ }
+ empty="No UID"
+ />
+
+ {/* Row 3: Linked Devices + User Notes (no border, plain flat rows) */}
+
+
+
+
+
+
+ }
+ label="Linked Devices"
+ onClick={() => setDevicesOpen(true)}
+ />
+
+
+
+
+
+
+
+ }
+ label="User Notes"
+ onClick={() => setNotesOpen(true)}
+ />
+
+
+
+
+ {/* ── Last active ── */}
+ {user.lastActive && (
+
+
+ Last active: {user.lastActive}
+
+ )}
+
+
+ {/* ── Mini modals (rendered outside card to avoid stacking context issues) ── */}
+ setDevicesOpen(false)}
+ />
+ setNotesOpen(false)}
+ />
+ >
+ )
+}
+
+// ─── Skeleton card ────────────────────────────────────────────────────────────
+
+function SkeletonCard() {
+ return (
+
+ )
+}
+
+// ─── UserListCardView (exported) ─────────────────────────────────────────────
+
+export default function UserListCardView({ users, loading, canEdit, onView, onEdit, onDelete, onMiddleClick, onRefresh }) {
+
+ if (loading) {
+ return (
+ <>
+
+
+ {Array.from({ length: 8 }).map((_, i) => )}
+
+ >
+ )
+ }
+
+ if (!users.length) {
+ return (
+ <>
+
+
+
+
+
+
No users found
+
Try adjusting your search or filters.
+
+ >
+ )
+ }
+
+ return (
+ <>
+
+
+ {users.map((user) => (
+ onView(user)}
+ onEdit={() => onEdit(user)}
+ onDelete={() => onDelete(user)}
+ onStatusChanged={onRefresh}
+ onMiddleClick={() => onMiddleClick?.(user)}
+ />
+ ))}
+
+ >
+ )
+}
+
+// ─── Styles (scoped, injected via
+
+
+
+ , onClick: loadAll, loading: loading && !syncing },
+ { label: 'Sync emails', icon: , onClick: syncEmails, loading: syncing },
+ ]}
+ />
+
+
+ {/* Filter bar */}
+
+
+
+ setTypeFilter(e.target.value)} placeholder="All types">
+ All types
+ {COMMS_TYPES.map((t) => {TYPE_META[t]?.label || t} )}
+
+
+
+
+ setDirFilter(e.target.value)} placeholder="All directions">
+ All directions
+ {DIRECTIONS.map((d) => {DIR_META[d]?.label || d} )}
+
+
+
+ {/* Customer picker — styled exactly like Select trigger */}
+
setCustPickerOpen(true)}
+ className="input select-trigger"
+ style={{
+ minWidth: 148,
+ maxWidth: 200,
+ width: 180,
+ color: custFilter ? 'var(--color-text-accent)' : 'var(--color-text-muted)',
+ fontWeight: custFilter ? 'var(--font-weight-semibold)' : 'var(--font-weight-normal)',
+ }}
+ >
+
+ {selectedCustLabel}
+
+
+
+
+
+
+
+
+
+
+ {hasFilters && (
+
{ setTypeFilter(''); setDirFilter(''); setCustFilter(''); setSearch('') }}>
+ Clear filters
+
+ )}
+
+
+ {/* Error */}
+ {error && (
+
+ {error}
+
+ )}
+
+ {/* Content area: count/pagination + list, tightly grouped */}
+
+
+ {/* Count + pagination row */}
+ {!loading && !error && (
+
+
+ {filtered.length} entr{filtered.length !== 1 ? 'ies' : 'y'}{hasFilters ? ' (filtered)' : ''}
+
+
{ setPageSize(s); setPage(1) }}
+ pageSizes={PAGE_SIZE_OPTIONS}
+ />
+
+ )}
+
+ {/* Loading */}
+ {loading ? (
+
+
+ Loading communications…
+
+
+ ) : filtered.length === 0 ? (
+
+
+
+ {hasFilters ? 'No communications match your filters.' : 'No communications logged yet.'}
+
+ {hasFilters && (
+
{ setTypeFilter(''); setDirFilter(''); setCustFilter(''); setSearch('') }}>
+ Clear filters
+
+ )}
+
+
+ ) : (
+ /* Timeline */
+
+ {paged.map((entry, idx) => (
+
+ {/* Timeline column */}
+
+
+ {idx < paged.length - 1 && (
+
+ )}
+
+
+ {/* Card */}
+
+ setExpandedId((prev) => prev === id ? null : id)}
+ onDelete={(id) => setDeleteId(id)}
+ onEdit={(entry) => setEditEntry(entry)}
+ onView={(e) => setViewEntry(e)}
+ onReply={openReply}
+ />
+
+
+ ))}
+
+ )}
+
+
+
+
+
+ {/* Customer picker overlay */}
+ {custPickerOpen && (
+ setCustPickerOpen(false)}
+ >
+
e.stopPropagation()}
+ >
+
+ setCustSearch(e.target.value)}
+ placeholder="Search customer…"
+ className="input"
+ style={{ boxSizing: 'border-box' }}
+ />
+
+
+
{ setCustFilter(''); setCustPickerOpen(false) }}
+ >
+ All customers
+
+ {filteredCustOptions.map((c) => (
+
{ setCustFilter(c.id); setCustPickerOpen(false) }}
+ >
+
+ {c.name}{c.surname ? ` ${c.surname}` : ''}
+
+ {c.organization && (
+
+ {c.organization}
+
+ )}
+
+ ))}
+ {filteredCustOptions.length === 0 && custSearch && (
+
+ No customers found
+
+ )}
+
+
+
+ )}
+
+ {/* Confirm delete */}
+ setDeleteId(null)}
+ loading={deleting}
+ />
+
+ customers[e.customer_id] || null}
+ onClose={() => setViewEntry(null)}
+ onReply={openReply}
+ />
+
+ setComposeOpen(false)}
+ onSent={() => { setComposeOpen(false); loadAll() }}
+ />
+ setEditEntry(null)}
+ onSaved={loadAll}
+ />
+ >
+ )
+}
diff --git a/frontend/src/pages/crm/comms/helpdesk/HelpdeskPage.jsx b/frontend/src/pages/crm/comms/helpdesk/HelpdeskPage.jsx
new file mode 100644
index 0000000..387451a
--- /dev/null
+++ b/frontend/src/pages/crm/comms/helpdesk/HelpdeskPage.jsx
@@ -0,0 +1,53 @@
+// frontend/src/pages/crm/comms/helpdesk/HelpdeskPage.jsx
+// Helpdesk hub — Issues tab + Support Tickets tab
+
+import { useState } from 'react'
+import PageHeader from '@/components/ui/PageHeader'
+import Tabs from '@/components/ui/Tabs'
+import Button from '@/components/ui/Button'
+import { useAuth } from '@/hooks/useAuth'
+import IssuesTab from './IssuesTab'
+import TicketsTab from './TicketsTab'
+
+const TABS = [
+ { key: 'issues', label: 'Issues' },
+ { key: 'tickets', label: 'Support Tickets' },
+]
+
+export default function HelpdeskPage() {
+ const { hasPermission } = useAuth()
+ const canAdd = hasPermission('crm', 'add')
+
+ const [activeTab, setActiveTab] = useState('issues')
+
+ // Each counter increment signals the active tab to open its create modal
+ const [issuesTrigger, setIssuesTrigger] = useState(0)
+ const [ticketsTrigger, setTicketsTrigger] = useState(0)
+
+ return (
+
+
+ {canAdd && activeTab === 'issues' && (
+ setIssuesTrigger(n => n + 1)}>
+ + New Issue
+
+ )}
+ {canAdd && activeTab === 'tickets' && (
+ setTicketsTrigger(n => n + 1)}>
+ + Open New Ticket
+
+ )}
+
+
+
+
+ {activeTab === 'issues' &&
}
+ {activeTab === 'tickets' &&
}
+
+ )
+}
diff --git a/frontend/src/pages/crm/comms/helpdesk/IssuesTab.jsx b/frontend/src/pages/crm/comms/helpdesk/IssuesTab.jsx
new file mode 100644
index 0000000..21a21f5
--- /dev/null
+++ b/frontend/src/pages/crm/comms/helpdesk/IssuesTab.jsx
@@ -0,0 +1,608 @@
+// frontend/src/pages/crm/comms/helpdesk/IssuesTab.jsx
+// Global Issues list — all open/researching/resolved issues across all entities.
+
+import { useState, useEffect, useCallback } from 'react'
+import { Link } from 'react-router-dom'
+import api from '@/lib/api'
+import { useAuth } from '@/hooks/useAuth'
+import { useToast } from '@/components/ui/Toast'
+import usePreferences from '@/hooks/usePreferences'
+import Button from '@/components/ui/Button'
+import Spinner from '@/components/ui/Spinner'
+import SearchBar from '@/components/ui/SearchBar'
+import Select from '@/components/ui/Select'
+import Pagination from '@/components/ui/Pagination'
+import ConfirmDialog from '@/components/ui/ConfirmDialog'
+import EntryFormModal from '@/modals/crm/helpdesk/EntryFormModal'
+import { fmtDateMedium, fmtRelative } from '@/lib/formatters'
+
+// ─── Constants ────────────────────────────────────────────────────────────────
+
+const STATUS_META = {
+ open: { label: 'Open', color: 'var(--color-danger)', bg: 'var(--color-danger-bg)', dot: 'var(--color-danger)' },
+ researching: { label: 'Researching', color: 'var(--color-warning)', bg: 'var(--color-warning-bg)', dot: 'var(--color-warning)' },
+ resolved: { label: 'Resolved', color: 'var(--color-success)', bg: 'var(--color-success-bg)', dot: 'var(--color-success)' },
+}
+
+const SEVERITY_META = {
+ low: { label: 'Low', color: 'var(--color-info)', bars: 1 },
+ medium: { label: 'Medium', color: 'var(--color-warning)', bars: 2 },
+ high: { label: 'High', color: 'var(--color-danger)', bars: 3 },
+ critical: { label: 'Critical', color: 'var(--color-danger)', bars: 4, breathe: true },
+}
+
+const CATEGORY_META = {
+ technical: { label: 'Technical Issue' },
+ install_support: { label: 'Install Support' },
+ general: { label: 'General' },
+}
+
+const ENTITY_LINKS = {
+ device: { label: 'Device', href: (id) => `/devices/${id}`, bg: 'rgba(186,230,253,0.10)', border: 'rgba(186,230,253,0.20)', color: 'rgb(186,230,253)' },
+ app_user: { label: 'User', href: (id) => `/users/${id}`, bg: 'rgba(147,197,253,0.10)', border: 'rgba(147,197,253,0.20)', color: 'rgb(147,197,253)' },
+ customer: { label: 'Customer', href: (id) => `/crm/customers/${id}`, bg: 'rgba(96,165,250,0.12)', border: 'rgba(96,165,250,0.25)', color: 'rgb(96,165,250)' },
+}
+
+// ─── Sub-components ───────────────────────────────────────────────────────────
+
+function StatusPill({ status }) {
+ const m = STATUS_META[status] || { label: status, color: 'var(--color-text-muted)', bg: 'var(--color-bg-elevated)', dot: 'var(--color-text-muted)' }
+ return (
+
+
+ {m.label}
+
+ )
+}
+
+function SeverityBars({ severity }) {
+ if (!severity) return —
+ const m = SEVERITY_META[severity] || { label: severity, color: 'var(--color-text-muted)', bars: 1 }
+ return (
+
+ {/* 4-bar signal icon — filled bars match severity */}
+
+ {[1, 2, 3, 4].map(b => (
+
+ ))}
+
+
+ {m.label}
+
+
+ )
+}
+
+const LINKED_TO_CAP = 3
+
+function EntityChips({ links, nameMap }) {
+ const [overflowOpen, setOverflowOpen] = useState(false)
+
+ if (!links || links.length === 0) return —
+
+ const visible = links.slice(0, LINKED_TO_CAP)
+ const overflow = links.slice(LINKED_TO_CAP)
+
+ const renderChip = (l) => {
+ const meta = ENTITY_LINKS[l.entity_type]
+ if (!meta) return null
+ const name = nameMap?.[l.entity_id] || l.entity_id.slice(0, 8) + '…'
+ return (
+ e.stopPropagation()}
+ style={{
+ display: 'inline-flex', alignItems: 'center', gap: 4,
+ padding: '2px 8px',
+ borderRadius: 'var(--radius-sm)',
+ backgroundColor: meta.bg,
+ border: `1px solid ${meta.border}`,
+ fontSize: 'var(--font-size-xs)',
+ color: meta.color,
+ fontWeight: 'var(--font-weight-medium)',
+ textDecoration: 'none',
+ whiteSpace: 'nowrap',
+ alignSelf: 'flex-start',
+ }}
+ >
+ {name}
+
+ )
+ }
+
+ return (
+ <>
+
+ {visible.map(renderChip)}
+ {overflow.length > 0 && (
+ { e.stopPropagation(); setOverflowOpen(true) }}
+ style={{
+ display: 'inline-flex', alignItems: 'center',
+ padding: '2px 8px',
+ borderRadius: 'var(--radius-sm)',
+ backgroundColor: 'var(--color-bg-island)',
+ border: '1px solid var(--color-border-strong)',
+ fontSize: 'var(--font-size-xs)',
+ color: 'var(--color-text-secondary)',
+ fontWeight: 'var(--font-weight-medium)',
+ cursor: 'pointer',
+ whiteSpace: 'nowrap',
+ alignSelf: 'flex-start',
+ fontFamily: 'var(--font-family-base)',
+ transition: 'background 0.1s, color 0.1s',
+ }}
+ onMouseEnter={e => { e.currentTarget.style.backgroundColor = 'var(--color-bg-elevated)'; e.currentTarget.style.color = 'var(--color-text-primary)' }}
+ onMouseLeave={e => { e.currentTarget.style.backgroundColor = 'var(--color-bg-island)'; e.currentTarget.style.color = 'var(--color-text-secondary)' }}
+ >
+ +{overflow.length + 0} more
+
+ )}
+
+
+ {/* Overflow mini-modal */}
+ {overflowOpen && (
+ e.stopPropagation()}
+ style={{
+ position: 'fixed', inset: 0, zIndex: 9999,
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
+ backgroundColor: 'rgba(10,14,20,0.65)',
+ backdropFilter: 'blur(4px)',
+ WebkitBackdropFilter: 'blur(4px)',
+ }}
+ onMouseDown={e => { if (e.target === e.currentTarget) setOverflowOpen(false) }}
+ >
+
+
+
+ {links.length} linked entities
+
+ setOverflowOpen(false)}
+ style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--color-text-muted)', lineHeight: 1, padding: 4 }}
+ >
+
+
+
+
+
+
+ {links.map(renderChip)}
+
+
+
+ )}
+ >
+ )
+}
+
+// ─── IssuesTab ────────────────────────────────────────────────────────────────
+
+const PAGE_SIZE_OPTIONS = [10, 25, 50]
+
+// gridTemplateColumns shared by header and every row
+// "Researching" pill + padding ≈ 126px → 130px. Longest chip ≈ 130px → 140px.
+const GRID_COLS = '130px 1fr 140px 120px 180px 120px 110px'
+
+export default function IssuesTab({ onOpenCreate }) {
+ const { hasPermission } = useAuth()
+ const { toast } = useToast()
+ const canEdit = hasPermission('crm', 'edit')
+ const canAdd = hasPermission('crm', 'add')
+ const canDelete = hasPermission('crm', 'delete')
+
+ const { prefs, setPref, setPrefBatch, ready } = usePreferences('helpdesk_issues')
+
+ const [issues, setIssues] = useState([])
+ const [total, setTotal] = useState(0)
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState('')
+
+ // entity ID → display name lookup for the Linked To column
+ const [nameMap, setNameMap] = useState({})
+
+ const [search, setSearch] = useState('')
+ const [status, setStatus] = useState('')
+ const [severity, setSeverity] = useState('')
+ const [category, setCategory] = useState('')
+
+ const [page, setPage] = useState(1)
+ const [pageSize, setPageSize] = useState(25)
+
+ const [prefsLoaded, setPrefsLoaded] = useState(false)
+
+ const [formOpen, setFormOpen] = useState(false)
+ const [editEntry, setEditEntry] = useState(null)
+ const [deleteId, setDeleteId] = useState(null)
+ const [deleting, setDeleting] = useState(false)
+
+ // When parent increments openTrigger, open the create form
+ useEffect(() => {
+ if (onOpenCreate && onOpenCreate > 0) openCreate()
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [onOpenCreate])
+
+ // ── Load preferences once ready ──
+ useEffect(() => {
+ if (!ready || prefsLoaded) return
+ if (prefs.status !== undefined) setStatus(prefs.status)
+ if (prefs.severity !== undefined) setSeverity(prefs.severity)
+ if (prefs.category !== undefined) setCategory(prefs.category)
+ if (prefs.page_size !== undefined) setPageSize(prefs.page_size)
+ setPrefsLoaded(true)
+ }, [ready, prefs, prefsLoaded])
+
+ // ── Persist filter changes ──
+ useEffect(() => {
+ if (!prefsLoaded) return
+ setPrefBatch({ status, severity, category, page_size: pageSize })
+ }, [status, severity, category, pageSize]) // eslint-disable-line react-hooks/exhaustive-deps
+
+ // ── Fetch entity display names for all links in the current page ──
+ const resolveNames = useCallback(async (rows) => {
+ // Collect unique IDs by type
+ const byType = { device: new Set(), app_user: new Set(), customer: new Set() }
+ for (const issue of rows) {
+ for (const l of issue.links || []) {
+ if (byType[l.entity_type]) byType[l.entity_type].add(l.entity_id)
+ }
+ }
+
+ const map = {}
+ await Promise.all([
+ // Devices — fetch individually (no bulk ID endpoint)
+ ...[...byType.device].map(async (id) => {
+ try {
+ const d = await api.get(`/devices/${id}`)
+ map[id] = d.device_name || id
+ } catch { map[id] = id }
+ }),
+ // App users
+ ...[...byType.app_user].map(async (id) => {
+ try {
+ const d = await api.get(`/users/${id}`)
+ map[id] = d.display_name || d.email || id
+ } catch { map[id] = id }
+ }),
+ // Customers
+ ...[...byType.customer].map(async (id) => {
+ try {
+ const d = await api.get(`/crm/customers/${id}`)
+ const name = [d.name, d.surname].filter(Boolean).join(' ') || d.organization
+ map[id] = name || id
+ } catch { map[id] = id }
+ }),
+ ])
+ setNameMap(prev => ({ ...prev, ...map }))
+ }, [])
+
+ // ── Fetch ──
+ const load = useCallback(async () => {
+ setLoading(true)
+ setError('')
+ try {
+ const params = new URLSearchParams({ type: 'issue', page, limit: pageSize })
+ if (status) params.set('status', status)
+ if (severity) params.set('severity', severity)
+ if (category) params.set('category', category)
+ const data = await api.get(`/notes?${params}`)
+ const rows = data.data || []
+ setIssues(rows)
+ setTotal(data.pagination?.total ?? 0)
+ resolveNames(rows)
+ } catch (err) {
+ setError(err.message || 'Failed to load issues')
+ } finally {
+ setLoading(false)
+ }
+ }, [page, pageSize, status, severity, category, resolveNames])
+
+ useEffect(() => { load() }, [load])
+ useEffect(() => { setPage(1) }, [status, severity, category, search])
+
+ // ── Client-side search filter (title + author) ──
+ const q = search.trim().toLowerCase()
+ const displayed = q
+ ? issues.filter(i =>
+ i.title.toLowerCase().includes(q) ||
+ (i.author_name || '').toLowerCase().includes(q) ||
+ (i.body || '').toLowerCase().includes(q)
+ )
+ : issues
+
+ // ── Delete ──
+ const handleDelete = useCallback(async () => {
+ if (!deleteId) return
+ setDeleting(true)
+ try {
+ await api.delete(`/notes/${deleteId}`)
+ toast.success('Deleted', 'Issue removed.')
+ setDeleteId(null)
+ load()
+ } catch (err) {
+ toast.danger('Delete failed', err.message)
+ } finally {
+ setDeleting(false)
+ }
+ }, [deleteId, toast, load])
+
+ const openCreate = () => { setEditEntry(null); setFormOpen(true) }
+ const openEdit = (e) => { setEditEntry(e); setFormOpen(true) }
+
+ const hasFilters = status || severity || category || search
+
+ return (
+ <>
+
+
+ {/* Toolbar — single row, SearchBar expands */}
+
+
+
+
+ setStatus(e.target.value)} placeholder="All statuses">
+ All statuses
+ {Object.entries(STATUS_META).map(([k, v]) => (
+ {v.label}
+ ))}
+
+
+
+
+ setSeverity(e.target.value)} placeholder="All severities">
+ All severities
+ {Object.entries(SEVERITY_META).map(([k, v]) => (
+ {v.label}
+ ))}
+
+
+
+
+ setCategory(e.target.value)} placeholder="All categories">
+ All categories
+ {Object.entries(CATEGORY_META).map(([k, v]) => (
+ {v.label}
+ ))}
+
+
+
+ {hasFilters && (
+
{ setStatus(''); setSeverity(''); setCategory(''); setSearch('') }}>
+ Clear
+
+ )}
+
+
+ {/* Error */}
+ {error && (
+
+ {error}
+
+ )}
+
+ {/* Table */}
+
+ {/* Table header */}
+
+ {['Status', 'Issue', 'Category', 'Severity', 'Linked To', 'Reported By', 'Date'].map(h => (
+
+ {h}
+
+ ))}
+
+
+ {/* Body */}
+ {loading ? (
+
+
+ Loading issues…
+
+ ) : displayed.length === 0 ? (
+
+
+
+
+
+ {hasFilters ? 'No issues match your filters.' : 'No issues recorded yet.'}
+
+ {canAdd && !hasFilters && (
+
Record first issue
+ )}
+
+ ) : (
+ displayed.map((issue, idx) => (
+
canEdit && openEdit(issue)}
+ >
+ {/* Status */}
+
+
+
+
+ {/* Title + body preview */}
+
+
+ {issue.title}
+
+ {issue.body && (
+
+ {issue.body.replace(/<[^>]*>/g, '').slice(0, 100)}
+
+ )}
+
+
+ {/* Category */}
+
+ {CATEGORY_META[issue.category]?.label || '—'}
+
+
+ {/* Severity */}
+
+
+
+
+ {/* Linked entities */}
+
+
+
+
+ {/* Author */}
+
+ {issue.author_name || '—'}
+
+
+ {/* Date */}
+
+
+ {fmtRelative(issue.created_at)}
+
+
+
+ ))
+ )}
+
+ {/* Footer: count + pagination */}
+ {!loading && displayed.length > 0 && (
+
+
+ {total} issue{total !== 1 ? 's' : ''}{hasFilters ? ' (filtered)' : ''}
+
+
{ setPageSize(s); setPage(1) }}
+ pageSizes={PAGE_SIZE_OPTIONS}
+ />
+
+ )}
+
+
+ {/* Modals */}
+ { setFormOpen(false); setEditEntry(null) }}
+ onSaved={() => { setFormOpen(false); setEditEntry(null); load() }}
+ onDelete={canDelete ? (id) => { setFormOpen(false); setDeleteId(id) } : null}
+ />
+
+ setDeleteId(null)}
+ loading={deleting}
+ />
+ >
+ )
+}
diff --git a/frontend/src/pages/crm/comms/helpdesk/TicketsTab.jsx b/frontend/src/pages/crm/comms/helpdesk/TicketsTab.jsx
new file mode 100644
index 0000000..7819af9
--- /dev/null
+++ b/frontend/src/pages/crm/comms/helpdesk/TicketsTab.jsx
@@ -0,0 +1,756 @@
+// frontend/src/pages/crm/comms/helpdesk/TicketsTab.jsx
+// Support Tickets — sidebar list + conversation thread view
+
+import { useState, useEffect, useCallback, useRef } from 'react'
+import api from '@/lib/api'
+import { useAuth } from '@/hooks/useAuth'
+import { useToast } from '@/components/ui/Toast'
+import usePreferences from '@/hooks/usePreferences'
+import Button from '@/components/ui/Button'
+import Spinner from '@/components/ui/Spinner'
+import SearchBar from '@/components/ui/SearchBar'
+import Select from '@/components/ui/Select'
+import ConfirmDialog from '@/components/ui/ConfirmDialog'
+import CreateTicketModal from '@/modals/crm/helpdesk/CreateTicketModal'
+import { fmtRelative, fmtDateTime } from '@/lib/formatters'
+
+// ─── Constants ────────────────────────────────────────────────────────────────
+
+const TICKET_STATUS_META = {
+ open: { label: 'Open', color: 'var(--color-danger)', bg: 'var(--color-danger-bg)' },
+ waiting_on_customer: { label: 'Waiting on Customer', color: 'var(--color-warning)', bg: 'var(--color-warning-bg)' },
+ waiting_on_staff: { label: 'Waiting on Staff', color: 'var(--color-info)', bg: 'var(--color-info-bg)' },
+ resolved: { label: 'Resolved', color: 'var(--color-success)', bg: 'var(--color-success-bg)' },
+ closed: { label: 'Closed', color: 'var(--color-text-muted)', bg: 'var(--color-bg-elevated)' },
+}
+
+const PRIORITY_META = {
+ low: { label: 'Low', color: 'var(--color-info)' },
+ medium: { label: 'Medium', color: 'var(--color-warning)' },
+ high: { label: 'High', color: 'var(--color-danger)' },
+ urgent: { label: 'Urgent', color: 'var(--color-danger)' },
+}
+
+// ─── Helpers ──────────────────────────────────────────────────────────────────
+
+function TicketStatusPill({ status, small }) {
+ const m = TICKET_STATUS_META[status] || { label: status, color: 'var(--color-text-muted)', bg: 'var(--color-bg-elevated)' }
+ return (
+
+
+ {m.label}
+
+ )
+}
+
+function PriorityPill({ priority }) {
+ if (!priority) return null
+ const m = PRIORITY_META[priority] || { label: priority, color: 'var(--color-text-muted)' }
+ return (
+
+ {m.label}
+
+ )
+}
+
+// ─── Ticket list item ──────────────────────────────────────────────────────────
+
+function TicketItem({ ticket, active, onClick }) {
+ const isActive = active
+ return (
+ { if (!isActive) e.currentTarget.style.backgroundColor = 'rgba(49,53,60,0.25)' }}
+ onMouseLeave={e => { if (!isActive) e.currentTarget.style.backgroundColor = 'transparent' }}
+ >
+
+
+ #{String(ticket.id).slice(0, 6).toUpperCase()}
+
+
+ {fmtRelative(ticket.updated_at)}
+
+
+
+ {ticket.subject}
+
+
+
+ {ticket.priority &&
}
+
+
+ )
+}
+
+// ─── Message bubble ───────────────────────────────────────────────────────────
+
+function MessageBubble({ msg }) {
+ const fromStaff = msg.sender_type === 'staff'
+ const isInternal = msg.is_internal
+
+ return (
+
+ {/* Avatar */}
+
+ {(msg.sender_name || '?').slice(0, 1).toUpperCase()}
+
+
+ {/* Content */}
+
+
+
+ {msg.sender_name || (fromStaff ? 'Staff' : 'Customer')}
+
+
+ {fmtDateTime(msg.created_at)}
+
+ {isInternal && (
+
+ Internal
+
+ )}
+
+
+
+
+
+ )
+}
+
+// ─── Reply composer ───────────────────────────────────────────────────────────
+
+// SVG icon helper — keeps toolbar buttons crisp
+function ToolbarIcon({ d, viewBox = '0 0 24 24', size = 14, strokeWidth = 2 }) {
+ return (
+
+ {Array.isArray(d)
+ ? d.map((path, i) => )
+ :
+ }
+
+ )
+}
+
+function ReplyComposer({ ticketId, onSent, currentUser }) {
+ const toast = useToast()
+ const [body, setBody] = useState('')
+ const [isInternal, setIsInternal] = useState(false)
+ const [sending, setSending] = useState(false)
+
+ const textareaRef = useRef(null)
+
+ const insertMarkdown = (before, after = '') => {
+ const ta = textareaRef.current
+ if (!ta) return
+ const start = ta.selectionStart
+ const end = ta.selectionEnd
+ const sel = body.slice(start, end)
+ const newBody = body.slice(0, start) + before + sel + after + body.slice(end)
+ setBody(newBody)
+ setTimeout(() => {
+ ta.focus()
+ ta.setSelectionRange(start + before.length, start + before.length + sel.length)
+ }, 0)
+ }
+
+ const send = async () => {
+ if (!body.trim()) return
+ setSending(true)
+ try {
+ await api.post(`/tickets/${ticketId}/messages`, {
+ sender_type: 'staff',
+ sender_id: currentUser?.uid || currentUser?.id || 'staff',
+ sender_name: currentUser?.displayName || currentUser?.name || 'Staff',
+ body: body.trim(),
+ is_internal: isInternal,
+ })
+ setBody('')
+ setIsInternal(false)
+ onSent()
+ } catch (err) {
+ toast.danger('Send failed', err.message)
+ } finally {
+ setSending(false)
+ }
+ }
+
+ // Proper SVG-based toolbar buttons
+ const TOOLBAR_BTNS = [
+ {
+ title: 'Bold',
+ action: () => insertMarkdown('**', '**'),
+ icon: (
+ // Bold — stroke-based B letterform
+
+
+
+
+ ),
+ },
+ {
+ title: 'Italic',
+ action: () => insertMarkdown('_', '_'),
+ icon: (
+ // Italic — classic slanted I
+
+
+
+
+
+ ),
+ },
+ {
+ title: 'Inline code',
+ action: () => insertMarkdown('`', '`'),
+ icon: (
+ // Code — angle brackets
+
+
+
+
+ ),
+ },
+ {
+ title: 'Code block',
+ action: () => insertMarkdown('```\n', '\n```'),
+ icon: (
+ // Code block — brackets + underline bar
+
+
+
+
+
+ ),
+ },
+ {
+ title: 'Bullet list',
+ action: () => insertMarkdown('\n- '),
+ icon: (
+ // Unordered list
+
+
+
+
+
+
+
+
+ ),
+ },
+ {
+ title: 'Numbered list',
+ action: () => insertMarkdown('\n1. '),
+ icon: (
+ // Ordered list — "1 2 3" on left, lines on right
+
+
+
+
+
+
+
+
+
+ ),
+ },
+ {
+ title: 'Link',
+ action: () => insertMarkdown('[', '](url)'),
+ icon: (
+ // Link — chain icon
+
+
+
+
+ ),
+ },
+ ]
+
+ return (
+
+ {/* Toolbar */}
+
+ {TOOLBAR_BTNS.map(btn => (
+
{ e.currentTarget.style.backgroundColor = 'var(--color-bg-island)'; e.currentTarget.style.color = 'var(--color-text-primary)' }}
+ onMouseLeave={e => { e.currentTarget.style.backgroundColor = 'transparent'; e.currentTarget.style.color = 'var(--color-text-muted)' }}
+ >
+ {btn.icon}
+
+ ))}
+
+ {/* File attach — visual only for now */}
+
{ e.currentTarget.style.backgroundColor = 'var(--color-bg-island)'; e.currentTarget.style.color = 'var(--color-text-primary)' }}
+ onMouseLeave={e => { e.currentTarget.style.backgroundColor = 'transparent'; e.currentTarget.style.color = 'var(--color-text-muted)' }}
+ >
+ {/* Paperclip */}
+
+
+
+
+
+
+ {/* Textarea */}
+
+ )
+}
+
+// ─── Thread view ──────────────────────────────────────────────────────────────
+
+function TicketThread({ ticket, onReload, currentUser }) {
+ const toast = useToast()
+ const [updating, setUpdating] = useState(false)
+ const messagesEndRef = useRef(null)
+
+ useEffect(() => {
+ messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
+ }, [ticket?.messages?.length])
+
+ const changeStatus = async (newStatus) => {
+ setUpdating(true)
+ try {
+ await api.patch(`/tickets/${ticket.id}`, { status: newStatus })
+ onReload()
+ } catch (err) {
+ toast.danger('Update failed', err.message)
+ } finally {
+ setUpdating(false)
+ }
+ }
+
+ if (!ticket) return (
+
+
+
+
+
Select a ticket to view the conversation
+
+ )
+
+ const messages = ticket.messages || []
+
+ return (
+
+ {/* Thread header */}
+
+
+
+ {ticket.subject}
+
+
+ {ticket.customer_name || 'Unknown customer'}
+ {ticket.device_serial ? ` · Device ${ticket.device_serial}` : ''}
+ {' · '}{messages.length} message{messages.length !== 1 ? 's' : ''}
+
+
+
+
+
+ {ticket.priority &&
}
+
+ {ticket.status !== 'resolved' && (
+
changeStatus('resolved')} loading={updating}>
+ Mark Resolved
+
+ )}
+ {ticket.status !== 'closed' && ticket.status === 'resolved' && (
+
changeStatus('closed')} loading={updating}>
+ Close
+
+ )}
+ {ticket.status === 'closed' && (
+
changeStatus('open')} loading={updating}>
+ Reopen
+
+ )}
+
+
+
+ {/* Messages */}
+
+ {messages.length === 0 ? (
+
+ No messages yet. Send the first reply below.
+
+ ) : (
+ messages.map(msg => (
+
+ ))
+ )}
+
+
+
+ {/* Reply composer */}
+
+
+
+
+ )
+}
+
+// ─── TicketsTab ────────────────────────────────────────────────────────────────
+
+export default function TicketsTab({ onOpenCreate }) {
+ const { user } = useAuth()
+ const toast = useToast()
+
+ const { prefs, setPref, setPrefBatch, ready } = usePreferences('helpdesk_tickets')
+
+ const [tickets, setTickets] = useState([])
+ const [selected, setSelected] = useState(null)
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState('')
+ const [search, setSearch] = useState('')
+ const [statusFilter, setStatusFilter] = useState('open')
+ const [createOpen, setCreateOpen] = useState(false)
+
+ // When parent increments onOpenCreate, open the create modal
+ useEffect(() => {
+ if (onOpenCreate && onOpenCreate > 0) setCreateOpen(true)
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [onOpenCreate])
+
+ const [prefsLoaded, setPrefsLoaded] = useState(false)
+
+ // ── Load preferences once ready ──
+ useEffect(() => {
+ if (!ready || prefsLoaded) return
+ if (prefs.status_filter !== undefined) setStatusFilter(prefs.status_filter)
+ setPrefsLoaded(true)
+ }, [ready, prefs, prefsLoaded])
+
+ // ── Persist filter changes ──
+ useEffect(() => {
+ if (!prefsLoaded) return
+ setPref('status_filter', statusFilter)
+ }, [statusFilter]) // eslint-disable-line react-hooks/exhaustive-deps
+
+ const loadTickets = useCallback(async () => {
+ setLoading(true)
+ setError('')
+ try {
+ const params = new URLSearchParams({ limit: 100 })
+ if (statusFilter) params.set('status', statusFilter)
+ const data = await api.get(`/tickets?${params}`)
+ setTickets(data.data || [])
+ } catch (err) {
+ setError(err.message || 'Failed to load tickets')
+ } finally {
+ setLoading(false)
+ }
+ }, [statusFilter])
+
+ useEffect(() => { loadTickets() }, [loadTickets])
+
+ const reloadSelected = useCallback(async () => {
+ await loadTickets()
+ if (selected) {
+ try {
+ const fresh = await api.get(`/tickets/${selected.id}`)
+ setSelected(fresh)
+ } catch { /* silent */ }
+ }
+ }, [loadTickets, selected])
+
+ const selectTicket = async (t) => {
+ try {
+ const full = await api.get(`/tickets/${t.id}`)
+ setSelected(full)
+ } catch (err) {
+ toast.danger('Failed to load ticket', err.message)
+ }
+ }
+
+ const q = search.trim().toLowerCase()
+ const filtered = q
+ ? tickets.filter(t =>
+ t.subject.toLowerCase().includes(q) ||
+ (t.customer_name || '').toLowerCase().includes(q) ||
+ (t.device_serial || '').toLowerCase().includes(q)
+ )
+ : tickets
+
+ return (
+ <>
+
+
+
+ {/* ── Sidebar ── */}
+
+ {/* Sidebar header */}
+
+
+
+ setStatusFilter(e.target.value)}>
+ All statuses
+ {Object.entries(TICKET_STATUS_META).map(([k, v]) => (
+ {v.label}
+ ))}
+
+
+
+
+ {/* Ticket list */}
+
+ {loading ? (
+
+
+
+ ) : error ? (
+
{error}
+ ) : filtered.length === 0 ? (
+
+ {search ? 'No tickets match your search.' : 'No tickets found.'}
+
+ ) : (
+ filtered.map(t => (
+
selectTicket(t)}
+ />
+ ))
+ )}
+
+
+ {/* Sidebar footer */}
+ {!loading && (
+
+ {filtered.length} ticket{filtered.length !== 1 ? 's' : ''}
+
+ )}
+
+
+ {/* ── Thread view ── */}
+
+
+
+ setCreateOpen(false)}
+ onCreated={(ticket) => { setCreateOpen(false); loadTickets(); selectTicket(ticket) }}
+ />
+ >
+ )
+}
diff --git a/frontend/src/pages/crm/comms/mail/MailPage.jsx b/frontend/src/pages/crm/comms/mail/MailPage.jsx
new file mode 100644
index 0000000..8670117
--- /dev/null
+++ b/frontend/src/pages/crm/comms/mail/MailPage.jsx
@@ -0,0 +1,726 @@
+// frontend/src/pages/crm/mail/MailPage.jsx
+
+import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
+import { Link } from 'react-router-dom'
+import api from '@/lib/api'
+import PageHeader from '@/components/ui/PageHeader'
+import Button from '@/components/ui/Button'
+import Spinner from '@/components/ui/Spinner'
+import SearchBar from '@/components/ui/SearchBar'
+import SegmentedControl from '@/components/ui/SegmentedControl'
+import IconButtonGroup from '@/components/ui/IconButtonGroup'
+import { useToast } from '@/components/ui/Toast'
+import ComposeEmailModal from '@/modals/crm/ComposeEmailModal'
+import MailViewModal from '@/modals/crm/MailViewModal'
+import MailSettingsModal from '@/modals/crm/MailSettingsModal'
+import { fmtRelative, fmtDateTimeFull } from '@/lib/formatters'
+
+// ─── helpers ────────────────────────────────────────────────────────────────
+
+const DEFAULT_POLL_INTERVAL = 30
+
+function getPollInterval() {
+ const v = parseInt(localStorage.getItem('mail_poll_interval'), 10)
+ return !isNaN(v) && v >= 15 && v <= 300 ? v : DEFAULT_POLL_INTERVAL
+}
+
+function relativeTime(dateStr) { return fmtRelative(dateStr) }
+function fullDate(dateStr) { return fmtDateTimeFull(dateStr) }
+
+// ─── SVG icons ───────────────────────────────────────────────────────────────
+
+const IconRefresh = () => (
+
+
+
+
+)
+
+const IconSettings = () => (
+
+
+
+
+)
+
+const IconDownload = () => (
+
+
+
+
+
+)
+
+function BookmarkIcon({ active }) {
+ return (
+
+
+
+ )
+}
+
+// ─── filter option definitions (with color tokens) ───────────────────────────
+
+const DIR_OPTIONS = (unreadCount) => [
+ {
+ value: 'inbound',
+ label: unreadCount > 0 ? `Inbox (${unreadCount})` : 'Inbox',
+ activeBg: 'var(--mail-filter-inbox-bg)',
+ activeText: 'var(--mail-filter-inbox-text)',
+ },
+ {
+ value: 'outbound',
+ label: 'Sent',
+ activeBg: 'var(--mail-filter-sent-bg)',
+ activeText: 'var(--mail-filter-sent-text)',
+ },
+]
+
+const CLIENT_OPTIONS = [
+ {
+ value: 'all',
+ label: 'All Messages',
+ activeBg: 'var(--mail-filter-all-bg)',
+ activeText: 'var(--mail-filter-all-text)',
+ },
+ {
+ value: 'clients',
+ label: 'Clients Only',
+ activeBg: 'var(--mail-filter-clients-bg)',
+ activeText: 'var(--mail-filter-clients-text)',
+ },
+]
+
+const MAILBOX_OPTIONS = [
+ {
+ value: 'sales',
+ label: 'Sales',
+ activeBg: 'var(--mail-filter-sales-bg)',
+ activeText: 'var(--mail-filter-sales-text)',
+ },
+ {
+ value: 'support',
+ label: 'Support',
+ activeBg: 'var(--mail-filter-support-bg)',
+ activeText: 'var(--mail-filter-support-text)',
+ },
+ {
+ value: 'both',
+ label: 'All',
+ activeBg: 'var(--mail-filter-both-bg)',
+ activeText: 'var(--mail-filter-both-text)',
+ },
+]
+
+const READ_OPTIONS = [
+ {
+ value: 'all',
+ label: 'All',
+ activeBg: 'var(--mail-filter-all-bg)',
+ activeText: 'var(--mail-filter-all-text)',
+ },
+ {
+ value: 'unread',
+ label: 'Unread',
+ activeBg: 'var(--mail-filter-unread-bg)',
+ activeText: 'var(--mail-filter-unread-text)',
+ },
+ {
+ value: 'read',
+ label: 'Read',
+ activeBg: 'var(--mail-filter-read-bg)',
+ activeText: 'var(--mail-filter-read-text)',
+ },
+ {
+ value: 'important',
+ label: 'Bookmarked',
+ activeBg: 'var(--mail-filter-bookmarked-bg)',
+ activeText: 'var(--mail-filter-bookmarked-text)',
+ },
+]
+
+// ─── MailRow ─────────────────────────────────────────────────────────────────
+
+function MailRow({ entry, customer, selected, onSelect, onClick, onBookmark }) {
+ const isUnread = entry.direction === 'inbound' && !entry.is_read
+ const isSelected = selected
+
+ return (
+ onClick(entry)}
+ className="mail-row"
+ style={{
+ display: 'grid',
+ gridTemplateColumns: '20px 32px 220px 1fr 8px 140px',
+ alignItems: 'center',
+ gap: 'var(--space-3)',
+ padding: 'var(--space-4) var(--space-5)',
+ borderBottom: '1px solid var(--color-border)',
+ backgroundColor: isSelected
+ ? 'var(--color-primary-subtle)'
+ : isUnread
+ ? 'color-mix(in srgb, var(--color-bg-elevated) 50%, var(--color-bg-surface))'
+ : 'transparent',
+ cursor: 'pointer',
+ transition: 'background-color 0.12s',
+ minHeight: '68px',
+ }}
+ >
+ {/* Checkbox */}
+
{ e.stopPropagation(); onSelect(entry.id) }}
+ style={{ display: 'flex', alignItems: 'center' }}
+ >
+
+ {isSelected && (
+
+
+
+ )}
+
+
+
+ {/* Bookmark */}
+
{ e.stopPropagation(); onBookmark(entry) }}
+ title={entry.is_important ? 'Remove bookmark' : 'Bookmark'}
+ style={{
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
+ cursor: 'pointer', opacity: entry.is_important ? 1 : 0.3,
+ transition: 'opacity 0.15s',
+ }}
+ >
+
+
+
+ {/* Sender / recipient */}
+
+ {customer ? (
+ e.stopPropagation()}
+ style={{
+ color: isUnread ? 'var(--color-text-primary)' : 'var(--color-text-secondary)',
+ fontWeight: isUnread ? 'var(--font-weight-semibold)' : 'var(--font-weight-medium)',
+ fontSize: 'var(--font-size-base)',
+ textDecoration: 'none',
+ display: 'block', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
+ }}
+ >
+ {customer.name}{customer.surname ? ` ${customer.surname}` : ''}
+
+ ) : (
+
+ {entry.direction === 'inbound'
+ ? (entry.from_addr || 'Unknown sender')
+ : (Array.isArray(entry.to_addrs) ? entry.to_addrs[0] : entry.to_addrs || 'Unknown recipient')}
+
+ )}
+ {customer?.organization && (
+
+ {customer.organization}
+
+ )}
+
+
+ {/* Subject + snippet */}
+
+
+ {entry.subject || (no subject) }
+
+ {entry.body && (
+
+ {entry.body.replace(/<[^>]*>/g, '').slice(0, 120)}
+
+ )}
+
+
+ {/* Unread dot */}
+
+ {isUnread && (
+
+ )}
+
+
+ {/* Time — verbose */}
+
+
+ {relativeTime(entry.occurred_at)}
+
+
+
+ )
+}
+
+// ─── main ────────────────────────────────────────────────────────────────────
+
+export default function MailPage() {
+ const { toast } = useToast()
+
+ const [entries, setEntries] = useState([])
+ const [customers, setCustomers] = useState({})
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState('')
+ const [dirFilter, setDirFilter] = useState('inbound')
+ const [clientFilter, setClientFilter] = useState('all')
+ const [mailboxFilter, setMailboxFilter] = useState('both')
+ const [readFilter, setReadFilter] = useState('all')
+ const [search, setSearch] = useState('')
+ const [syncing, setSyncing] = useState(false)
+ const [newMailCount, setNewMailCount] = useState(0)
+ const [bannerVisible, setBannerVisible] = useState(false)
+ const [selected, setSelected] = useState(new Set())
+ const [deleting, setDeleting] = useState(false)
+ const [bulkLoading, setBulkLoading] = useState(false)
+ const [viewEntry, setViewEntry] = useState(null)
+ const [composeOpen, setComposeOpen] = useState(false)
+ const [composeTo, setComposeTo] = useState('')
+ const [composeFromAccount, setComposeFromAccount] = useState('')
+ const [settingsOpen, setSettingsOpen] = useState(false)
+
+ const pollRef = useRef(null)
+ const composeOpenRef = useRef(false)
+
+ // ── data ──
+ 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])
+ useEffect(() => { setSelected(new Set()); setReadFilter('all') }, [dirFilter])
+
+ // Keep ref in sync so the interval closure can read it without a dep
+ useEffect(() => { composeOpenRef.current = composeOpen }, [composeOpen])
+
+ // ── polling ──
+ const startPolling = useCallback(() => {
+ if (pollRef.current) clearInterval(pollRef.current)
+ pollRef.current = setInterval(async () => {
+ if (composeOpenRef.current) return // pause while composing
+ try {
+ const data = await api.get('/crm/comms/email/check')
+ if (data.new_count > 0) { setNewMailCount(data.new_count); setBannerVisible(true) }
+ } catch { /* silent */ }
+ }, getPollInterval() * 1000)
+ }, [])
+
+ useEffect(() => {
+ startPolling()
+ return () => { if (pollRef.current) clearInterval(pollRef.current) }
+ }, [startPolling])
+
+ // ── actions ──
+ const syncEmails = async () => {
+ setSyncing(true); setNewMailCount(0); setBannerVisible(false)
+ try {
+ const data = await api.post('/crm/comms/email/sync', {})
+ toast.success('Mail synced', `${data.new_count} new email${data.new_count !== 1 ? 's' : ''} pulled.`)
+ await loadAll()
+ } catch (err) { toast.danger('Sync failed', err.message) }
+ finally { setSyncing(false) }
+ }
+
+ 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) return
+ setDeleting(true)
+ try {
+ await api.post('/crm/comms/bulk-delete', { ids: [...selected] })
+ toast.success('Deleted', `${selected.size} message${selected.size !== 1 ? 's' : ''} removed.`)
+ setSelected(new Set()); await loadAll()
+ } catch (err) { toast.danger('Delete failed', err.message) }
+ finally { setDeleting(false) }
+ }
+
+ const bulkMarkRead = async (read) => {
+ if (!selected.size) return
+ setBulkLoading(true)
+ try {
+ await api.post('/crm/comms/bulk-read', { ids: [...selected], read })
+ setEntries((prev) => prev.map((e) => selected.has(e.id) ? { ...e, is_read: read } : e))
+ toast.success(read ? 'Marked as read' : 'Marked as unread', `${selected.size} message${selected.size !== 1 ? 's' : ''} updated.`)
+ setSelected(new Set())
+ } catch (err) { toast.danger('Failed', err.message) }
+ finally { setBulkLoading(false) }
+ }
+
+ const bulkFlag = async () => {
+ if (!selected.size) return
+ setBulkLoading(true)
+ try {
+ await api.post('/crm/comms/bulk-important', { ids: [...selected], important: true })
+ setEntries((prev) => prev.map((e) => selected.has(e.id) ? { ...e, is_important: true } : e))
+ toast.success('Flagged', `${selected.size} message${selected.size !== 1 ? 's' : ''} flagged as important.`)
+ setSelected(new Set())
+ } catch (err) { toast.danger('Failed', err.message) }
+ finally { setBulkLoading(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)
+ 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 { /* ok */ }
+ }
+ }
+
+ const openReply = (toAddr, sourceAccount = '') => {
+ setViewEntry(null); setComposeTo(toAddr || ''); setComposeFromAccount(sourceAccount || ''); setComposeOpen(true)
+ }
+
+ // ── customer email lookup ──
+ 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 resolveCustomer = useCallback((entry) => {
+ if (entry.customer_id && customers[entry.customer_id]) return customers[entry.customer_id]
+ const candidates = [
+ ...(entry.from_addr ? [String(entry.from_addr).toLowerCase()] : []),
+ ...(Array.isArray(entry.to_addrs) ? entry.to_addrs : entry.to_addrs ? [entry.to_addrs] : []).map((a) => String(a).toLowerCase()),
+ ]
+ for (const addr of candidates) {
+ if (customerEmailMap[addr]) return customers[customerEmailMap[addr]]
+ }
+ return null
+ }, [customers, customerEmailMap])
+
+ // ── filtering ──
+ const q = search.trim().toLowerCase()
+ const unreadCount = entries.filter((e) => e.direction === 'inbound' && !e.is_read).length
+
+ const filtered = useMemo(() => {
+ let list = entries.filter((e) => e.direction === dirFilter)
+ if (clientFilter === 'clients') list = list.filter((e) => !!resolveCustomer(e))
+ if (readFilter === 'unread') list = list.filter((e) => !e.is_read)
+ else if (readFilter === 'read') list = list.filter((e) => !!e.is_read)
+ else if (readFilter === 'important') list = list.filter((e) => !!e.is_important)
+ if (q) list = list.filter((e) => {
+ const cust = resolveCustomer(e)
+ 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)
+ )
+ })
+ return list
+ }, [entries, dirFilter, clientFilter, readFilter, q, resolveCustomer])
+
+ const anySelected = selected.size > 0
+
+ return (
+ <>
+
+
+
+
+
+ ,
+ onClick: loadAll,
+ loading: loading && !syncing,
+ },
+ {
+ label: 'Download new mail',
+ icon: ,
+ onClick: syncEmails,
+ loading: syncing,
+ },
+ {
+ label: 'Mail settings',
+ icon: ,
+ onClick: () => setSettingsOpen(true),
+ },
+ ]}
+ />
+ { setComposeTo(''); setComposeFromAccount(''); setComposeOpen(true) }}
+ >
+ Compose
+
+
+
+
+ {/* New mail banner */}
+ {bannerVisible && (
+
+
+ {newMailCount} new email{newMailCount !== 1 ? 's' : ''} available — sync to load them.
+
+
+ Sync now
+ setBannerVisible(false)}>Dismiss
+
+
+ )}
+
+ {/* Main card */}
+
+
+ {/* ── Filter toolbar ─────────────────────────────────────── */}
+
+ {/* 1. Inbox / Sent */}
+
+
+ {/* 2. All Messages / Clients Only */}
+
+
+ {/* 3. Sales / Support / All */}
+
+
+ {/* 4. Search — fills remaining space, same height as buttons */}
+
+
+
+
+ {/* 5. All / Unread / Read / Bookmarked */}
+
+
+
+ {/* ── Bulk action / count bar ────────────────────────────── */}
+
+
+ {filtered.length} message{filtered.length !== 1 ? 's' : ''}
+ {filtered.length < entries.filter((e) => e.direction === dirFilter).length ? ' (filtered)' : ''}
+
+
+ {anySelected ? (
+
+
+ {selected.size} selected
+
+ setSelected(new Set())}>Clear
+ bulkMarkRead(true)} loading={bulkLoading}>
+ Mark Read
+
+ bulkMarkRead(false)} loading={bulkLoading}>
+ Mark Unread
+
+
+ Flag Important
+
+
+ Delete
+
+
+ ) : filtered.length > 0 ? (
+
setSelected(new Set(filtered.map((e) => e.id)))}>
+ Select all
+
+ ) : null}
+
+
+ {/* ── Error ──────────────────────────────────────────────── */}
+ {error && (
+
+ {error}
+
+ )}
+
+ {/* ── Loading ────────────────────────────────────────────── */}
+ {loading ? (
+
+
+ Loading mail…
+
+
+ ) : filtered.length === 0 ? (
+
+
+
+ {q || readFilter !== 'all' || clientFilter !== 'all'
+ ? 'No messages match your filters.'
+ : dirFilter === 'inbound' ? 'Your inbox is empty.' : 'No sent mail.'}
+
+
+
+ ) : (
+
+ {filtered.map((entry) => (
+
+ ))}
+
+ )}
+
+
+
+ setViewEntry(null)}
+ onReply={openReply}
+ />
+ setComposeOpen(false)}
+ onSent={() => { setComposeOpen(false); loadAll() }}
+ />
+ setSettingsOpen(false)}
+ onSaved={startPolling}
+ />
+ >
+ )
+}
diff --git a/frontend/src/pages/crm/customers/CustomerDetail.jsx b/frontend/src/pages/crm/customers/CustomerDetail.jsx
new file mode 100644
index 0000000..a147e64
--- /dev/null
+++ b/frontend/src/pages/crm/customers/CustomerDetail.jsx
@@ -0,0 +1,398 @@
+// frontend/src/pages/crm/customers/CustomerDetail.jsx
+// Main Customer Detail page — profile header, action menu, tab selector, tab content
+
+import { useState, useEffect, useCallback, useRef } from 'react'
+import { useParams, useNavigate, useSearchParams } from 'react-router-dom'
+import api from '@/lib/api'
+import { useAuth } from '@/hooks/useAuth'
+import { ProfilePageHeaderActions } from '@/components/ui/ProfilePageHeader'
+import Button from '@/components/ui/Button'
+import Tabs from '@/components/ui/Tabs'
+import Spinner from '@/components/ui/Spinner'
+import {
+ InitNegotiationsModal,
+ SendMessageModal,
+ RecordIssueModal,
+ RecordPaymentModal,
+ AddFilesModal,
+} from '@/modals/crm/QuickEntryModals'
+import OverviewTab from '@/pages/crm/customers/tabs/OverviewTab'
+import OrdersTab from '@/pages/crm/customers/tabs/OrdersTab'
+import FinancialsTab from '@/pages/crm/customers/tabs/FinancialsTab'
+import SupportTab from '@/pages/crm/customers/tabs/SupportTab'
+import CommunicationTab from '@/pages/crm/customers/tabs/CommunicationTab'
+import QuotationsTab from '@/pages/crm/customers/tabs/QuotationsTab'
+import FilesTab from '@/pages/crm/customers/tabs/FilesTab'
+
+// ─── Tab config ───────────────────────────────────────────────────────────────
+
+const TAB_DEFINITIONS = [
+ { key: 'overview', label: 'Overview' },
+ { key: 'communication', label: 'Communication' },
+ { key: 'quotations', label: 'Quotations' },
+ { key: 'orders', label: 'Orders' },
+ { key: 'finance', label: 'Finance' },
+ { key: 'files', label: 'Files & Media' },
+ { key: 'devices', label: 'Devices' },
+ { key: 'support', label: 'Support' },
+]
+
+function resolveInitialTab(searchParams) {
+ const raw = searchParams.get('tab')
+ if (!raw) return 'overview'
+ const match = TAB_DEFINITIONS.find(t => t.key === raw.toLowerCase())
+ return match ? match.key : 'overview'
+}
+
+// ─── Coming-soon stub ─────────────────────────────────────────────────────────
+
+function TabComingSoon({ label }) {
+ return (
+
+
+
+
+
{label}
+
This tab is being built.
+
+ )
+}
+
+// ─── New Action dropdown menu ─────────────────────────────────────────────────
+
+const ACTION_ITEMS = [
+ {
+ key: 'init-neg',
+ label: 'Init Negotiations',
+ description: 'Start a new order at negotiating stage',
+ icon: (
+
+
+
+ ),
+ },
+ {
+ key: 'send-message',
+ label: 'Send Message',
+ description: 'Compose and send an email',
+ icon: (
+
+
+
+ ),
+ },
+ {
+ key: 'record-issue',
+ label: 'Record Issue',
+ description: 'Log a tech issue or support request',
+ icon: (
+
+
+
+ ),
+ },
+ {
+ key: 'record-payment',
+ label: 'Record Payment',
+ description: 'Log a payment or invoice transaction',
+ icon: (
+
+
+
+ ),
+ },
+ {
+ key: 'add-files',
+ label: 'Add Files',
+ description: 'Upload files for this customer',
+ icon: (
+
+
+
+ ),
+ },
+]
+
+function NewActionButton({ onSelect }) {
+ const [open, setOpen] = useState(false)
+ const ref = useRef(null)
+
+ useEffect(() => {
+ if (!open) return
+ const handler = e => { if (ref.current && !ref.current.contains(e.target)) setOpen(false) }
+ document.addEventListener('mousedown', handler)
+ return () => document.removeEventListener('mousedown', handler)
+ }, [open])
+
+ return (
+
+
setOpen(v => !v)}>
+
+ + New Action
+
+
+
+
+
+
+ {open && (
+
+ {ACTION_ITEMS.map((item, i) => (
+
{ setOpen(false); onSelect(item.key) }}
+ style={{
+ display: 'flex',
+ alignItems: 'flex-start',
+ gap: 'var(--space-3)',
+ width: '100%',
+ padding: 'var(--space-3) var(--space-4)',
+ borderTop: i > 0 ? '1px solid var(--color-border)' : 'none',
+ background: 'none',
+ border: 'none',
+ borderTop: i > 0 ? '1px solid var(--color-border)' : 'none',
+ cursor: 'pointer',
+ textAlign: 'left',
+ transition: 'background-color var(--transition-fast)',
+ }}
+ onMouseEnter={e => e.currentTarget.style.backgroundColor = 'var(--color-bg-island)'}
+ onMouseLeave={e => e.currentTarget.style.backgroundColor = 'transparent'}
+ >
+
+ {item.icon}
+
+
+
+ {item.label}
+
+
+ {item.description}
+
+
+
+ ))}
+
+ )}
+
+ )
+}
+
+// ─── Main component ───────────────────────────────────────────────────────────
+
+export default function CustomerDetail() {
+ const { id } = useParams()
+ const navigate = useNavigate()
+ const [searchParams, setSearchParams] = useSearchParams()
+ const { hasPermission } = useAuth()
+ const canEdit = hasPermission('crm_customers', 'full_access')
+
+ // ── State ──────────────────────────────────────────────────────────────────
+ const [customer, setCustomer] = useState(null)
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState('')
+ const [activeTab, setActiveTab] = useState(() => resolveInitialTab(searchParams))
+
+ // Modal visibility
+ const [modal, setModal] = useState(null) // 'init-neg' | 'send-message' | 'record-issue' | 'record-payment' | 'add-files' | null
+
+ // Orders for RecordPaymentModal order-ref selector
+ const [orders, setOrders] = useState([])
+
+ // ── Data fetching ──────────────────────────────────────────────────────────
+ const loadCustomer = useCallback((silent = false) => {
+ if (!silent) { setLoading(true); setError('') }
+ api.get(`/crm/customers/${id}`)
+ .then(data => setCustomer(data))
+ .catch(err => { if (!silent) setError(err.message || 'Failed to load customer') })
+ .finally(() => { if (!silent) setLoading(false) })
+ }, [id])
+
+ const loadOrders = useCallback(() => {
+ api.get(`/crm/customers/${id}/orders`)
+ .then(d => setOrders(d.orders || []))
+ .catch(() => {})
+ }, [id])
+
+ useEffect(() => { loadCustomer() }, [loadCustomer])
+ useEffect(() => { loadOrders() }, [loadOrders])
+
+ const reloadFinancials = useCallback(() => {
+ loadCustomer(true)
+ loadOrders()
+ }, [loadCustomer, loadOrders])
+
+ // ── Tab sync ───────────────────────────────────────────────────────────────
+ const handleTabChange = key => {
+ setActiveTab(key)
+ setSearchParams(prev => {
+ const next = new URLSearchParams(prev)
+ next.set('tab', key)
+ return next
+ }, { replace: true })
+ }
+
+ // ── Modal dispatch ─────────────────────────────────────────────────────────
+ const openModal = key => setModal(key)
+ const closeModal = () => setModal(null)
+
+ const handleActionSuccess = () => {
+ loadCustomer() // refresh customer stats after any quick action
+ }
+
+ // ── Render states ──────────────────────────────────────────────────────────
+ if (loading) {
+ return (
+
+
+
+ )
+ }
+
+ if (error) {
+ return (
+
+ )
+ }
+
+ if (!customer) return null
+
+ // ── Derived display values ─────────────────────────────────────────────────
+ const displayName = [customer.name, customer.surname].filter(Boolean).join(' ')
+ || customer.organization
+ || 'Unknown Customer'
+
+ const subtitleParts = [
+ customer.organization,
+ customer.location?.city,
+ customer.location?.country,
+ ].filter(Boolean)
+ const subtitle = subtitleParts.join(' · ') || undefined
+
+ // ── Tab content ────────────────────────────────────────────────────────────
+ function renderTabContent() {
+ switch (activeTab) {
+ case 'overview':
+ return
+ case 'communication': return
+ case 'quotations': return
+ case 'orders': return
+ case 'finance': return
+ case 'files': return
+ case 'devices': return
+ case 'support': return
+ default: return
+ }
+ }
+
+ return (
+ <>
+
+
+ {/* ── Profile Page Header ────────────────────────────────────────── */}
+
+ navigate('/crm/customers')}>
+ ← Customers
+
+ {canEdit && (
+ navigate(`/crm/customers/${id}/edit`)}>
+ Edit Profile
+
+ )}
+
+
+
+ {/* ── Tab Bar ───────────────────────────────────────────────────── */}
+
+
+ {/* ── Tab Content ───────────────────────────────────────────────── */}
+
+ {renderTabContent()}
+
+
+
+
+ {/* ── Quick-entry modals (portaled) ──────────────────────────────── */}
+
+
+
+
+ { closeModal(); handleTabChange('files') }}
+ />
+ >
+ )
+}
diff --git a/frontend/src/pages/crm/customers/CustomerForm.jsx b/frontend/src/pages/crm/customers/CustomerForm.jsx
new file mode 100644
index 0000000..868c6c5
--- /dev/null
+++ b/frontend/src/pages/crm/customers/CustomerForm.jsx
@@ -0,0 +1,923 @@
+// frontend/src/pages/crm/customers/CustomerForm.jsx
+
+import { useState, useEffect, useRef } from 'react'
+import { useNavigate, useParams } from 'react-router-dom'
+import api from '@/lib/api'
+import { useAuth } from '@/hooks/useAuth'
+import PageHeader from '@/components/ui/PageHeader'
+import Button from '@/components/ui/Button'
+import Card from '@/components/ui/Card'
+import FormField from '@/components/ui/FormField'
+import Icon from '@/components/ui/Icon'
+import Spinner from '@/components/ui/Spinner'
+import ConfirmDialog from '@/components/ui/ConfirmDialog'
+import { fmtDate } from '@/lib/formatters'
+
+// ─── Constants ────────────────────────────────────────────────────────────────
+
+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' },
+ { value: 'ru', label: 'Russian' },
+ { value: 'ar', label: 'Arabic' },
+ { value: 'tr', label: 'Turkish' },
+ { value: 'sr', label: 'Serbian' },
+]
+
+const TITLES = [
+ { value: '', label: '—' },
+ { value: 'Fr.', label: 'Father' },
+ { value: 'Rev.', label: 'Reverend' },
+ { value: 'Archim.', label: 'Archimandrite' },
+ { value: 'Bp.', label: 'Bishop' },
+ { value: 'Abp.', label: 'Archbishop' },
+ { value: 'Met.', label: 'Metropolitan' },
+ { value: 'Mr.', label: 'Mister' },
+ { value: 'Mrs.', label: 'Missus' },
+ { value: 'Ms.', label: 'Ms.' },
+ { value: 'Dr.', label: 'Doctor' },
+ { value: 'Prof.', label: 'Professor' },
+]
+
+const PRESET_TAGS = [
+ 'church', 'municipality', 'monastery', 'priest', 'church council',
+ 'technician', 'donor', 'repeat-customer', 'vip', 'promoter',
+]
+
+const RELIGIONS = [
+ { value: '', label: '— Unknown' },
+ { value: 'Orthodox', label: 'Orthodox' },
+ { value: 'Catholic', label: 'Catholic' },
+ { value: 'Christian Other',label: 'Christian Other' },
+ { value: 'Other', label: 'Other' },
+]
+
+const REL_STATUS_OPTIONS = [
+ { value: 'lead', label: 'Lead' },
+ { value: 'prospect', label: 'Prospect' },
+ { value: 'active', label: 'Active' },
+ { value: 'inactive', label: 'Archived' },
+ { value: 'churned', label: 'Went Cold' },
+]
+
+const CONTACT_TYPE_LABEL = {
+ email: 'Email',
+ phone: 'Phone',
+ whatsapp: 'WhatsApp',
+ other: 'Other',
+}
+
+const DEFAULT_FORM = {
+ title: '',
+ name: '',
+ surname: '',
+ organization: '',
+ religion: '',
+ language: 'el',
+ relationship_status: 'lead',
+ tags: [],
+ folder_id: '',
+ location: {
+ address: '',
+ city: '',
+ postal_code: '',
+ region: '',
+ country: '',
+ },
+ contacts: [],
+ notes: [],
+}
+
+const emptyContact = () => ({ type: 'email', label: '', value: '', primary: false })
+
+// ─── Helpers ──────────────────────────────────────────────────────────────────
+
+function SectionDivider({ label }) {
+ return (
+
+ )
+}
+
+// ─── CustomerForm ─────────────────────────────────────────────────────────────
+
+export default function CustomerForm() {
+ const { id } = useParams()
+ const isEdit = Boolean(id)
+ const navigate = useNavigate()
+ const { user, hasPermission } = useAuth()
+ const canEdit = hasPermission('crm_customers', isEdit ? 'full_access' : 'add')
+
+ // Form state
+ const [form, setForm] = useState(DEFAULT_FORM)
+ const [loading, setLoading] = useState(isEdit)
+ const [saving, setSaving] = useState(false)
+ const [error, setError] = useState('')
+
+ // Tag state
+ const [tagInput, setTagInput] = useState('')
+ const [allTags, setAllTags] = useState([])
+ const tagInputRef = useRef(null)
+
+ // Note state
+ const [newNoteText, setNewNoteText] = useState('')
+ const [editingNoteIdx, setEditingNoteIdx] = useState(null)
+ const [editingNoteText,setEditingNoteText]= useState('')
+
+ // Delete dialog
+ const [showDeleteDialog, setShowDeleteDialog] = useState(false)
+ const [deleting, setDeleting] = useState(false)
+
+ // ── Fetch ────────────────────────────────────────────────────────────────
+
+ useEffect(() => {
+ api.get('/crm/customers/tags').then(setAllTags).catch(() => {})
+ }, [])
+
+ useEffect(() => {
+ if (!isEdit) return
+ api.get(`/crm/customers/${id}`)
+ .then((data) => {
+ setForm({
+ title: data.title || '',
+ name: data.name || '',
+ surname: data.surname || '',
+ organization: data.organization || '',
+ religion: data.religion || '',
+ language: data.language || 'el',
+ relationship_status: data.relationship_status || 'lead',
+ tags: data.tags || [],
+ folder_id: data.folder_id || '',
+ location: {
+ address: data.location?.address || '',
+ city: data.location?.city || '',
+ postal_code: data.location?.postal_code || '',
+ region: data.location?.region || '',
+ country: data.location?.country || '',
+ },
+ contacts: data.contacts || [],
+ notes: data.notes || [],
+ })
+ })
+ .catch((err) => setError(err.message))
+ .finally(() => setLoading(false))
+ }, [id, isEdit])
+
+ // ── Field setters ────────────────────────────────────────────────────────
+
+ 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) => {
+ set('contacts', form.contacts.map((c, idx) => idx === i ? { ...c, [field]: value } : c))
+ }
+
+ const setPrimaryContact = (i) => {
+ const type = form.contacts[i].type
+ set('contacts', form.contacts.map((c, idx) => ({
+ ...c,
+ primary: c.type === type ? idx === i : c.primary,
+ })))
+ }
+
+ // ── Notes ────────────────────────────────────────────────────────────────
+
+ const addNote = () => {
+ if (!newNoteText.trim()) return
+ set('notes', [...form.notes, {
+ text: newNoteText.trim(),
+ by: user?.name || 'unknown',
+ at: new Date().toISOString(),
+ }])
+ 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
+ set('notes', form.notes.map((n, idx) =>
+ idx === i ? { ...n, text: editingNoteText.trim(), at: new Date().toISOString() } : n
+ ))
+ setEditingNoteIdx(null)
+ }
+
+ // ── Save ─────────────────────────────────────────────────────────────────
+
+ const buildPayload = () => ({
+ title: form.title.trim() || null,
+ name: form.name.trim(),
+ surname: form.surname.trim() || null,
+ organization: form.organization.trim() || null,
+ religion: form.religion.trim() || null,
+ language: form.language,
+ relationship_status: form.relationship_status || 'lead',
+ tags: form.tags,
+ ...(!isEdit && { folder_id: form.folder_id.trim().toLowerCase() }),
+ location: {
+ address: form.location.address.trim() || null,
+ city: form.location.city.trim() || null,
+ postal_code: form.location.postal_code.trim() || null,
+ region: form.location.region.trim() || null,
+ country: form.location.country.trim() || null,
+ },
+ 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('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 () => {
+ setDeleting(true)
+ try {
+ await api.delete(`/crm/customers/${id}`)
+ navigate('/crm/customers')
+ } catch (err) {
+ setError(err.message)
+ setDeleting(false)
+ }
+ }
+
+ // ── Loading state ────────────────────────────────────────────────────────
+
+ if (loading) {
+ return (
+
+ )
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+
+ const tagSuggestions = allTags.filter(
+ (t) => t.toLowerCase().startsWith(tagInput.toLowerCase()) && !form.tags.includes(t)
+ )
+
+ return (
+
+
+
+ navigate(isEdit ? `/crm/customers/${id}` : '/crm/customers')}
+ >
+ Cancel
+
+ {canEdit && (
+
+ {isEdit ? 'Save Changes' : 'Create Customer'}
+
+ )}
+
+
+ {/* Error banner */}
+ {error && (
+
+ {error}
+
+ )}
+
+ {/* ── Two-column form layout ─────────────────────────────────────────── */}
+
+
+ {/* ── Basic Info card ───────────────────────────────────────────────── */}
+
+
+ {/* Row: Title + Name + Surname */}
+
+ set('title', e.target.value)}
+ >
+ {TITLES.map((t) => {t.label} )}
+
+
+ set('name', e.target.value)}
+ placeholder="First name"
+ required
+ />
+
+ set('surname', e.target.value)}
+ placeholder="Last name"
+ />
+
+
+ {/* Row: Relationship Status + Organization */}
+
+ set('relationship_status', e.target.value)}
+ >
+ {REL_STATUS_OPTIONS.map((o) => {o.label} )}
+
+
+ set('organization', e.target.value)}
+ placeholder="Church, company, etc."
+ />
+
+
+ {/* Row: Religion + Language */}
+
+ set('religion', e.target.value)}
+ >
+ {RELIGIONS.map((r) => {r.label} )}
+
+
+ set('language', e.target.value)}
+ >
+ {LANGUAGES.map((l) => {l.label} )}
+
+
+
+ {/* Folder ID — only on create */}
+ {!isEdit ? (
+ <>
+ set('folder_id', e.target.value.toLowerCase().replace(/[^a-z0-9\-]/g, ''))}
+ placeholder="e.g. saint-john-corfu"
+ hint="Lowercase letters, numbers and hyphens only. Becomes the Nextcloud folder name and cannot be changed."
+ required
+ />
+ >
+ ) : (
+
+
+ Folder ID
+
+
+ {form.folder_id || '—'}
+
+
+ )}
+
+ {/* Tags */}
+
+
+ {/* Active tags */}
+ {form.tags.length > 0 && (
+
+ {form.tags.map((tag) => (
+ removeTag(tag)}
+ title="Click to remove"
+ style={{
+ display: 'inline-flex',
+ alignItems: 'center',
+ gap: 'var(--space-1)',
+ padding: '3px var(--space-2)',
+ fontSize: 'var(--font-size-xs)',
+ borderRadius: 'var(--radius-full)',
+ backgroundColor: 'var(--color-primary-subtle)',
+ color: 'var(--color-primary)',
+ border: '1px solid rgba(192,193,255,0.2)',
+ cursor: 'pointer',
+ }}
+ >
+ {tag}
+
+
+ ))}
+
+ )}
+
+ {/* Preset quick-add tags */}
+
+ {PRESET_TAGS.filter((t) => !form.tags.includes(t)).map((t) => (
+ addTag(t)}
+ style={{
+ padding: '3px var(--space-2)',
+ fontSize: 'var(--font-size-xs)',
+ borderRadius: 'var(--radius-full)',
+ border: '1px solid var(--color-border-strong)',
+ color: 'var(--color-text-muted)',
+ backgroundColor: 'transparent',
+ cursor: 'pointer',
+ }}
+ >
+ + {t}
+
+ ))}
+
+
+ {/* Tag input with autocomplete */}
+
+
setTagInput(e.target.value)}
+ inputProps={{
+ onKeyDown: (e) => {
+ if (e.key === 'Tab' && tagSuggestions.length > 0) {
+ e.preventDefault()
+ addTag(tagSuggestions[0])
+ } else if (e.key === 'Enter' || e.key === ',') {
+ e.preventDefault()
+ addTag(tagInput)
+ } else if (e.key === 'Escape') {
+ setTagInput('')
+ }
+ },
+ onBlur: () => { setTimeout(() => { if (tagInput.trim()) addTag(tagInput) }, 150) },
+ }}
+ placeholder="Type a tag — Enter or comma to add, Tab to autocomplete…"
+ />
+ {tagInput.trim().length > 0 && tagSuggestions.length > 0 && (() => {
+ const rect = tagInputRef.current?.getBoundingClientRect()
+ return (
+
+ {tagSuggestions.map((s, idx) => (
+
{ e.preventDefault(); addTag(s) }}
+ style={{
+ padding: 'var(--space-2) var(--space-3)',
+ fontSize: 'var(--font-size-sm)',
+ cursor: 'pointer',
+ color: 'var(--color-text-primary)',
+ borderBottom: '1px solid var(--color-border)',
+ backgroundColor: 'transparent',
+ display: 'flex',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ }}
+ onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = 'var(--color-bg-island)' }}
+ onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = 'transparent' }}
+ >
+ {s}
+ {idx === 0 && (
+
+ Tab
+
+ )}
+
+ ))}
+
+ )
+ })()}
+
+
+
+ {/* ── Location card ──────────────────────────────────────────────────── */}
+
+
+ setLoc('address', e.target.value)}
+ placeholder="Street address"
+ />
+ setLoc('city', e.target.value)}
+ placeholder="City"
+ />
+ setLoc('postal_code', e.target.value)}
+ placeholder="Postal code"
+ />
+
+
+
+ setLoc('region', e.target.value)}
+ placeholder="Region / State"
+ />
+ setLoc('country', e.target.value)}
+ placeholder="Country"
+ />
+
+
+
+ {/* ── Contacts card ──────────────────────────────────────────────────── */}
+
+ {form.contacts.length === 0 && (
+
+ No contacts added yet.
+
+ )}
+
+
+ {form.contacts.map((c, i) => (
+
+ {/* Type */}
+ setContact(i, 'type', e.target.value)}
+ >
+ {CONTACT_TYPES.map((t) => (
+ {CONTACT_TYPE_LABEL[t] || t}
+ ))}
+
+
+ {/* Label */}
+ setContact(i, 'label', e.target.value)}
+ placeholder="label (e.g. work)"
+ />
+
+ {/* Value */}
+ setContact(i, 'value', e.target.value)}
+ placeholder="value"
+ />
+
+ {/* Primary */}
+
+ setPrimaryContact(i)}
+ style={{ accentColor: 'var(--color-primary)', cursor: 'pointer' }}
+ />
+ Primary
+
+
+ {/* Remove */}
+ removeContact(i)}
+ aria-label="Remove contact"
+ style={{
+ background: 'none',
+ border: 'none',
+ cursor: 'pointer',
+ padding: 'var(--space-1)',
+ color: 'var(--color-danger)',
+ display: 'flex',
+ alignItems: 'center',
+ flexShrink: 0,
+ opacity: 0.7,
+ }}
+ onMouseEnter={(e) => { e.currentTarget.style.opacity = '1' }}
+ onMouseLeave={(e) => { e.currentTarget.style.opacity = '0.7' }}
+ >
+
+
+
+ ))}
+
+
+ 0 ? 'var(--space-3)' : 0 }}
+ >
+
+ Add Contact
+
+
+
+ {/* ── Notes card ─────────────────────────────────────────────────────── */}
+
+ {form.notes.length > 0 && (
+
+ {form.notes.map((note, i) => (
+
+ {editingNoteIdx === i ? (
+
+
setEditingNoteText(e.target.value)}
+ rows={3}
+ inputProps={{
+ autoFocus: true,
+ onKeyDown: (e) => {
+ if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) saveEditNote(i)
+ if (e.key === 'Escape') setEditingNoteIdx(null)
+ },
+ }}
+ />
+
+ saveEditNote(i)}>Save
+ setEditingNoteIdx(null)}>Cancel
+
+
+ ) : (
+ <>
+
+ {note.text}
+
+
+
+ {note.by} · {note.at ? fmtDate(note.at) : ''}
+
+
+ startEditNote(i)}
+ style={{
+ background: 'none', border: 'none', cursor: 'pointer',
+ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-accent)', padding: 0,
+ }}
+ >
+ Edit
+
+ removeNote(i)}
+ style={{
+ background: 'none', border: 'none', cursor: 'pointer',
+ fontSize: 'var(--font-size-xs)', color: 'var(--color-danger)', padding: 0,
+ }}
+ >
+ Remove
+
+
+
+ >
+ )}
+
+ ))}
+
+ )}
+
+ {/* New note */}
+
+
+ setNewNoteText(e.target.value)}
+ rows={2}
+ placeholder="Add a note…"
+ inputProps={{
+ onKeyDown: (e) => {
+ if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) addNote()
+ },
+ }}
+ />
+
+
+ Add
+
+
+
+
+
+
+ {/* ── Danger Zone ───────────────────────────────────────────────────────── */}
+ {isEdit && canEdit && (
+
+
+
+
+ Danger Zone
+
+
+ Permanently delete this customer record. This action cannot be undone.
+
+
+
setShowDeleteDialog(true)}
+ >
+ Delete Customer
+
+
+
+ )}
+
+ {/* ── Delete confirmation ───────────────────────────────────────────────── */}
+
setShowDeleteDialog(false)}
+ />
+
+
+ )
+}
diff --git a/frontend/src/pages/crm/customers/CustomerList.jsx b/frontend/src/pages/crm/customers/CustomerList.jsx
new file mode 100644
index 0000000..870c852
--- /dev/null
+++ b/frontend/src/pages/crm/customers/CustomerList.jsx
@@ -0,0 +1,1364 @@
+// frontend/src/pages/crm/customers/CustomerList.jsx
+
+import { useState, useEffect, useCallback, useRef } from 'react'
+import { useNavigate } from 'react-router-dom'
+import { createPortal } from 'react-dom'
+import api from '@/lib/api'
+import { useAuth } from '@/hooks/useAuth'
+import usePreferences from '@/hooks/usePreferences'
+import PageHeader from '@/components/ui/PageHeader'
+import Button from '@/components/ui/Button'
+import DataTable from '@/components/ui/DataTable'
+import StatusBadge from '@/components/ui/StatusBadge'
+import Pagination from '@/components/ui/Pagination'
+import SearchBar from '@/components/ui/SearchBar'
+import RowActions from '@/components/ui/RowActions'
+import Icon from '@/components/ui/Icon'
+import MailViewModal from '@/modals/crm/MailViewModal'
+import CustomerListCardView from '@/pages/crm/customers/CustomerListCardView'
+
+// ─── Status icon imports ───────────────────────────────────────────────────────
+
+import clientIcon from '@/assets/customer-status/client.svg?raw'
+import negotiatingIcon from '@/assets/customer-status/negotiating.svg?raw'
+import awaitingQuotationIcon from '@/assets/customer-status/awating-quotation.svg?raw'
+import awaitingConfirmIcon from '@/assets/customer-status/awaiting-confirmation.svg?raw'
+import quotationAcceptedIcon from '@/assets/customer-status/quotation-accepted.svg?raw'
+import startedMfgIcon from '@/assets/customer-status/started-mfg.svg?raw'
+import awaitingPaymentIcon from '@/assets/customer-status/awaiting-payment.svg?raw'
+import shippedIcon from '@/assets/customer-status/shipped.svg?raw'
+import inactiveIcon from '@/assets/customer-status/inactive.svg?raw'
+import declinedIcon from '@/assets/customer-status/declined.svg?raw'
+import churnedIcon from '@/assets/customer-status/churned.svg?raw'
+import orderIcon from '@/assets/customer-status/order.svg?raw'
+import exclamationIcon from '@/assets/customer-status/exclamation.svg?raw'
+import wrenchIcon from '@/assets/customer-status/wrench.svg?raw'
+
+// ─── Constants ────────────────────────────────────────────────────────────────
+
+const PREFS_KEY = 'crm_customers'
+
+const REL_STATUS_VARIANT = {
+ lead: 'neutral',
+ prospect: 'info',
+ active: 'success',
+ inactive: 'neutral',
+ churned: 'danger',
+}
+
+const REL_STATUS_LABELS = {
+ lead: 'Lead',
+ prospect: 'Prospect',
+ active: 'Active',
+ inactive: 'Archived',
+ churned: 'Went Cold',
+}
+
+// Active sub-statuses that come from the active order's status
+const ACTIVE_SUB_OPTIONS = [
+ { value: 'active__negotiating', label: 'Negotiating' },
+ { value: 'active__awaiting_quotation', label: 'Awaiting Quotation' },
+ { value: 'active__awaiting_customer_confirmation', label: 'Awaiting Confirmation' },
+ { value: 'active__awaiting_fulfilment', label: 'Accepted - Waiting' },
+ { value: 'active__awaiting_payment', label: 'Awaiting Payment' },
+ { value: 'active__manufacturing', label: 'Manufacturing' },
+ { value: 'active__shipped', label: 'Shipped' },
+ { value: 'active__installed', label: 'Installed' },
+]
+
+const TOP_STATUS_OPTIONS = [
+ { value: 'lead', label: 'Lead' },
+ { value: 'prospect', label: 'Prospect' },
+ { value: 'active', label: 'Active' },
+ { value: 'inactive', label: 'Archived' },
+ { value: 'churned', label: 'Went Cold' },
+]
+
+// Columns that are always visible (cannot be toggled off)
+const ALL_COLUMNS_DEF = [
+ { key: 'name', label: 'Name', alwaysOn: true },
+ { key: 'status', label: 'Status' },
+ { key: 'rel_status', label: 'Relationship' },
+ { key: 'support', label: 'Support' },
+ { key: 'organization', label: 'Organization' },
+ { key: 'location', label: 'Location' },
+ { key: 'email', label: 'Email' },
+ { key: 'phone', label: 'Phone' },
+ { key: 'latest_comm', label: 'Latest Comm' },
+ { key: 'tags', label: 'Tags' },
+ { key: 'religion', label: 'Religion' },
+ { key: 'language', label: 'Language' },
+]
+
+const DEFAULT_VISIBLE = ['name', 'status', 'rel_status', 'support', 'organization', 'location', 'email', 'phone', 'latest_comm', 'tags']
+
+// 4-way sort modes for the Name column
+// Each mode: { key, dir, label } — label shown in the column header
+const NAME_SORT_MODES = [
+ { sortKey: 'name', sortDir: 'asc', label: 'name ↑' },
+ { sortKey: 'name', sortDir: 'desc', label: 'name ↓' },
+ { sortKey: 'surname', sortDir: 'asc', label: 'surn ↑' },
+ { sortKey: 'surname', sortDir: 'desc', label: 'surn ↓' },
+]
+
+const ORDER_STATUS_LABELS = {
+ negotiating: 'Negotiating',
+ awaiting_quotation: 'Awaiting Quotation',
+ awaiting_customer_confirmation: 'Awaiting Confirmation',
+ awaiting_fulfilment: 'Accepted - Waiting',
+ awaiting_payment: 'Awaiting Payment',
+ manufacturing: 'Manufacturing',
+ shipped: 'Shipped',
+ installed: 'Installed',
+ declined: 'Declined',
+ complete: 'Complete',
+}
+
+// Comm type display metadata
+const COMM_TYPE_META = {
+ email: { label: 'email', color: 'var(--color-info)', bg: 'var(--color-info-bg)' },
+ whatsapp: { label: 'whatsapp', color: 'var(--color-success)', bg: 'var(--color-success-bg)' },
+ call: { label: 'call', color: 'var(--color-warning)', bg: 'var(--color-warning-bg)' },
+ sms: { label: 'sms', color: 'var(--color-warning)', bg: 'var(--color-warning-bg)' },
+ note: { label: 'note', color: 'var(--color-text-muted)', bg: 'var(--color-bg-elevated)' },
+ in_person: { label: 'in person', color: 'var(--color-primary)', bg: 'var(--color-primary-subtle)' },
+}
+
+// ─── Language map ─────────────────────────────────────────────────────────────
+
+const LANGUAGE_NAMES = {
+ el: 'Greek', en: 'English', de: 'German', fr: 'French',
+ it: 'Italian', ru: 'Russian', ar: 'Arabic', tr: 'Turkish',
+ es: 'Spanish', pt: 'Portuguese', sr: 'Serbian', uk: 'Ukrainian',
+}
+
+function resolveLanguage(val) {
+ if (!val) return '—'
+ return LANGUAGE_NAMES[val.trim().toLowerCase()] || val
+}
+
+// ─── Status icon renderer ─────────────────────────────────────────────────────
+
+const STATUS_ICON_SIZE = 22
+
+function renderMaskedIcon(icon, color, title, size = STATUS_ICON_SIZE) {
+ const svgMarkup = icon
+ .replace(/<\?xml[\s\S]*?\?>/gi, '')
+ .replace(//gi, '')
+ .replace(//g, '')
+ .replace(
+ /]*)>/i,
+ ``,
+ )
+ return (
+
+ )
+}
+
+function resolveStatusIcon(customer) {
+ const status = customer.relationship_status || 'lead'
+ const summary = customer.crm_summary || {}
+ const allOrders = summary.all_orders_statuses || []
+
+ if (status === 'churned') return { icon: churnedIcon, color: 'var(--color-text-muted)', title: 'Went Cold' }
+ if (status === 'lead') return { icon: clientIcon, color: 'var(--color-text-muted)', title: 'Lead' }
+ if (status === 'prospect')return { icon: clientIcon, color: 'var(--color-info)', title: 'Prospect' }
+ if (status === 'inactive')return { icon: inactiveIcon, color: 'var(--color-text-muted)', title: 'Archived' }
+
+ if (status === 'active') {
+ const activeOrderStatus = summary.active_order_status
+ const orderIconMap = {
+ negotiating: { icon: negotiatingIcon, color: 'var(--color-text-secondary)', title: 'Negotiating' },
+ awaiting_quotation: { icon: awaitingQuotationIcon, color: 'var(--color-text-secondary)', title: 'Awaiting Quotation' },
+ awaiting_customer_confirmation: { icon: awaitingConfirmIcon, color: 'var(--color-text-secondary)', title: 'Awaiting Confirmation' },
+ awaiting_fulfilment: { icon: quotationAcceptedIcon, color: 'var(--color-info)', title: 'Accepted - Waiting' },
+ awaiting_payment: { icon: awaitingPaymentIcon, color: 'var(--color-warning)', title: 'Awaiting Payment' },
+ manufacturing: { icon: startedMfgIcon, color: 'var(--color-success)', title: 'Manufacturing' },
+ shipped: { icon: shippedIcon, color: 'var(--color-success)', title: 'Shipped' },
+ installed: { icon: inactiveIcon, color: 'var(--color-success)', title: 'Installed' },
+ }
+ if (activeOrderStatus && orderIconMap[activeOrderStatus]) return orderIconMap[activeOrderStatus]
+ const allDeclined = allOrders.length > 0 && allOrders.every((s) => s === 'declined')
+ if (allDeclined) return { icon: declinedIcon, color: 'var(--color-danger)', title: 'All orders declined' }
+ const allComplete = allOrders.length > 0 && allOrders.every((s) => s === 'complete')
+ if (allComplete) return { icon: inactiveIcon, color: 'var(--color-success)', title: 'All orders complete' }
+ return { icon: inactiveIcon, color: 'var(--color-info)', title: 'Active, no orders' }
+ }
+
+ return { icon: clientIcon, color: 'var(--color-text-muted)', title: status }
+}
+
+// ─── Helpers ──────────────────────────────────────────────────────────────────
+
+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
+}
+
+function Muted({ children }) {
+ return {children}
+}
+
+function formatRelative(val) {
+ if (!val) return ''
+ const diff = Date.now() - new Date(val).getTime()
+ if (diff < 0) return 'just now'
+ const s = Math.floor(diff / 1000)
+ if (s < 60) return 'just now'
+ const m = Math.floor(s / 60)
+ if (m < 60) return `${m}m ago`
+ const h = Math.floor(m / 60)
+ if (h < 24) return `${h}h ago`
+ const d = Math.floor(h / 24)
+ if (d === 1) return 'yesterday'
+ if (d < 7) return `${d}d ago`
+ const w = Math.floor(d / 7)
+ if (w < 5) return `${w}w ago`
+ const mo = Math.floor(d / 30)
+ if (mo < 12) return `${mo}mo ago`
+ return `${Math.floor(d / 365)}y ago`
+}
+
+// ─── Status filter modal ──────────────────────────────────────────────────────
+
+function StatusFilterModal({ open, anchorRect, selectedValues, onChange, onClose }) {
+ const ref = useRef(null)
+
+ useEffect(() => {
+ if (!open) return
+ function onDown(e) {
+ if (ref.current && !ref.current.contains(e.target)) onClose()
+ }
+ document.addEventListener('mousedown', onDown)
+ return () => document.removeEventListener('mousedown', onDown)
+ }, [open, onClose])
+
+ if (!open || !anchorRect) return null
+
+ const style = {
+ position: 'fixed',
+ top: anchorRect.bottom + 4,
+ left: anchorRect.left,
+ zIndex: 9999,
+ minWidth: 220,
+ backgroundColor: 'var(--color-bg-elevated)',
+ border: '1px solid var(--color-border-strong)',
+ borderRadius: 'var(--radius-lg)',
+ boxShadow: 'var(--shadow-lg)',
+ padding: 'var(--space-2) 0',
+ }
+
+ const selected = new Set(selectedValues)
+
+ // Derive states
+ const activeSubValues = ACTIVE_SUB_OPTIONS.map((o) => o.value)
+ const activeSubSelected = activeSubValues.filter((v) => selected.has(v))
+ const activeTopSelected = selected.has('active')
+
+ function toggleTop(value) {
+ const next = new Set(selected)
+ if (value === 'active') {
+ // Uncheck if fully selected (parent checked + all subs checked, or just parent checked)
+ const fullyChecked = activeParentChecked && !activeParentIndeterminate
+ if (fullyChecked) {
+ next.delete('active')
+ activeSubValues.forEach((v) => next.delete(v))
+ } else {
+ // Partially checked or unchecked → check everything
+ next.add('active')
+ activeSubValues.forEach((v) => next.add(v))
+ }
+ } else {
+ if (next.has(value)) next.delete(value)
+ else next.add(value)
+ }
+ onChange([...next])
+ }
+
+ function toggleSub(value) {
+ const next = new Set(selected)
+ if (next.has(value)) {
+ next.delete(value)
+ // If not all subs checked, remove the parent "active" check
+ const remaining = activeSubValues.filter((v) => next.has(v))
+ if (remaining.length < activeSubValues.length) next.delete('active')
+ } else {
+ next.add(value)
+ // If all subs now checked, auto-check parent
+ const remaining = activeSubValues.filter((v) => next.has(v))
+ if (remaining.length === activeSubValues.length) next.add('active')
+ }
+ onChange([...next])
+ }
+
+ const activeParentChecked = activeTopSelected || activeSubSelected.length === activeSubValues.length
+ const activeParentIndeterminate = !activeParentChecked && activeSubSelected.length > 0
+
+ const itemStyle = {
+ display: 'flex',
+ alignItems: 'center',
+ gap: 'var(--space-2)',
+ padding: 'var(--space-2) var(--space-3)',
+ cursor: 'pointer',
+ fontSize: 'var(--font-size-sm)',
+ color: 'var(--color-text-primary)',
+ userSelect: 'none',
+ lineHeight: 1.4,
+ }
+ const subItemStyle = {
+ ...itemStyle,
+ paddingLeft: 'var(--space-8)',
+ color: 'var(--color-text-secondary)',
+ fontSize: 'var(--font-size-xs)',
+ }
+
+ return createPortal(
+
+
+ {/* Clear filter */}
+ {selected.size > 0 && (
+ <>
+
onChange([])}
+ >
+
+ Clear filter
+
+
+ >
+ )}
+
+ {TOP_STATUS_OPTIONS.map((opt) => {
+ if (opt.value === 'active') {
+ return (
+
+ {/* Active parent row */}
+ toggleTop('active')}
+ >
+
+ Active
+
+
+ {/* Active sub-options */}
+ {ACTIVE_SUB_OPTIONS.map((sub) => (
+ toggleSub(sub.value)}
+ >
+ {}}
+ style={{ accentColor: 'var(--color-primary)', flexShrink: 0, cursor: 'pointer' }}
+ />
+ {sub.label}
+
+ ))}
+
+ )
+ }
+
+ const checked = selected.has(opt.value)
+ return (
+
toggleTop(opt.value)}
+ >
+ {}}
+ style={{ accentColor: 'var(--color-primary)', flexShrink: 0, cursor: 'pointer' }}
+ />
+
+ {opt.label}
+
+
+ )
+ })}
+
,
+ document.body
+ )
+}
+
+// Renders a checkbox that supports an indeterminate state
+function IndeterminateCheckbox({ checked, indeterminate }) {
+ const ref = useRef(null)
+ useEffect(() => {
+ if (ref.current) ref.current.indeterminate = indeterminate
+ }, [indeterminate])
+ return (
+ {}}
+ style={{ accentColor: 'var(--color-primary)', flexShrink: 0, cursor: 'pointer' }}
+ />
+ )
+}
+
+// ─── CustomerList ─────────────────────────────────────────────────────────────
+
+export default function CustomerList() {
+ const navigate = useNavigate()
+ const { hasPermission } = useAuth()
+ const canAdd = hasPermission('crm_customers', 'add')
+ const canEdit = hasPermission('crm_customers', 'edit')
+
+ const { prefs, setPref, setPrefBatch, ready } = usePreferences(PREFS_KEY)
+
+ // Data
+ const [customers, setCustomers] = useState([])
+ const [loading, setLoading] = useState(true) // true only on first load (no data yet)
+ const [error, setError] = useState('')
+ // Latest comm per customer: { [customerId]: { id, type, occurred_at } }
+ const [latestComms, setLatestComms] = useState({})
+
+ // Filters — initialised from prefs once ready
+ const [search, setSearch] = useState('')
+ const [statusFilters, setStatusFilters] = useState([]) // array of selected values
+
+ // Sort — 4-way name/surname cycling; also sortable by latest_comm and organization
+ // nameSortIdx: 0–3 cycles through NAME_SORT_MODES; -1 means a non-name sort is active
+ const [nameSortIdx, setNameSortIdx] = useState(0) // default: name asc
+ const [sortKey, setSortKey] = useState('name')
+ const [sortDir, setSortDir] = useState('asc')
+
+ // Pagination
+ const [page, setPage] = useState(1)
+ const [pageSize, setPageSize] = useState(25)
+
+ // Column visibility + order
+ const [visibleKeys, setVisibleKeys] = useState(DEFAULT_VISIBLE)
+
+ // View mode: 'quick' | 'expanded' | 'card'
+ const [viewMode, setViewMode] = useState('quick')
+
+ // Show titles (Mr., Fr., etc.) in the Name column
+ const [showTitles, setShowTitles] = useState(true)
+
+ // Comm preview modal
+ const [viewComm, setViewComm] = useState(null)
+
+ // Status filter popover
+ const filterBtnRef = useRef(null)
+ const [filterOpen, setFilterOpen] = useState(false)
+ const [filterAnchor, setFilterAnchor] = useState(null)
+
+ // Settings cog popover
+ const cogBtnRef = useRef(null)
+ const [cogOpen, setCogOpen] = useState(false)
+ const [cogAnchor, setCogAnchor] = useState(null)
+
+ // ── Load prefs once ready ─────────────────────────────────────────────────
+ useEffect(() => {
+ if (!ready) return
+ if (prefs.status_filters !== undefined) setStatusFilters(prefs.status_filters)
+ if (prefs.sort_key !== undefined) {
+ setSortKey(prefs.sort_key)
+ setSortDir(prefs.sort_dir ?? 'asc')
+ // Restore nameSortIdx if it was a name/surname sort
+ const idx = NAME_SORT_MODES.findIndex(
+ (m) => m.sortKey === prefs.sort_key && m.sortDir === (prefs.sort_dir ?? 'asc')
+ )
+ setNameSortIdx(idx !== -1 ? idx : -1)
+ }
+ if (prefs.page_size !== undefined) setPageSize(prefs.page_size)
+ if (prefs.columns !== undefined) setVisibleKeys(prefs.columns)
+ if (prefs.view_mode !== undefined) setViewMode(prefs.view_mode)
+ if (prefs.show_titles !== undefined) setShowTitles(prefs.show_titles)
+ }, [ready]) // eslint-disable-line react-hooks/exhaustive-deps
+
+ // ── Fetch ─────────────────────────────────────────────────────────────────
+ const fetchCustomers = useCallback(async () => {
+ // First load (no data yet): show skeleton. Subsequent fetches: keep old data visible.
+ const isFirstLoad = customers.length === 0 && !error
+ if (isFirstLoad) setLoading(true)
+ setError('')
+ try {
+ const params = new URLSearchParams()
+ if (search) params.set('search', search)
+ const qs = params.toString()
+ const data = await api.get(`/crm/customers${qs ? `?${qs}` : ''}`)
+ const list = data.customers ?? []
+ setCustomers(list)
+
+ // Fetch latest comm for every customer in one batch request
+ if (list.length > 0) {
+ const ids = list.map((c) => c.id).join(',')
+ api.get(`/crm/comms/latest-batch?ids=${encodeURIComponent(ids)}`)
+ .then((batch) => setLatestComms(batch || {}))
+ .catch(() => {}) // non-critical — column just shows dashes
+ } else {
+ setLatestComms({})
+ }
+ } catch (err) {
+ setError(err.message || 'Failed to load customers.')
+ } finally {
+ setLoading(false)
+ }
+ }, [search]) // eslint-disable-line react-hooks/exhaustive-deps
+
+ useEffect(() => { fetchCustomers() }, [fetchCustomers])
+ useEffect(() => { setPage(1) }, [search, statusFilters])
+
+ // ── Column management ─────────────────────────────────────────────────────
+ const handleColumnChange = (keys) => {
+ setVisibleKeys(keys)
+ setPref('columns', keys)
+ }
+
+ // ── Sort helpers ──────────────────────────────────────────────────────────
+
+ // Called when the Name column header is clicked — cycles through 4 modes
+ function cycleNameSort() {
+ const nextIdx = (nameSortIdx + 1) % NAME_SORT_MODES.length
+ const mode = NAME_SORT_MODES[nextIdx]
+ setNameSortIdx(nextIdx)
+ setSortKey(mode.sortKey)
+ setSortDir(mode.sortDir)
+ setPrefBatch({ sort_key: mode.sortKey, sort_dir: mode.sortDir })
+ setPage(1)
+ }
+
+ // Called by DataTable's onSort for other sortable columns
+ function handleSort(key, dir) {
+ setSortKey(key)
+ setSortDir(dir)
+ setNameSortIdx(-1) // deactivate name cycling
+ setPrefBatch({ sort_key: key, sort_dir: dir })
+ setPage(1)
+ }
+
+ // ── Client-side filter ────────────────────────────────────────────────────
+
+ function customerMatchesFilter(c) {
+ if (statusFilters.length === 0) return true
+ const rel = c.relationship_status || 'lead'
+ const summary = c.crm_summary || {}
+ const activeOrderStatus = summary.active_order_status
+
+ return statusFilters.some((f) => {
+ if (!f.startsWith('active__')) return f === rel
+ // sub-filter: e.g. active__manufacturing
+ const subStatus = f.replace('active__', '')
+ return rel === 'active' && activeOrderStatus === subStatus
+ })
+ }
+
+ const filtered = customers.filter(customerMatchesFilter)
+
+ // ── Sort ──────────────────────────────────────────────────────────────────
+
+ function getSortValue(c, key) {
+ switch (key) {
+ case 'name': return (c.name || '').toLowerCase()
+ case 'surname': return (c.surname || '').toLowerCase()
+ case 'organization': return (c.organization || '').toLowerCase()
+ case 'latest_comm': {
+ const ts = latestComms[c.id]?.occurred_at
+ return ts ? new Date(ts).getTime() : 0
+ }
+ default: return 0
+ }
+ }
+
+ const sorted = sortKey
+ ? [...filtered].sort((a, b) => {
+ const va = getSortValue(a, sortKey)
+ const vb = getSortValue(b, sortKey)
+ if (va < vb) return sortDir === 'asc' ? -1 : 1
+ if (va > vb) return sortDir === 'asc' ? 1 : -1
+ return 0
+ })
+ : filtered
+
+ const total = sorted.length
+ const paged = sorted.slice((page - 1) * pageSize, page * pageSize)
+
+ // ── Name column label ─────────────────────────────────────────────────────
+ // Shows a small indicator when a name/surname sort is active
+ const nameSortMode = nameSortIdx >= 0 ? NAME_SORT_MODES[nameSortIdx] : null
+
+ // ── Column definitions ────────────────────────────────────────────────────
+
+ const allColumnDefs = [
+ ...ALL_COLUMNS_DEF.map((col) => {
+ switch (col.key) {
+
+ case 'name':
+ return {
+ ...col,
+ // Custom label with sort indicator
+ label: nameSortMode ? (
+
+ Name{' '}
+
+ ({nameSortMode.label})
+
+
+ ) : 'Name',
+ sortable: false, // handled manually via cycleNameSort
+ render: (c) => {
+ const parts = showTitles ? [c.title, c.name, c.surname] : [c.name, c.surname]
+ return (
+
+ {parts.filter(Boolean).join(' ') || Unnamed }
+
+ )
+ },
+ }
+
+ case 'status':
+ return {
+ ...col,
+ width: '52px',
+ render: (c) => {
+ const { icon, color, title } = resolveStatusIcon(c)
+ return (
+
+ {renderMaskedIcon(icon, color, title)}
+
+ )
+ },
+ }
+
+ case 'rel_status':
+ return {
+ ...col,
+ render: (c) => {
+ const rel = c.relationship_status || 'lead'
+ return (
+
+ {REL_STATUS_LABELS[rel] ?? rel}
+
+ )
+ },
+ }
+
+ case 'support':
+ return {
+ ...col,
+ width: '80px',
+ render: (c) => {
+ const summary = c.crm_summary || {}
+ const hasIssue = (summary.active_issues_count || 0) > 0
+ const hasSupport = (summary.active_support_count || 0) > 0
+ if (!hasIssue && !hasSupport) return null
+ return (
+
+ {hasIssue && (
+
+
+
+ {summary.active_issues_count}
+
+
+ )}
+ {hasSupport && (
+
+
+
+ {summary.active_support_count}
+
+
+ )}
+
+ )
+ },
+ }
+
+ case 'organization':
+ return {
+ ...col,
+ sortable: true,
+ render: (c) => c.organization
+ ? {c.organization}
+ : — ,
+ }
+
+ case 'location':
+ return {
+ ...col,
+ render: (c) => {
+ const loc = c.location || {}
+ const country = (loc.country || '').trim()
+ const city = (loc.city || '').trim()
+ // Omit country when it's Greece (either English or Greek name)
+ const isGreece = /^(greece|ελλάδα|ellada)$/i.test(country)
+ const display = isGreece
+ ? city
+ : [city, country].filter(Boolean).join(', ')
+ return display ? (
+
+ {display}
+
+ ) : —
+ },
+ }
+
+ case 'email':
+ return {
+ ...col,
+ render: (c) => {
+ const email = primaryContact(c, 'email')
+ return email
+ ? {email}
+ : —
+ },
+ }
+
+ case 'phone':
+ return {
+ ...col,
+ render: (c) => {
+ const phone = primaryContact(c, 'phone')
+ return phone
+ ? {phone}
+ : —
+ },
+ }
+
+ case 'latest_comm':
+ return {
+ ...col,
+ sortable: true,
+ render: (c) => {
+ const lc = latestComms[c.id]
+ const type = lc?.type
+ const date = lc?.occurred_at
+ const commId = lc?.id
+ if (!type || !date) return —
+ const meta = COMM_TYPE_META[type] || COMM_TYPE_META.note
+ return (
+
+ {
+ e.stopPropagation()
+ // Build a minimal comm entry object for MailViewModal
+ if (commId) {
+ setViewComm({ id: commId, type, date })
+ }
+ }}
+ title={`View ${meta.label} communication`}
+ style={{
+ display: 'inline-flex',
+ alignItems: 'center',
+ gap: 'var(--space-1)',
+ padding: '2px var(--space-2)',
+ borderRadius: 'var(--radius-sm)',
+ backgroundColor: meta.bg,
+ color: meta.color,
+ fontSize: 'var(--font-size-xs)',
+ fontWeight: 'var(--font-weight-medium)',
+ border: 'none',
+ cursor: commId ? 'pointer' : 'default',
+ flexShrink: 0,
+ lineHeight: 1.4,
+ }}
+ >
+ {meta.label}
+
+
+ {formatRelative(date)}
+
+
+ )
+ },
+ }
+
+ case 'tags':
+ return {
+ ...col,
+ render: (c) => {
+ const tags = c.tags || []
+ if (!tags.length) return —
+ return (
+
+ {tags.slice(0, 3).map((tag) => (
+
+ {tag}
+
+ ))}
+ {tags.length > 3 && (
+
+ +{tags.length - 3}
+
+ )}
+
+ )
+ },
+ }
+
+ case 'religion':
+ return {
+ ...col,
+ render: (c) => c.religion
+ ? {c.religion}
+ : — ,
+ }
+
+ case 'language':
+ return {
+ ...col,
+ render: (c) => (
+
+ {resolveLanguage(c.language)}
+
+ ),
+ }
+
+ default:
+ return col
+ }
+ }),
+
+ // Actions column — always last, always rendered
+ {
+ key: '__actions',
+ label: '',
+ width: '110px',
+ alwaysOn: true,
+ render: (c) => (
+ e.stopPropagation()} style={{ display: 'flex', justifyContent: 'flex-end' }}>
+ ,
+ onClick: () => navigate(`/crm/customers/${c.id}`),
+ },
+ ...(canEdit ? [{
+ label: 'Edit',
+ icon: ,
+ onClick: () => navigate(`/crm/customers/${c.id}/edit`),
+ }] : []),
+ ]}
+ />
+
+ ),
+ },
+ ]
+
+ // Name column header click handler — injected via a wrapper
+ // We intercept th clicks for the 'name' column in the DataTable via a modified column def
+ const nameColDef = allColumnDefs.find((c) => c.key === 'name')
+ if (nameColDef) {
+ nameColDef.onHeaderClick = cycleNameSort
+ nameColDef.sortable = false // DataTable won't handle it, we do it manually
+ }
+
+ const tableColumns = allColumnDefs.filter((col) => {
+ if (col.key === 'support' && viewMode === 'expanded') return false
+ return col.alwaysOn || visibleKeys.includes(col.key)
+ })
+ const pickerColumns = allColumnDefs.filter((col) => col.key !== '__actions')
+
+ // ── Expanded row sub-content ──────────────────────────────────────────────
+ function renderSubRows(c) {
+ const summary = c.crm_summary || {}
+ const activeOrderStatus = summary.active_order_status
+ const activeOrderNumber = summary.active_order_number
+ const activeOrderTitle = summary.active_order_title
+ const issueCount = summary.active_issues_count || 0
+ const supportCount = summary.active_support_count || 0
+ if (!activeOrderStatus && issueCount === 0 && supportCount === 0) return null
+ const { color: statusColor } = resolveStatusIcon(c)
+ const rows = []
+ if (activeOrderStatus) {
+ const label = ORDER_STATUS_LABELS[activeOrderStatus] || activeOrderStatus
+ rows.push(
+
+ {renderMaskedIcon(orderIcon, statusColor, label, 13)}
+
+ {['Order', activeOrderNumber, label].filter(Boolean).join(' · ')}
+ {activeOrderTitle && (
+ · {activeOrderTitle}
+ )}
+
+
+ )
+ }
+ if (issueCount > 0) {
+ rows.push(
+
+ {renderMaskedIcon(exclamationIcon, 'var(--color-danger)', 'Issue', 13)}
+
+ {issueCount} active technical issue{issueCount > 1 ? 's' : ''}
+
+
+ )
+ }
+ if (supportCount > 0) {
+ rows.push(
+
+ {renderMaskedIcon(wrenchIcon, 'var(--color-warning)', 'Support', 13)}
+
+ {supportCount} active support ticket{supportCount > 1 ? 's' : ''}
+
+
+ )
+ }
+ return rows
+ }
+
+ // ── Status filter badge label ─────────────────────────────────────────────
+ function filterBadgeLabel() {
+ if (statusFilters.length === 0) return 'All Statuses'
+ if (statusFilters.length === 1) {
+ const v = statusFilters[0]
+ if (v.startsWith('active__')) {
+ const sub = ACTIVE_SUB_OPTIONS.find((o) => o.value === v)
+ return sub ? `Active: ${sub.label}` : v
+ }
+ return REL_STATUS_LABELS[v] ?? v
+ }
+ return `${statusFilters.length} statuses`
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+
+ // Keep showing stale count while a search is in flight — no jank
+ const countLabel = loading
+ ? '\u00a0' // non-breaking space — holds line height on very first load
+ : filtered.length === 0
+ ? 'No customers found'
+ : `${filtered.length} ${filtered.length === 1 ? 'customer' : 'customers'}`
+
+ if (viewMode === 'card') {
+ return (
+
+
+ {canAdd && (
+ navigate('/crm/customers/new')}>
+ New Customer
+
+ )}
+
+
+
+
+
+
+
+ {/* Status filter button */}
+
{
+ if (filterBtnRef.current) {
+ setFilterAnchor(filterBtnRef.current.getBoundingClientRect())
+ }
+ setFilterOpen((v) => !v)
+ }}
+ style={{ flexShrink: 0, position: 'relative', padding: 'var(--space-3) var(--space-4)', lineHeight: 'var(--line-height-base)' }}
+ >
+ {filterBadgeLabel()}
+ {statusFilters.length > 0 && (
+
+ {statusFilters.length}
+
+ )}
+
+
+
{
+ setStatusFilters(vals)
+ setPref('status_filters', vals)
+ }}
+ onClose={() => setFilterOpen(false)}
+ />
+
+ { setViewMode(v); setPref('view_mode', v) }} />
+
+ {/* Settings cog */}
+ {
+ if (cogBtnRef.current) setCogAnchor(cogBtnRef.current.getBoundingClientRect())
+ setCogOpen((v) => !v)
+ }}
+ style={{ flexShrink: 0, padding: 'var(--space-3)', lineHeight: 'var(--line-height-base)' }}
+ >
+
+
+
+ {
+ const next = !showTitles
+ setShowTitles(next)
+ setPref('show_titles', next)
+ }}
+ onClose={() => setCogOpen(false)}
+ />
+
+
+ {!loading && total > 0 && (
+
{ setPageSize(s); setPage(1); setPref('page_size', s) }}
+ pageSizes={[10, 25, 50, 100]}
+ />
+ )}
+
+ navigate(`/crm/customers/${c.id}`)}
+ onEdit={(c) => navigate(`/crm/customers/${c.id}/edit`)}
+ />
+
+ )
+ }
+
+ return (
+
+
+
+ {canAdd && (
+ navigate('/crm/customers/new')}>
+ New Customer
+
+ )}
+
+
+ {/* Filter toolbar */}
+
+
+
+
+
+ {/* Status filter button */}
+
{
+ if (filterBtnRef.current) {
+ setFilterAnchor(filterBtnRef.current.getBoundingClientRect())
+ }
+ setFilterOpen((v) => !v)
+ }}
+ style={{ flexShrink: 0, position: 'relative', padding: 'var(--space-3) var(--space-4)', lineHeight: 'var(--line-height-base)' }}
+ >
+ {filterBadgeLabel()}
+ {statusFilters.length > 0 && (
+
+ {statusFilters.length}
+
+ )}
+
+
+
{
+ setStatusFilters(vals)
+ setPref('status_filters', vals)
+ }}
+ onClose={() => setFilterOpen(false)}
+ />
+
+ { setViewMode(v); setPref('view_mode', v) }}
+ />
+
+ {/* Settings cog */}
+ {
+ if (cogBtnRef.current) setCogAnchor(cogBtnRef.current.getBoundingClientRect())
+ setCogOpen((v) => !v)
+ }}
+ style={{ flexShrink: 0, padding: 'var(--space-3)', lineHeight: 'var(--line-height-base)' }}
+ >
+
+
+
+ {
+ const next = !showTitles
+ setShowTitles(next)
+ setPref('show_titles', next)
+ }}
+ onClose={() => setCogOpen(false)}
+ />
+
+
+ {/* Pagination — above the table */}
+ {!loading && total > 0 && (
+
{ setPageSize(s); setPage(1); setPref('page_size', s) }}
+ pageSizes={[10, 25, 50, 100]}
+ />
+ )}
+
+ {/* Error banner */}
+ {error && (
+
+ {error}
+
+ )}
+
+ {/* Table */}
+ navigate(`/crm/customers/${c.id}`)}
+ onRowMiddleClick={(c) => window.open(`/crm/customers/${c.id}`, '_blank', 'noopener,noreferrer')}
+ skeletonRows={10}
+ sortKey={sortKey}
+ sortDir={sortDir}
+ onSort={handleSort}
+ onNameColHeaderClick={cycleNameSort}
+ allColumns={pickerColumns}
+ visibleKeys={visibleKeys}
+ onColumnChange={handleColumnChange}
+ renderSubRows={viewMode === 'expanded' ? renderSubRows : undefined}
+ expandedRowPadding={true}
+ />
+
+ {/* Comm preview modal */}
+ {viewComm && (
+ setViewComm(null)}
+ />
+ )}
+
+
+ )
+}
+
+// ─── ViewModeSelector ─────────────────────────────────────────────────────────
+
+function ViewModeSelector({ value, onChange }) {
+ const options = [
+ { value: 'quick', label: 'Quick' },
+ { value: 'expanded', label: 'Expanded' },
+ { value: 'card', label: 'Card View' },
+ ]
+ return (
+
+ {options.map((opt, i) => (
+ onChange(opt.value)}
+ style={{
+ padding: 'var(--space-3) var(--space-4)',
+ fontSize: 'var(--font-size-base)',
+ fontWeight: value === opt.value ? 'var(--font-weight-semibold)' : 'var(--font-weight-normal)',
+ color: value === opt.value ? 'var(--color-text-primary)' : 'var(--color-text-muted)',
+ backgroundColor: value === opt.value ? 'var(--color-bg-island)' : 'transparent',
+ border: 'none',
+ borderRight: i < options.length - 1 ? '1px solid var(--color-border-strong)' : 'none',
+ cursor: 'pointer',
+ transition: 'background-color 0.15s, color 0.15s',
+ whiteSpace: 'nowrap',
+ lineHeight: 'var(--line-height-base)',
+ }}
+ >
+ {opt.label}
+
+ ))}
+
+ )
+}
+
+// ─── SettingsCogMenu ──────────────────────────────────────────────────────────
+
+function SettingsCogMenu({ open, anchorRect, showTitles, onToggleTitles, onClose }) {
+ const ref = useRef(null)
+
+ useEffect(() => {
+ if (!open) return
+ function onDown(e) {
+ if (ref.current && !ref.current.contains(e.target)) onClose()
+ }
+ document.addEventListener('mousedown', onDown)
+ return () => document.removeEventListener('mousedown', onDown)
+ }, [open, onClose])
+
+ if (!open || !anchorRect) return null
+
+ return createPortal(
+
+
+ {}}
+ style={{ accentColor: 'var(--color-primary)', flexShrink: 0, cursor: 'pointer' }}
+ />
+ Show Titles (Mr., Fr., etc.)
+
+
,
+ document.body
+ )
+}
+
+// ─── CommPreviewLoader ────────────────────────────────────────────────────────
+// Fetches the full comm entry then opens MailViewModal (which handles all types)
+
+function CommPreviewLoader({ commId, onClose }) {
+ const [entry, setEntry] = useState(null)
+
+ useEffect(() => {
+ api.get(`/crm/comms/${commId}`)
+ .then(setEntry)
+ .catch(() => onClose())
+ }, [commId, onClose])
+
+ if (!entry) return null
+
+ return (
+ {}}
+ />
+ )
+}
diff --git a/frontend/src/pages/crm/customers/CustomerListCardView.jsx b/frontend/src/pages/crm/customers/CustomerListCardView.jsx
new file mode 100644
index 0000000..d92f5cc
--- /dev/null
+++ b/frontend/src/pages/crm/customers/CustomerListCardView.jsx
@@ -0,0 +1,827 @@
+// frontend/src/pages/crm/customers/CustomerListCardView.jsx
+// Card view for the Customer List — rendered when viewMode === 'card'
+
+import { useState, useRef, useEffect } from 'react'
+import { createPortal } from 'react-dom'
+import Icon from '@/components/ui/Icon'
+import { fmtRelative } from '@/lib/formatters'
+import ComposeEmailModal from '@/modals/crm/ComposeEmailModal'
+
+// ─── Comm icon raw SVG imports ────────────────────────────────────────────────
+
+import commEmailIcon from '@/assets/comms/email.svg?raw'
+import commWhatsappIcon from '@/assets/comms/whatsapp.svg?raw'
+import commCallIcon from '@/assets/comms/call.svg?raw'
+import commSmsIcon from '@/assets/comms/sms.svg?raw'
+import commNoteIcon from '@/assets/comms/note.svg?raw'
+import commInPersonIcon from '@/assets/comms/inperson.svg?raw'
+
+// ─── Status icon raw SVG imports ──────────────────────────────────────────────
+
+import clientIcon from '@/assets/customer-status/client.svg?raw'
+import negotiatingIcon from '@/assets/customer-status/negotiating.svg?raw'
+import awaitingQuotationIcon from '@/assets/customer-status/awating-quotation.svg?raw'
+import awaitingConfirmIcon from '@/assets/customer-status/awaiting-confirmation.svg?raw'
+import quotationAcceptedIcon from '@/assets/customer-status/quotation-accepted.svg?raw'
+import startedMfgIcon from '@/assets/customer-status/started-mfg.svg?raw'
+import awaitingPaymentIcon from '@/assets/customer-status/awaiting-payment.svg?raw'
+import shippedIcon from '@/assets/customer-status/shipped.svg?raw'
+import inactiveIcon from '@/assets/customer-status/inactive.svg?raw'
+import declinedIcon from '@/assets/customer-status/declined.svg?raw'
+import churnedIcon from '@/assets/customer-status/churned.svg?raw'
+import exclamationIcon from '@/assets/customer-status/exclamation.svg?raw'
+import wrenchIcon from '@/assets/customer-status/wrench.svg?raw'
+
+// ─── Constants ────────────────────────────────────────────────────────────────
+
+const COMM_TYPE_META = {
+ email: { label: 'Email', color: 'var(--color-info)', icon: commEmailIcon },
+ whatsapp: { label: 'WhatsApp', color: 'var(--color-success)', icon: commWhatsappIcon },
+ call: { label: 'Call', color: 'var(--color-warning)', icon: commCallIcon },
+ sms: { label: 'SMS', color: 'var(--color-warning)', icon: commSmsIcon },
+ note: { label: 'Note', color: 'var(--color-text-muted)', icon: commNoteIcon },
+ in_person: { label: 'In Person', color: 'var(--color-primary)', icon: commInPersonIcon },
+}
+
+// ─── Helpers ──────────────────────────────────────────────────────────────────
+
+// Strips all hardcoded fill colours from SVG markup so fill:currentColor takes effect.
+function sanitiseSvg(svgRaw) {
+ return svgRaw
+ // Remove XML/DOCTYPE/comment cruft
+ .replace(/<\?xml[\s\S]*?\?>/gi, '')
+ .replace(//gi, '')
+ .replace(//g, '')
+ // Remove
+
+ {Array.from({ length: 12 }).map((_, i) => )}
+
+ >
+ )
+ }
+
+ if (!customers.length) {
+ return (
+ <>
+
+
+
+
No customers found
+
Try adjusting your search or filters.
+
+ >
+ )
+ }
+
+ return (
+ <>
+
+
+ {customers.map((c) => (
+ onNavigate(c)}
+ onEdit={() => onEdit(c)}
+ />
+ ))}
+
+ >
+ )
+}
+
+// ─── Styles ───────────────────────────────────────────────────────────────────
+
+const CARD_STYLES = `
+
+/* ── Grid ───────────────────────────────────────────────────────────────────── */
+.ccard-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(500px, 1fr));
+ gap: var(--space-4);
+ align-items: flex-start;
+}
+
+/* ── Card shell ─────────────────────────────────────────────────────────────── */
+.ccard {
+ height: 250px;
+ display: flex;
+ flex-direction: row;
+ background-color: rgba(28, 32, 38, 0.30);
+ backdrop-filter: var(--blur-modal);
+ -webkit-backdrop-filter: var(--blur-modal);
+ border: 0px solid var(--color-border);
+ border-radius: var(--radius-lg);
+ box-shadow: var(--shadow-card), var(--shadow-sm);
+ cursor: pointer;
+ transition: background-color 200ms ease, border-color 200ms ease,
+ box-shadow 200ms ease, transform 200ms;
+ overflow: hidden;
+ user-select: none;
+ outline: none;
+ box-sizing: border-box;
+ position: relative;
+}
+.ccard:hover {
+ background-color: rgba(49, 53, 60, 0.50);
+ border-color: var(--color-border-strong);
+ box-shadow: var(--shadow-card), var(--shadow-md);
+ transform: scale(1.03);
+}
+.ccard:focus-visible {
+ border-color: var(--color-border-focus);
+ box-shadow: var(--shadow-focus);
+}
+.ccard:active {
+ transform: translateY(0) scale(1);
+}
+
+/* ── Watermark icon ─────────────────────────────────────────────────────────── */
+.ccard__watermark {
+ position: absolute;
+ bottom: -20%;
+ right: -30%;
+ width: 100%;
+ height: 100%;
+ opacity: 0.02;
+ pointer-events: none;
+ z-index: 0;
+ display: flex;
+ align-items: flex-end;
+ justify-content: flex-end;
+}
+.ccard__watermark svg {
+ width: 100%;
+ height: 100%;
+}
+.ccard > *:not(.ccard__watermark) {
+ position: relative;
+ z-index: 1;
+}
+
+/* ── Accent bar — vertical, left side ──────────────────────────────────────── */
+.ccard__accent-wrap {
+ padding: var(--space-6) 0 var(--space-6) var(--space-6);
+ flex-shrink: 0;
+ display: flex;
+}
+.ccard__accent-bar {
+ width: 10px;
+ border-radius: var(--radius-sm);
+ transition: opacity 100ms ease;
+ align-self: stretch;
+}
+
+/* ── Body — space-between so 4 rows spread evenly ──────────────────────────── */
+.ccard__body {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+ padding: var(--space-6) var(--space-6) var(--space-6);
+ min-height: 0;
+ overflow: hidden;
+}
+
+/* ── Name container — dark inset pill behind name + sub ─────────────────────── */
+.ccard__name-container {
+ background-color: rgba(0, 0, 0, 0.28);
+ border-radius: var(--radius-md);
+ padding: var(--space-3) var(--space-4);
+ margin: 0 calc(-1 * var(--space-1));
+}
+
+/* ── Header: name block + dots ─────────────────────────────────────────────── */
+.ccard__header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--space-2);
+ min-width: 0;
+}
+.ccard__name-block {
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+}
+.ccard .ccard__name {
+ font-family: var(--font-family-display);
+ font-size: var(--font-size-xl);
+ font-weight: var(--font-weight-bold);
+ color: #ffffff;
+ letter-spacing: var(--tracking-tight);
+ line-height: 1.2;
+ margin: 0;
+ overflow: hidden;
+}
+.ccard .ccard__sub {
+ font-family: var(--font-family-base);
+ font-size: var(--font-size-md);
+ font-weight: var(--font-weight-medium);
+ color: var(--color-text-more-muted);
+ margin: 0;
+ margin-top: 3px;
+ overflow: hidden;
+ line-height: 1.4;
+ min-height: 1.4em;
+}
+
+/* ── Marquee — scrolls text only when it actually overflows ─────────────────── */
+.ccard__name,
+.ccard__sub,
+.ccard__status-label {
+ white-space: nowrap;
+}
+@keyframes ccard-marquee {
+ 0% { transform: translateX(0); }
+ 20% { transform: translateX(0); }
+ 80% { transform: translateX(var(--marquee-shift, -30%)); }
+ 100% { transform: translateX(var(--marquee-shift, -30%)); }
+}
+.ccard__marquee-inner {
+ display: inline-block;
+}
+/* Animation only fires when JS detects overflow and sets data-overflow="true" on the wrapper */
+.ccard__name[data-overflow="true"]:hover .ccard__marquee-inner,
+.ccard__sub[data-overflow="true"]:hover .ccard__marquee-inner,
+.ccard__status-label[data-overflow="true"]:hover .ccard__marquee-inner {
+ animation: ccard-marquee 4s ease-in-out infinite alternate;
+}
+
+/* ── 3-dot button ───────────────────────────────────────────────────────────── */
+.ccard__dots {
+ flex-shrink: 0;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 32px;
+ height: 32px;
+ border-radius: var(--radius-full);
+ background: none;
+ border: none;
+ color: var(--color-text-secondary);
+ cursor: pointer;
+ transition: background-color 120ms ease, color 120ms ease;
+ padding: 0;
+}
+.ccard__dots:hover {
+ background-color: rgba(255, 255, 255, 0.20);
+ box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.15);
+ color: var(--color-text-primary);
+}
+.ccard__dots:focus-visible {
+ outline: 2px solid var(--color-border-focus);
+ outline-offset: 1px;
+}
+
+/* ── Status primary row ─────────────────────────────────────────────────────── */
+.ccard__status-primary {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ min-width: 0;
+ overflow: hidden;
+ margin-top: var(--space-3);
+ padding-top: var(--space-2);
+ border-top: 1px solid rgba(255, 255, 255, 0.07);
+}
+.ccard__status-label {
+ font-family: var(--font-family-display);
+ font-size: var(--font-size-md);
+ font-weight: var(--font-weight-semibold);
+ line-height: 1.2;
+ overflow: hidden;
+ min-width: 0;
+ flex: 1;
+}
+
+/* ── Tags row — always reserves its height ──────────────────────────────────── */
+.ccard__tags {
+ display: flex;
+ flex-wrap: nowrap;
+ gap: var(--space-2);
+ overflow: hidden;
+ min-height: 24px;
+ align-items: center;
+}
+.ccard__tag {
+ display: inline-flex;
+ align-items: center;
+ padding: 2px var(--space-2);
+ font-family: var(--font-family-base);
+ font-size: var(--font-size-xs);
+ font-weight: var(--font-weight-medium);
+ color: var(--color-text-secondary);
+ background-color: var(--color-bg-elevated);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-full);
+ line-height: 1.5;
+ white-space: nowrap;
+ flex-shrink: 0;
+}
+.ccard__tag--more {
+ color: var(--color-text-muted);
+ background-color: transparent;
+ border-color: transparent;
+}
+.ccard__tag-placeholder {
+ display: inline-block;
+ height: 22px;
+}
+
+/* ── Footer row: comm · issues · support ────────────────────────────────────── */
+.ccard__footer-row {
+ display: flex;
+ align-items: center;
+ gap: var(--space-5);
+ flex-shrink: 0;
+ flex-wrap: nowrap;
+ overflow: hidden;
+ min-height: 18px;
+}
+.ccard__footer-group {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--space-1);
+ flex-shrink: 0;
+}
+.ccard__footer-label {
+ font-family: var(--font-family-base);
+ font-size: var(--font-size-xs);
+ font-weight: var(--font-weight-semibold);
+ line-height: 1.4;
+ white-space: nowrap;
+}
+.ccard__footer-time {
+ font-family: var(--font-family-base);
+ font-size: var(--font-size-xs);
+ color: var(--color-text-muted);
+ white-space: nowrap;
+ line-height: 1.4;
+}
+.ccard__no-comm {
+ font-family: var(--font-family-base);
+ font-size: var(--font-size-xs);
+ color: var(--color-text-muted);
+ font-style: italic;
+}
+
+/* ── Actions dropdown ───────────────────────────────────────────────────────── */
+.ccard-actions-menu {
+ min-width: 140px;
+ background-color: var(--color-bg-elevated);
+ border: 1px solid var(--color-border-strong);
+ border-radius: var(--radius-lg);
+ box-shadow: var(--shadow-lg);
+ padding: var(--space-1) 0;
+ overflow: hidden;
+}
+.ccard-actions-item {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ width: 100%;
+ padding: var(--space-2) var(--space-3);
+ font-family: var(--font-family-base);
+ font-size: var(--font-size-sm);
+ color: var(--color-text-secondary);
+ background: none;
+ border: none;
+ cursor: pointer;
+ text-align: left;
+ transition: background-color 80ms ease, color 80ms ease;
+}
+.ccard-actions-item:hover {
+ background-color: var(--color-bg-island);
+ color: var(--color-text-primary);
+}
+.ccard-actions-item__icon {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ opacity: 0.75;
+}
+.ccard-actions-item:hover .ccard-actions-item__icon {
+ opacity: 1;
+}
+
+/* ── Empty state ────────────────────────────────────────────────────────────── */
+.ccard-empty {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: var(--space-3);
+ padding: var(--space-16) var(--space-8);
+ text-align: center;
+ width: 100%;
+}
+.ccard-empty__title {
+ font-family: var(--font-family-base);
+ font-size: var(--font-size-md);
+ font-weight: var(--font-weight-semibold);
+ color: var(--color-text-secondary);
+ margin: 0;
+}
+.ccard-empty__sub {
+ font-family: var(--font-family-base);
+ font-size: var(--font-size-sm);
+ color: var(--color-text-muted);
+ margin: 0;
+}
+
+/* ── Skeleton shimmer ───────────────────────────────────────────────────────── */
+.ccard--skeleton {
+ cursor: default;
+ pointer-events: none;
+}
+.skel-line,
+.skel-circle {
+ background: linear-gradient(
+ 90deg,
+ var(--color-bg-elevated) 0%,
+ var(--color-bg-island) 50%,
+ var(--color-bg-elevated) 100%
+ );
+ background-size: 200% 100%;
+ animation: skel-shimmer 1.4s ease-in-out infinite;
+ border-radius: var(--radius-sm);
+}
+.skel-circle { border-radius: var(--radius-full); }
+@keyframes skel-shimmer {
+ 0% { background-position: 200% 0; }
+ 100% { background-position: -200% 0; }
+}
+
+/* ── Responsive ─────────────────────────────────────────────────────────────── */
+@media (max-width: 768px) {
+ .ccard-grid {
+ grid-template-columns: 1fr;
+ gap: var(--space-3);
+ }
+ .ccard { height: auto; min-height: 240px; }
+}
+`
diff --git a/frontend/src/pages/crm/customers/tabs/CommunicationTab.jsx b/frontend/src/pages/crm/customers/tabs/CommunicationTab.jsx
new file mode 100644
index 0000000..0dd3b74
--- /dev/null
+++ b/frontend/src/pages/crm/customers/tabs/CommunicationTab.jsx
@@ -0,0 +1,439 @@
+// frontend/src/pages/crm/customers/tabs/CommunicationTab.jsx
+// Customer-scoped communications log — same card/timeline UI as the global CommsPage,
+// but filtered to this customer only. Fetches via GET /crm/comms?customer_id=...
+
+import { useState, useEffect, useCallback } from 'react'
+import api from '@/lib/api'
+import { useAuth } from '@/hooks/useAuth'
+import { useToast } from '@/components/ui/Toast'
+import Button from '@/components/ui/Button'
+import Spinner from '@/components/ui/Spinner'
+import SearchBar from '@/components/ui/SearchBar'
+import Select from '@/components/ui/Select'
+import ConfirmDialog from '@/components/ui/ConfirmDialog'
+import MailViewModal from '@/modals/crm/MailViewModal'
+import ComposeEmailModal from '@/modals/crm/ComposeEmailModal'
+import EditCommsEntryModal from '@/modals/crm/EditCommsEntryModal'
+import LogCommsEntryModal from '@/modals/crm/LogCommsEntryModal'
+import { fmtRelative, fmtDateTime as fmtDateTimeCentral } from '@/lib/formatters'
+
+// ─── Constants ────────────────────────────────────────────────────────────────
+
+const TYPE_META = {
+ email: { label: 'Email', icon: '/src/assets/comms/email.svg', color: 'var(--color-info)', bg: 'var(--color-info-bg)' },
+ whatsapp: { label: 'WhatsApp', icon: '/src/assets/comms/whatsapp.svg', color: 'var(--color-success)', bg: 'var(--color-success-bg)' },
+ call: { label: 'Call', icon: '/src/assets/comms/call.svg', color: 'var(--color-warning)', bg: 'var(--color-warning-bg)' },
+ sms: { label: 'SMS', icon: '/src/assets/comms/sms.svg', color: 'var(--color-warning)', bg: 'var(--color-warning-bg)' },
+ note: { label: 'Note', icon: '/src/assets/comms/note.svg', color: 'var(--color-text-muted)', bg: 'var(--color-bg-elevated)' },
+ in_person: { label: 'In person', icon: '/src/assets/comms/inperson.svg', color: 'var(--color-primary)', bg: 'var(--color-primary-subtle)' },
+}
+
+const DIR_META = {
+ inbound: { label: 'Inbound', icon: '/src/assets/comms/inbound.svg', color: 'var(--color-success)' },
+ outbound: { label: 'Outbound', icon: '/src/assets/comms/outbound.svg', color: 'var(--color-info)' },
+ internal: { label: 'Internal', icon: '/src/assets/comms/internal.svg', color: 'var(--color-text-muted)' },
+}
+
+const COMMS_TYPES = ['email', 'whatsapp', 'call', 'sms', 'note', 'in_person']
+
+// ─── Helpers ──────────────────────────────────────────────────────────────────
+
+function formatRelative(val) { return fmtRelative(val) }
+function formatFull(val) { return fmtDateTimeCentral(val) }
+
+// ─── SVG icons ────────────────────────────────────────────────────────────────
+
+const IconExpand = () =>
+const IconReply = () =>
+const IconEdit = () =>
+const IconDelete = () =>
+
+// ─── Type icon badge ──────────────────────────────────────────────────────────
+
+function TypeIconBadge({ type }) {
+ const meta = TYPE_META[type] || TYPE_META.note
+ return (
+
+
+
+ )
+}
+
+function DirBadge({ direction }) {
+ const meta = DIR_META[direction] || DIR_META.internal
+ return (
+
+
+
+ {meta.label}
+
+
+ )
+}
+
+// ─── Entry card ───────────────────────────────────────────────────────────────
+
+function EntryCard({ entry, expanded, canEdit, onToggle, onDelete, onEdit, onView, onReply }) {
+ const [hovered, setHovered] = useState(false)
+ const meta = TYPE_META[entry.type] || { label: entry.type, color: 'var(--color-text-muted)' }
+
+ return (
+ setHovered(true)}
+ onMouseLeave={() => setHovered(false)}
+ style={{
+ backgroundColor: 'var(--color-bg-surface)',
+ border: '1px solid var(--color-border)',
+ borderRadius: 'var(--radius-lg)',
+ overflow: 'hidden',
+ transition: 'border-color 0.15s',
+ position: 'relative',
+ }}
+ >
+ {/* Hover overlay — gradient + actions (collapsed only) */}
+ {hovered && !expanded && (
+
+
+
+
+ {entry.direction === 'inbound' ? 'Received' : entry.direction === 'outbound' ? 'Sent' : 'Logged'} via {meta.label}
+
+
+ {formatFull(entry.occurred_at)}
+
+
+
+
+ { e.stopPropagation(); if (entry.type === 'email') onView(entry) }}
+ style={{ minWidth: 100, justifyContent: 'center' }}>
+ Full view
+
+ { e.stopPropagation(); onReply(entry.from_addr || '', entry.mailbox_account) }}
+ style={{ minWidth: 100, justifyContent: 'center' }}>
+ Reply
+
+
+ {canEdit && (
+ <>
+
+
+ { e.stopPropagation(); onEdit(entry) }}
+ style={{ minWidth: 100, justifyContent: 'center' }}>
+ Edit
+
+ { e.stopPropagation(); onDelete(entry.id) }}
+ style={{ minWidth: 100, justifyContent: 'center' }}>
+ Delete
+
+
+ >
+ )}
+
+
+ )}
+
+ {/* Card header row */}
+
entry.body && onToggle(entry.id)}
+ style={{
+ display: 'grid',
+ gridTemplateColumns: '44px 1fr auto',
+ gap: 'var(--space-4)',
+ padding: 'var(--space-4) var(--space-5)',
+ cursor: entry.body ? 'pointer' : 'default',
+ alignItems: 'flex-start',
+ }}
+ >
+
+
+
+
+ {meta.label}
+
+ ·
+
+
+ {entry.subject && (
+
+ {entry.subject}
+
+ )}
+ {entry.body && !expanded && (
+
+ {entry.body.replace(/<[^>]*>/g, '').slice(0, 140)}
+
+ )}
+ {entry.logged_by && (
+
+ by {entry.logged_by}
+
+ )}
+
+
+
+ {formatRelative(entry.occurred_at)}
+
+ {entry.attachments?.length > 0 && (
+
+ {entry.attachments.length} file{entry.attachments.length !== 1 ? 's' : ''}
+
+ )}
+
+
+
+ {/* Expanded body */}
+ {expanded && entry.body && (
+
+
+ {entry.body.replace(/<[^>]*>/g, '')}
+
+
+ )}
+
+ {/* Expanded action bar */}
+ {expanded && (
+
+
+ {entry.direction === 'inbound' ? 'Received' : entry.direction === 'outbound' ? 'Sent' : 'Logged'} via {meta.label} · {formatFull(entry.occurred_at)}
+
+ { e.stopPropagation(); if (entry.type === 'email') onView(entry) }}>
+ Full view
+
+ { e.stopPropagation(); onReply(entry.from_addr || '', entry.mailbox_account) }}>
+ Reply
+
+ {canEdit && (
+ <>
+ { e.stopPropagation(); onEdit(entry) }}>
+ Edit
+
+ { e.stopPropagation(); onDelete(entry.id) }}>
+ Delete
+
+ >
+ )}
+
+ )}
+
+ )
+}
+
+// ─── Main component ───────────────────────────────────────────────────────────
+
+export default function CommunicationTab({ customer, canEdit }) {
+ const { toast } = useToast()
+
+ const [entries, setEntries] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState('')
+
+ const [typeFilter, setTypeFilter] = useState('')
+ const [search, setSearch] = useState('')
+
+ const [expandedId, setExpandedId] = useState(null)
+ const [deleteId, setDeleteId] = useState(null)
+ const [deleting, setDeleting] = useState(false)
+
+ const [viewEntry, setViewEntry] = useState(null)
+ const [composeOpen, setComposeOpen] = useState(false)
+ const [composeTo, setComposeTo] = useState('')
+ const [composeAccount, setComposeAccount] = useState('')
+ const [editEntry, setEditEntry] = useState(null)
+ const [logOpen, setLogOpen] = useState(false)
+
+ const load = useCallback(async () => {
+ setLoading(true)
+ setError('')
+ try {
+ const params = new URLSearchParams({ customer_id: customer.id })
+ if (typeFilter) params.set('type', typeFilter)
+ const data = await api.get(`/crm/comms?${params}`)
+ const list = (data.entries || []).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
+ return tb - ta
+ })
+ setEntries(list)
+ } catch (err) {
+ setError(err.message || 'Failed to load communications.')
+ } finally {
+ setLoading(false)
+ }
+ }, [customer.id, typeFilter])
+
+ useEffect(() => { load() }, [load])
+
+ const handleDelete = async () => {
+ if (!deleteId) return
+ setDeleting(true)
+ try {
+ await api.delete(`/crm/comms/${deleteId}`)
+ toast.success('Deleted', 'Entry removed.')
+ setDeleteId(null)
+ await load()
+ } catch (err) {
+ toast.danger('Error', err.message || 'Failed to delete.')
+ } finally {
+ setDeleting(false)
+ }
+ }
+
+ const openReply = (toAddr, sourceAccount = '') => {
+ setViewEntry(null)
+ setComposeTo(toAddr || '')
+ setComposeAccount(sourceAccount || '')
+ setComposeOpen(true)
+ }
+
+ // Client-side search filter
+ const q = search.trim().toLowerCase()
+ const filtered = entries.filter(e => {
+ if (!q) return true
+ return (
+ (e.subject || '').toLowerCase().includes(q) ||
+ (e.body || '').replace(/<[^>]*>/g, '').toLowerCase().includes(q) ||
+ (e.from_addr || '').toLowerCase().includes(q) ||
+ (e.logged_by || '').toLowerCase().includes(q)
+ )
+ })
+
+ return (
+
+ {/* Toolbar */}
+
+
+
+
+
+ All types
+ {COMMS_TYPES.map(t => (
+ {TYPE_META[t]?.label || t}
+ ))}
+
+ {(search || typeFilter) && (
+
{ setSearch(''); setTypeFilter('') }}>Clear
+ )}
+
+ setLogOpen(true)}>
+ + Log Entry
+
+ { setComposeTo(''); setComposeOpen(true) }}>
+ + Compose Email
+
+
+
+
+ {/* States */}
+ {loading && (
+
+
+
+ )}
+
+ {!loading && error && (
+
+ {error}
+
+ )}
+
+ {!loading && !error && filtered.length === 0 && (
+
+
+
+
+
+ {search || typeFilter ? 'No matching communications.' : 'No communications logged yet.'}
+
+ {!search && !typeFilter && (
+
+ Use "Send Message" from the action menu or Compose Email above to get started.
+
+ )}
+
+ )}
+
+ {!loading && !error && filtered.length > 0 && (
+
+ {filtered.map(entry => (
+ setExpandedId(prev => prev === id ? null : id)}
+ onDelete={id => setDeleteId(id)}
+ onEdit={e => setEditEntry(e)}
+ onView={e => setViewEntry(e)}
+ onReply={openReply}
+ />
+ ))}
+
+ )}
+
+ {/* Modals */}
+
setViewEntry(null)}
+ onReply={addr => openReply(addr)}
+ />
+
+ setComposeOpen(false)}
+ onSent={() => { setComposeOpen(false); load() }}
+ />
+
+ setEditEntry(null)}
+ onSaved={() => { setEditEntry(null); load() }}
+ />
+
+ setLogOpen(false)}
+ onSaved={() => { setLogOpen(false); load() }}
+ />
+
+ setDeleteId(null)}
+ />
+
+ )
+}
diff --git a/frontend/src/pages/crm/customers/tabs/FilesTab.jsx b/frontend/src/pages/crm/customers/tabs/FilesTab.jsx
new file mode 100644
index 0000000..33c1894
--- /dev/null
+++ b/frontend/src/pages/crm/customers/tabs/FilesTab.jsx
@@ -0,0 +1,1465 @@
+// frontend/src/pages/crm/customers/tabs/FilesTab.jsx
+
+import { useState, useEffect, useCallback, useRef } from 'react'
+import { createPortal } from 'react-dom'
+import api from '@/lib/api'
+import { useToast } from '@/components/ui/Toast'
+import Button from '@/components/ui/Button'
+import SearchBar from '@/components/ui/SearchBar'
+import Spinner from '@/components/ui/Spinner'
+import ConfirmDialog from '@/components/ui/ConfirmDialog'
+import { fmtDateMedium } from '@/lib/formatters'
+import Icon from '@/components/ui/Icon'
+
+// ─── Category colour system ────────────────────────────────────────────────────
+
+const CATEGORIES = [
+ { value: 'all', label: 'All', color: null },
+ { value: 'sent_media', label: 'Sent', color: { bg: 'rgba(201,168,76,0.12)', text: '#c9a84c', border: 'rgba(107,90,42,0.6)', dot: '#c9a84c' } },
+ { value: 'received_media', label: 'Received', color: { bg: 'rgba(184,146,46,0.10)', text: '#b8922e', border: 'rgba(92,75,26,0.6)', dot: '#b8922e' } },
+ { value: 'media', label: 'General', color: { bg: 'rgba(212,168,67,0.10)', text: '#d4a843', border: 'rgba(107,90,42,0.5)', dot: '#d4a843' } },
+ { value: 'invoices', label: 'Invoices', color: { bg: 'rgba(196,112,112,0.10)', text: '#c47070', border: 'rgba(107,46,46,0.5)', dot: '#c47070' } },
+ { value: 'quotations', label: 'Quotations', color: { bg: 'rgba(106,171,122,0.10)', text: '#6aab7a', border: 'rgba(46,94,56,0.5)', dot: '#6aab7a' } },
+ { value: 'documents', label: 'Documents', color: { bg: 'rgba(167,139,250,0.10)', text: '#a78bfa', border: 'rgba(91,33,182,0.5)', dot: '#a78bfa' } },
+]
+
+const SORT_OPTIONS = [
+ { value: 'date_desc', label: 'Date ↓' },
+ { value: 'date_asc', label: 'Date ↑' },
+ { value: 'name_asc', label: 'Name A–Z' },
+ { value: 'name_desc', label: 'Name Z–A' },
+ { value: 'size_desc', label: 'Size ↓' },
+ { value: 'size_asc', label: 'Size ↑' },
+]
+
+const PAGE_SIZES = [20, 50, 100]
+
+const SUBFOLDER_TO_CATEGORY = {
+ sent_media: 'sent_media', received_media: 'received_media',
+ media: 'media', invoices: 'invoices', quotations: 'quotations', documents: 'documents',
+ sent: 'sent_media', received: 'received_media',
+}
+
+// ─── Helpers ───────────────────────────────────────────────────────────────────
+
+function getCatMeta(value) {
+ return CATEGORIES.find(c => c.value === value) || CATEGORIES.find(c => c.value === 'documents')
+}
+
+function formatFileSize(bytes) {
+ if (!bytes && bytes !== 0) return '—'
+ if (bytes === 0) return '0 B'
+ const units = ['B', 'KB', 'MB', 'GB']
+ let i = 0, val = bytes
+ while (val >= 1024 && i < units.length - 1) { val /= 1024; i++ }
+ return `${val < 10 ? val.toFixed(1) : Math.round(val)} ${units[i]}`
+}
+
+function formatDate(d) { return fmtDateMedium(d) }
+
+function getExt(filename = '') {
+ const p = filename.split('.')
+ return p.length > 1 ? p[p.length - 1].toUpperCase() : 'FILE'
+}
+
+function getMimeGroup(mime = '', filename = '') {
+ if (!mime && filename) {
+ const ext = filename.split('.').pop().toLowerCase()
+ if (['jpg','jpeg','png','gif','webp','svg','bmp'].includes(ext)) return 'image'
+ if (['mp4','mov','avi','mkv','webm'].includes(ext)) return 'video'
+ if (['mp3','wav','ogg','flac','aac','m4a'].includes(ext)) return 'audio'
+ if (ext === 'pdf') return 'pdf'
+ if (['zip','tar','gz','rar','7z'].includes(ext)) return 'archive'
+ if (['txt','md','csv','json','xml','yaml','yml'].includes(ext)) return 'text'
+ }
+ if (mime.startsWith('image/')) return 'image'
+ if (mime.startsWith('video/')) return 'video'
+ if (mime.startsWith('audio/')) return 'audio'
+ if (mime === 'application/pdf') return 'pdf'
+ if (mime.startsWith('text/')) return 'text'
+ return 'generic'
+}
+
+function detectCategory(path = '') {
+ const parts = path.split('/')
+ for (let i = parts.length - 2; i >= 0; i--) {
+ if (SUBFOLDER_TO_CATEGORY[parts[i]]) return SUBFOLDER_TO_CATEGORY[parts[i]]
+ }
+ return 'media'
+}
+
+function isPreviewable(mime, filename) {
+ return !!(mime && (
+ mime.startsWith('image/') || mime.startsWith('video/') || mime.startsWith('audio/') ||
+ mime === 'application/pdf' || mime === 'text/plain' ||
+ (filename || '').endsWith('.txt')
+ ))
+}
+
+// ─── Shared toolbar button style ───────────────────────────────────────────────
+
+const TB = {
+ height: 34,
+ display: 'flex',
+ alignItems: 'center',
+ gap: 6,
+ padding: '0 10px',
+ fontSize: 'var(--font-size-sm)',
+ fontFamily: 'var(--font-family-base)',
+ backgroundColor: 'var(--color-bg-surface)',
+ border: '1px solid var(--color-border-strong)',
+ borderRadius: 'var(--radius-md)',
+ color: 'var(--color-text-secondary)',
+ cursor: 'pointer',
+ whiteSpace: 'nowrap',
+ transition: 'background-color 0.12s, color 0.12s, border-color 0.12s',
+ flexShrink: 0,
+}
+
+// ─── PDF Thumbnail (PDF.js CDN) ────────────────────────────────────────────────
+
+function PdfThumbnail({ url }) {
+ const canvasRef = useRef(null)
+ const [failed, setFailed] = useState(false)
+ useEffect(() => {
+ let cancelled = false
+ ;(async () => {
+ try {
+ if (!window.pdfjsLib) {
+ await new Promise((res, rej) => {
+ if (document.getElementById('__pdfjs_ft__')) { res(); return }
+ const s = document.createElement('script')
+ s.id = '__pdfjs_ft__'
+ s.src = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js'
+ s.onload = res; s.onerror = rej
+ document.head.appendChild(s)
+ })
+ window.pdfjsLib.GlobalWorkerOptions.workerSrc =
+ 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js'
+ }
+ const pdf = await window.pdfjsLib.getDocument(url).promise
+ if (cancelled) return
+ const page = await pdf.getPage(1)
+ if (cancelled) return
+ const canvas = canvasRef.current
+ if (!canvas) return
+ const vp = page.getViewport({ scale: 1 })
+ const scale = Math.min(200 / vp.width, 140 / vp.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 }
+ }, [url])
+ if (failed) return
+ return
+}
+
+// ─── File type icon ────────────────────────────────────────────────────────────
+
+function FileTypeIcon({ group, size = 40 }) {
+ const p = { width: size, height: size, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 1.5, strokeLinecap: 'round', strokeLinejoin: 'round' }
+ switch (group) {
+ case 'pdf': return
+ case 'audio': return
+ case 'video': return
+ case 'archive':return
+ case 'text': return
+ default: return
+ }
+}
+
+// ─── Tag input pill ────────────────────────────────────────────────────────────
+
+function TagInput({ tags, tagInput, onChange, onInputChange, disabled }) {
+ return (
+
+ {tags.map(t => (
+
+ {t}
+ {!disabled && (
+ onChange(tags.filter(tt => tt !== t))}
+ style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 0, lineHeight: 1, color: 'var(--color-text-muted)', fontSize: 11 }}>×
+ )}
+
+ ))}
+ {!disabled && (
+ onInputChange(e.target.value)}
+ onKeyDown={e => {
+ if (e.key === 'Enter' || e.key === ',') {
+ e.preventDefault()
+ const val = tagInput.trim().replace(/,$/, '')
+ if (val && !tags.includes(val)) onChange([...tags, val])
+ onInputChange('')
+ }
+ }}
+ />
+ )}
+
+ )
+}
+
+// ─── Upload Modal ──────────────────────────────────────────────────────────────
+
+function UploadModal({ open, customerId, onClose, onSuccess }) {
+ const toast = useToast()
+ const inputRef = useRef(null)
+ const [queue, setQueue] = useState([]) // { file, name, category, tags, tagInput, progress }
+ const [uploading, setUploading] = useState(false)
+ const [dragging, setDragging] = useState(false)
+ const [bulkCat, setBulkCat] = useState('media')
+ const [bulkTags, setBulkTags] = useState([])
+ const [bulkTagInput,setBulkTagInput]= useState('')
+
+ // Reset when closed
+ useEffect(() => {
+ if (!open) { setQueue([]); setBulkCat('media'); setBulkTags([]); setBulkTagInput(''); setUploading(false) }
+ }, [open])
+
+ const addFiles = files => {
+ const entries = Array.from(files).map(f => ({
+ file: f, name: f.name, category: bulkCat,
+ tags: [...bulkTags], tagInput: '', progress: null,
+ }))
+ setQueue(prev => [...prev, ...entries])
+ }
+
+ const handleUpload = async () => {
+ const pending = queue.filter(e => e.progress === null)
+ if (!pending.length || uploading) return
+ setUploading(true)
+ const tok = localStorage.getItem('access_token') || ''
+ for (let i = 0; i < queue.length; i++) {
+ const entry = queue[i]
+ if (entry.progress !== null) continue
+ setQueue(prev => prev.map((e, j) => j === i ? { ...e, progress: 0 } : e))
+ try {
+ const fd = new FormData()
+ const uploadFile = entry.name !== entry.file.name
+ ? new File([entry.file], entry.name, { type: entry.file.type })
+ : entry.file
+ fd.append('file', uploadFile)
+ fd.append('customer_id', customerId)
+ fd.append('subfolder', entry.category)
+ if (entry.tags.length) fd.append('tags', entry.tags.join(','))
+ const resp = await fetch('/api/crm/nextcloud/upload', {
+ method: 'POST', headers: { Authorization: `Bearer ${tok}` }, body: fd,
+ })
+ if (!resp.ok) throw new Error()
+ setQueue(prev => prev.map((e, j) => j === i ? { ...e, progress: 'done' } : e))
+ } catch {
+ setQueue(prev => prev.map((e, j) => j === i ? { ...e, progress: 'error' } : e))
+ }
+ }
+ setUploading(false)
+ onSuccess?.()
+ }
+
+ const pendingCount = queue.filter(e => e.progress === null).length
+ const doneCount = queue.filter(e => e.progress === 'done').length
+
+ if (!open) return null
+
+ return createPortal(
+ { if (e.target === e.currentTarget && !uploading) onClose() }}
+ onDragOver={e => { e.preventDefault(); setDragging(true) }}
+ onDragLeave={e => { if (!e.currentTarget.contains(e.relatedTarget)) setDragging(false) }}
+ onDrop={e => { e.preventDefault(); setDragging(false); addFiles(e.dataTransfer.files) }}
+ style={{
+ position: 'fixed', inset: 0, zIndex: 1000,
+ backgroundColor: dragging ? 'rgba(128,131,255,0.15)' : 'rgba(0,0,0,0.75)',
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
+ transition: 'background-color 0.15s',
+ }}
+ >
+
e.stopPropagation()}
+ style={{
+ backgroundColor: 'var(--color-bg-surface)',
+ borderRadius: 'var(--radius-xl)',
+ border: '1px solid var(--color-border)',
+ boxShadow: 'var(--shadow-lg)',
+ width: 'min(900px, 94vw)',
+ maxHeight: '88vh',
+ display: 'flex', flexDirection: 'column',
+ overflow: 'hidden',
+ }}
+ >
+ {/* Header */}
+
+
+ Upload Files
+
+ {!uploading && (
+
+
+
+ )}
+
+
+ {/* Drop zone strip */}
+
inputRef.current?.click()}
+ style={{
+ padding: 'var(--space-4) var(--space-6)',
+ cursor: 'pointer',
+ display: 'flex', alignItems: 'center', gap: 'var(--space-4)',
+ borderBottom: '1px solid var(--color-border)',
+ backgroundColor: dragging ? 'var(--color-primary-subtle)' : 'var(--color-bg-void)',
+ flexShrink: 0,
+ }}
+ >
+
+
+
+
+
+ Click to browse or drop files anywhere in this window
+
+
+ Multiple files supported — all types accepted
+
+
+
+
{ addFiles(e.target.files); e.target.value = '' }} />
+
+ {/* Table */}
+
+ {queue.length === 0 ? (
+
+ No files selected yet. Click above or drag & drop.
+
+ ) : (
+
+
+ {/* Quick-set row */}
+
+
+ Quick Set ALL →
+
+
+ {
+ setBulkCat(e.target.value)
+ setQueue(prev => prev.map(x => x.progress === null ? { ...x, category: e.target.value } : x))
+ }}
+ style={{ width: '100%', fontSize: 'var(--font-size-xs)', padding: '4px 6px', borderRadius: 'var(--radius-sm)', border: '1px solid var(--color-border)', backgroundColor: 'var(--color-bg-abyss)', color: 'var(--color-text-primary)', fontFamily: 'var(--font-family-base)' }}
+ >
+ {CATEGORIES.filter(c => c.value !== 'all').map(c => {c.label} )}
+
+
+
+ { setBulkTags(v); setQueue(prev => prev.map(x => x.progress === null ? { ...x, tags: v } : x)) }}
+ onInputChange={v => setBulkTagInput(v)}
+ />
+
+
+
+
+ {/* Column headers */}
+
+ {['Filename', 'Type', 'Tags', 'Size', ''].map((h, i) => (
+
+ {h}
+
+ ))}
+
+
+
+ {queue.map((entry, i) => (
+
+ {/* Filename */}
+
+
+ {entry.progress === 'done' &&
}
+ {entry.progress === 'error' &&
}
+ {typeof entry.progress === 'number' && (
+
+ )}
+
setQueue(prev => prev.map((x, j) => j === i ? { ...x, name: e.target.value } : x))}
+ disabled={entry.progress !== null}
+ style={{ width: '100%', fontSize: 'var(--font-size-sm)', padding: '3px 6px', borderRadius: 'var(--radius-sm)', border: '1px solid var(--color-border)', backgroundColor: 'var(--color-bg-abyss)', color: 'var(--color-text-primary)', fontFamily: 'var(--font-family-base)' }}
+ />
+
+ {typeof entry.progress === 'number' && (
+
+ )}
+
+ {/* Type */}
+
+ setQueue(prev => prev.map((x, j) => j === i ? { ...x, category: e.target.value } : x))}
+ disabled={entry.progress !== null}
+ style={{ width: '100%', fontSize: 'var(--font-size-xs)', padding: '4px 6px', borderRadius: 'var(--radius-sm)', border: '1px solid var(--color-border)', backgroundColor: 'var(--color-bg-abyss)', color: 'var(--color-text-primary)', fontFamily: 'var(--font-family-base)' }}
+ >
+ {CATEGORIES.filter(c => c.value !== 'all').map(c => {c.label} )}
+
+
+ {/* Tags */}
+
+ setQueue(prev => prev.map((x, j) => j === i ? { ...x, tags: v } : x))}
+ onInputChange={v => setQueue(prev => prev.map((x, j) => j === i ? { ...x, tagInput: v } : x))}
+ />
+
+ {/* Size */}
+
+ {formatFileSize(entry.file.size)}
+
+ {/* Remove */}
+
+ {entry.progress === null && (
+ setQueue(prev => prev.filter((_, j) => j !== i))}
+ style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--color-text-muted)', lineHeight: 1, padding: 4 }}>
+
+
+ )}
+
+
+ ))}
+
+
+ )}
+
+
+ {/* Footer */}
+
+ {!uploading && doneCount > 0 && (
+ Done
+ )}
+
+ {uploading ? 'Uploading…' : `Upload ${pendingCount} File${pendingCount !== 1 ? 's' : ''}`}
+
+
+
+
,
+ document.body
+ )
+}
+
+// ─── Preview Modal ─────────────────────────────────────────────────────────────
+
+function PreviewModal({ file, previewList, token, canEdit, onClose, onDelete, onNavigate }) {
+ const [txtContent, setTxtContent] = useState(null)
+
+ const proxyUrl = file ? `/api/crm/nextcloud/file?path=${encodeURIComponent(file.path)}&token=${encodeURIComponent(token)}` : ''
+
+ useEffect(() => {
+ if (!file) return
+ const isTxt = file.mime_type === 'text/plain' || file.filename?.endsWith('.txt')
+ if (!isTxt) { setTxtContent(null); return }
+ setTxtContent(null)
+ fetch(proxyUrl).then(r => r.text()).then(t => setTxtContent(t)).catch(() => setTxtContent('(failed to load)'))
+ }, [file, proxyUrl])
+
+ // Keyboard nav
+ useEffect(() => {
+ if (!file) return
+ const h = e => {
+ if (e.key === 'Escape') onClose()
+ if (e.key === 'ArrowLeft') { const idx = previewList.findIndex(f => f.path === file.path); if (idx > 0) onNavigate(previewList[idx - 1]) }
+ if (e.key === 'ArrowRight') { const idx = previewList.findIndex(f => f.path === file.path); if (idx < previewList.length - 1) onNavigate(previewList[idx + 1]) }
+ }
+ document.addEventListener('keydown', h)
+ return () => document.removeEventListener('keydown', h)
+ }, [file, previewList, onClose, onNavigate])
+
+ if (!file) return null
+
+ const idx = previewList.findIndex(f => f.path === file.path)
+ const hasPrev = idx > 0
+ const hasNext = idx < previewList.length - 1
+ const isImg = file.mime_type?.startsWith('image/')
+ const isVideo = file.mime_type?.startsWith('video/')
+ const isAudio = file.mime_type?.startsWith('audio/')
+ const isPdf = file.mime_type === 'application/pdf'
+ const isTxt = file.mime_type === 'text/plain' || file.filename?.endsWith('.txt')
+ const isWide = isPdf || isTxt
+
+ const navBtnBase = {
+ display: 'flex', alignItems: 'center', gap: 6,
+ padding: '6px 14px',
+ fontSize: 'var(--font-size-sm)',
+ fontFamily: 'var(--font-family-base)',
+ borderRadius: 'var(--radius-md)',
+ border: '1px solid var(--color-border-strong)',
+ backgroundColor: 'var(--color-bg-elevated)',
+ cursor: 'pointer',
+ transition: 'background-color 0.12s, color 0.12s',
+ whiteSpace: 'nowrap',
+ textDecoration: 'none',
+ }
+ const navBtnStyle = active => ({
+ ...navBtnBase,
+ color: active ? 'var(--color-text-secondary)' : 'var(--color-text-muted)',
+ cursor: active ? 'pointer' : 'default',
+ opacity: active ? 1 : 0.35,
+ })
+ const navBtnColored = (colorVar, hoverColorVar) => ({
+ ...navBtnBase,
+ color: colorVar,
+ '--nav-btn-hover-color': hoverColorVar,
+ })
+
+ const openNextcloud = async () => {
+ try {
+ const data = await api.get(`/crm/nextcloud/web-url?path=${encodeURIComponent(file.path)}`)
+ window.open(data.url, '_blank', 'noreferrer')
+ } catch { /* swallow */ }
+ }
+
+ return createPortal(
+
+
e.stopPropagation()}
+ style={{
+ backgroundColor: 'var(--color-bg-surface)',
+ borderRadius: 'var(--radius-xl)',
+ border: '1px solid var(--color-border)',
+ boxShadow: '0 24px 64px rgba(0,0,0,0.6)',
+ width: isWide ? '82vw' : undefined,
+ maxWidth: '92vw',
+ maxHeight: '90vh',
+ display: 'flex', flexDirection: 'column',
+ overflow: 'hidden',
+ }}
+ >
+ {/* Header */}
+
+
+ {file.filename}
+
+
+
+
+
+
+ {/* Body */}
+
+ {isImg && (
+
+ )}
+ {isVideo && (
+
+
+
+
+ )}
+ {isAudio && (
+
+
+
+ {file.filename}
+
+
+
+ )}
+ {isPdf && (
+
+ )}
+ {isTxt && (
+
+ {txtContent === null ? 'Loading…' : txtContent}
+
+ )}
+ {!isImg && !isVideo && !isAudio && !isPdf && !isTxt && (
+
+
+
No preview available
+
+ )}
+
+
+ {/* Footer nav */}
+
+
+
,
+ document.body
+ )
+}
+
+// ─── File Grid Card ────────────────────────────────────────────────────────────
+
+function FileCard({ file, token, onDelete, canEdit, onPreview, showStripe, showExtTag }) {
+ const [hovered, setHovered] = useState(false)
+
+ const group = getMimeGroup(file.mime_type, file.filename)
+ const isImage = group === 'image'
+ const isVideo = group === 'video'
+ const isPdf = group === 'pdf'
+ const catMeta = getCatMeta(file.category)
+ const dotColor = catMeta?.color?.dot || 'var(--color-text-muted)'
+
+ const thumbUrl = file.thumbnail_path
+ ? `/api/crm/nextcloud/file?path=${encodeURIComponent(file.thumbnail_path)}&token=${encodeURIComponent(token)}`
+ : null
+ const proxyUrl = `/api/crm/nextcloud/file?path=${encodeURIComponent(file.path)}&token=${encodeURIComponent(token)}`
+
+ const showThumb = (isImage || isVideo || isPdf) && !file.nc_missing
+ const useThumbImg = showThumb && !!thumbUrl
+ const usePdfJs = showThumb && isPdf && !thumbUrl
+ const useImgFallback = showThumb && isImage && !thumbUrl
+ const ext = getExt(file.filename)
+
+ const handleClick = () => {
+ if (isPreviewable(file.mime_type, file.filename)) onPreview(file)
+ else window.open(proxyUrl, '_blank', 'noopener,noreferrer')
+ }
+
+ return (
+ setHovered(true)}
+ onMouseLeave={() => setHovered(false)}
+ onClick={handleClick}
+ style={{
+ position: 'relative',
+ aspectRatio: '1 / 1',
+ borderRadius: 'var(--radius-lg)',
+ overflow: 'hidden',
+ backgroundColor: 'var(--color-bg-surface)',
+ border: `1px solid ${hovered ? 'var(--color-border-focus)' : catMeta?.color?.border || 'var(--color-border)'}`,
+ boxShadow: hovered ? 'var(--shadow-md)' : 'var(--shadow-card)',
+ transition: 'border-color 0.18s, box-shadow 0.18s',
+ cursor: 'pointer',
+ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
+ }}
+ >
+ {/* Thumbnail */}
+ {useThumbImg && (
+
+ )}
+ {usePdfJs && (
+
+ )}
+ {useImgFallback && (
+
+ )}
+ {isVideo && (
+
+ )}
+
+ {/* Icon fallback */}
+ {!useThumbImg && !usePdfJs && !useImgFallback && (
+
+ )}
+
+ {/* Category stripe — bottom, padded, hidden on hover */}
+ {showStripe && !hovered && (
+
+ )}
+
+ {/* EXT badge — top-left */}
+ {showExtTag && (
+
+ {ext}
+
+ )}
+
+ {/* Gradient overlay */}
+
+
+ {/* Hover: filename + actions */}
+
+
+ {file.filename}
+
+
+
{ e.stopPropagation(); handleClick() }}
+ style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 4, padding: '5px', backgroundColor: 'rgba(28,32,38,0.88)', backdropFilter: 'blur(8px)', border: '1px solid var(--color-border-strong)', borderRadius: 'var(--radius-sm)', color: 'var(--color-text-primary)', fontSize: 'var(--font-size-xs)', fontWeight: 'var(--font-weight-semibold)', cursor: 'pointer' }}
+ aria-label="View"
+ > View
+
e.stopPropagation()}
+ style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '5px 7px', backgroundColor: 'rgba(28,32,38,0.88)', backdropFilter: 'blur(8px)', border: '1px solid var(--color-border-strong)', borderRadius: 'var(--radius-sm)', color: 'var(--color-text-primary)', lineHeight: 1 }}
+ aria-label="Download"
+ >
+ {canEdit && (
+
{ e.stopPropagation(); onDelete(file) }}
+ style={{ padding: '5px 7px', backgroundColor: 'rgba(28,32,38,0.88)', backdropFilter: 'blur(8px)', border: '1px solid var(--color-border-strong)', borderRadius: 'var(--radius-sm)', color: 'var(--color-danger)', cursor: 'pointer', lineHeight: 1 }}
+ aria-label="Delete"
+ >
+ )}
+
+
+
+ )
+}
+
+// ─── File List Row ─────────────────────────────────────────────────────────────
+
+function FileListRow({ file, token, onDelete, canEdit, onPreview }) {
+ const group = getMimeGroup(file.mime_type, file.filename)
+ const catMeta = getCatMeta(file.category)
+ const dotColor = catMeta?.color?.dot || 'var(--color-text-muted)'
+ const proxyUrl = `/api/crm/nextcloud/file?path=${encodeURIComponent(file.path)}&token=${encodeURIComponent(token)}`
+
+ const handleOpen = () => {
+ if (isPreviewable(file.mime_type, file.filename)) onPreview(file)
+ else window.open(proxyUrl, '_blank', 'noopener,noreferrer')
+ }
+
+ return (
+ e.currentTarget.style.backgroundColor = 'var(--color-bg-elevated)'}
+ onMouseLeave={e => e.currentTarget.style.backgroundColor = 'transparent'}
+ >
+
+
+
+ {file.filename}
+
+
+ {catMeta?.label || file.category}
+
+
{formatFileSize(file.size_bytes)}
+
{formatDate(file.uploaded_at || file.created_at)}
+
+
+ { const a = document.createElement('a'); a.href = proxyUrl + '&download=1'; a.download = file.filename; a.click() }} aria-label="Download">
+ {canEdit && onDelete(file)} aria-label="Delete"> }
+
+
+ )
+}
+
+// ─── Sort dropdown ─────────────────────────────────────────────────────────────
+
+function SortDropdown({ value, onChange }) {
+ const [open, setOpen] = useState(false)
+ const ref = useRef(null)
+ const current = SORT_OPTIONS.find(o => o.value === value) || SORT_OPTIONS[0]
+ useEffect(() => {
+ if (!open) return
+ const h = e => { if (ref.current && !ref.current.contains(e.target)) setOpen(false) }
+ document.addEventListener('mousedown', h)
+ return () => document.removeEventListener('mousedown', h)
+ }, [open])
+ return (
+
+
setOpen(v => !v)}>
+
+ {current.label}
+
+
+ {open && (
+ <>
+
setOpen(false)} style={{ position: 'fixed', inset: 0, zIndex: 49 }} />
+
+ {SORT_OPTIONS.map(o => (
+ { onChange(o.value); setOpen(false) }}
+ style={{ display: 'block', width: '100%', textAlign: 'left', padding: '8px 14px', fontSize: 'var(--font-size-sm)', fontFamily: 'var(--font-family-base)', border: 'none', cursor: 'pointer', backgroundColor: o.value === value ? 'var(--color-primary-subtle)' : 'transparent', color: o.value === value ? 'var(--color-primary)' : 'var(--color-text-secondary)' }}
+ onMouseEnter={e => { if (o.value !== value) e.currentTarget.style.backgroundColor = 'var(--color-bg-island)' }}
+ onMouseLeave={e => { e.currentTarget.style.backgroundColor = o.value === value ? 'var(--color-primary-subtle)' : 'transparent' }}
+ >{o.label}
+ ))}
+
+ >
+ )}
+
+ )
+}
+
+// ─── Gear menu ─────────────────────────────────────────────────────────────────
+
+function GearMenu({ customerId, onRefresh, thumbGenLoading, setThumbGenLoading, setThumbGenResult, showStripe, setShowStripe, showExtTag, setShowExtTag }) {
+ const [open, setOpen] = useState(false)
+ const ref = useRef(null)
+ const { toast } = useToast()
+
+ useEffect(() => {
+ if (!open) return
+ const h = e => { if (ref.current && !ref.current.contains(e.target)) setOpen(false) }
+ document.addEventListener('mousedown', h)
+ return () => document.removeEventListener('mousedown', h)
+ }, [open])
+
+ const handleGenerateThumbs = async () => {
+ setOpen(false); setThumbGenLoading(true); setThumbGenResult(null)
+ try {
+ const fd = new FormData(); fd.append('customer_id', customerId)
+ const tok = localStorage.getItem('access_token') || ''
+ const resp = await fetch('/api/crm/nextcloud/generate-thumbs', { method: 'POST', headers: { Authorization: `Bearer ${tok}` }, body: fd })
+ if (!resp.ok) throw new Error()
+ const result = await resp.json()
+ setThumbGenResult(result); onRefresh()
+ toast.success('Thumbnails generated', `${result.generated} generated, ${result.skipped} skipped.`)
+ } catch { toast.danger('Failed', 'Could not generate thumbnails.') }
+ finally { setThumbGenLoading(false) }
+ }
+
+ const handleClearThumbs = async () => {
+ setOpen(false)
+ try {
+ const fd = new FormData(); fd.append('customer_id', customerId)
+ const tok = localStorage.getItem('access_token') || ''
+ const resp = await fetch('/api/crm/nextcloud/clear-thumbs', { method: 'POST', headers: { Authorization: `Bearer ${tok}` }, body: fd })
+ if (!resp.ok) throw new Error()
+ onRefresh(); toast.success('Thumbnails cleared', 'All thumbnail folders removed.')
+ } catch { toast.danger('Failed', 'Could not clear thumbnails.') }
+ }
+
+ const rowStyle = (danger) => ({
+ display: 'flex', alignItems: 'center', gap: 'var(--space-3)',
+ width: '100%', textAlign: 'left', padding: 'var(--space-3) var(--space-4)',
+ fontSize: 'var(--font-size-sm)', fontFamily: 'var(--font-family-base)',
+ border: 'none', borderBottom: '1px solid var(--color-border)',
+ cursor: 'pointer', backgroundColor: 'transparent',
+ color: danger ? 'var(--color-danger)' : 'var(--color-text-primary)',
+ transition: 'background-color 0.12s',
+ })
+
+ const toggleRow = (icon, label, desc, active, onToggle) => (
+
{ e.stopPropagation(); onToggle(!active) }}
+ style={{ ...rowStyle(false), borderBottom: '1px solid var(--color-border)' }}
+ onMouseEnter={e => e.currentTarget.style.backgroundColor = 'var(--color-bg-island)'}
+ onMouseLeave={e => e.currentTarget.style.backgroundColor = 'transparent'}
+ >
+ {icon}
+
+
+
+ )
+
+ return (
+
+
setOpen(v => !v)}
+ style={{ ...TB, padding: '0 9px', ...(thumbGenLoading ? { opacity: 0.6, cursor: 'wait' } : {}) }}
+ disabled={thumbGenLoading} title="Media settings" aria-label="Settings"
+ >
+ {thumbGenLoading
+ ?
+ :
+ }
+
+ {open && (
+ <>
+
setOpen(false)} style={{ position: 'fixed', inset: 0, zIndex: 49 }} />
+
+
+ {/* Section label */}
+
+ Display
+
+
+ {/* Toggle: category stripe */}
+ {toggleRow(
+
,
+ 'Category Stripe',
+ 'Show a colour-coded stripe below the file type tag on each thumbnail.',
+ showStripe,
+ setShowStripe,
+ )}
+
+ {/* Toggle: ext tag */}
+ {toggleRow(
+
,
+ 'File Type Tag',
+ 'Show the file extension badge on the top-left of each thumbnail.',
+ showExtTag,
+ setShowExtTag,
+ )}
+
+ {/* Section label */}
+
+ Thumbnails
+
+
+ {/* Generate */}
+
e.currentTarget.style.backgroundColor = 'var(--color-bg-island)'}
+ onMouseLeave={e => e.currentTarget.style.backgroundColor = 'transparent'}
+ >
+
+
+
Generate Missing Thumbnails
+
Creates thumbnails for all files that don't have one yet. Existing thumbnails are skipped.
+
+
+
+ {/* Flush */}
+
e.currentTarget.style.backgroundColor = 'var(--color-danger-bg)'}
+ onMouseLeave={e => e.currentTarget.style.backgroundColor = 'transparent'}
+ >
+
+
+
Flush All Thumbnails
+
Deletes all .thumbs folders for this customer. Thumbnails can be regenerated afterwards.
+
+
+
+ >
+ )}
+
+ )
+}
+
+// ─── Group toggle ──────────────────────────────────────────────────────────────
+
+function GroupToggle({ active, onChange }) {
+ return (
+
onChange(!active)} style={{ ...TB, border: `1px solid ${active ? 'var(--color-primary)' : 'var(--color-border-strong)'}`, color: active ? 'var(--color-primary)' : 'var(--color-text-secondary)' }}>
+
+
+
+ Group
+
+ )
+}
+
+// ─── View toggle ───────────────────────────────────────────────────────────────
+
+function ViewToggle({ view, onChange }) {
+ return (
+
+ {[
+ { key: 'grid', icon: },
+ { key: 'list', icon: },
+ ].map(({ key, icon }) => (
+ onChange(key)} aria-label={`${key} view`} style={{ padding: '0 10px', border: 'none', cursor: 'pointer', backgroundColor: view === key ? 'var(--color-primary-container)' : 'var(--color-bg-surface)', color: view === key ? 'var(--color-text-primary)' : 'var(--color-text-muted)', lineHeight: 1, transition: 'background-color 0.12s' }}>
+ {icon}
+
+ ))}
+
+ )
+}
+
+// ─── Page size selector ────────────────────────────────────────────────────────
+
+function PageSizeSelector({ value, onChange }) {
+ return (
+
+ {PAGE_SIZES.map((n, i) => (
+ onChange(n)} style={{ padding: '0 9px', border: 'none', borderRight: i < PAGE_SIZES.length - 1 ? '1px solid var(--color-border-strong)' : 'none', cursor: 'pointer', fontSize: 'var(--font-size-xs)', fontWeight: 'var(--font-weight-bold)', fontFamily: 'var(--font-family-base)', backgroundColor: n === value ? 'var(--color-bg-island)' : 'var(--color-bg-surface)', color: n === value ? 'var(--color-primary)' : 'var(--color-text-muted)', transition: 'background-color 0.12s' }}>
+ {n}
+
+ ))}
+
+ )
+}
+
+// ─── Pagination ────────────────────────────────────────────────────────────────
+
+function PaginationBar({ total, page, pageSize, onPage }) {
+ const totalPages = Math.max(1, Math.ceil(total / pageSize))
+ const pages = []
+ if (totalPages <= 7) { for (let i = 1; i <= totalPages; i++) pages.push(i) }
+ else {
+ pages.push(1)
+ if (page > 3) pages.push('…')
+ for (let i = Math.max(2, page - 1); i <= Math.min(totalPages - 1, page + 1); i++) pages.push(i)
+ if (page < totalPages - 2) pages.push('…')
+ pages.push(totalPages)
+ }
+ const nb = (active) => ({ ...TB, padding: '0 8px', width: 34, justifyContent: 'center', ...(active ? { backgroundColor: 'var(--color-primary-container)', borderColor: 'var(--color-primary-container)', color: 'var(--color-text-primary)', fontWeight: 'var(--font-weight-bold)' } : {}) })
+ return (
+
+ onPage(page - 1)} disabled={page === 1} style={{ ...nb(false), opacity: page === 1 ? 0.3 : 1, cursor: page === 1 ? 'default' : 'pointer' }} aria-label="Prev">
+ {pages.map((p, i) => p === '…'
+ ? …
+ : onPage(p)} style={nb(p === page)}>{p}
+ )}
+ onPage(page + 1)} disabled={page === totalPages} style={{ ...nb(false), opacity: page === totalPages ? 0.3 : 1, cursor: page === totalPages ? 'default' : 'pointer' }} aria-label="Next">
+
+ )
+}
+
+// ─── Empty state ───────────────────────────────────────────────────────────────
+
+function EmptyFiles({ filtered }) {
+ return (
+
+
+
{filtered ? 'No matching files' : 'No files yet'}
+
{filtered ? 'Try adjusting your filters or search.' : 'Upload files to get started.'}
+
+ )
+}
+
+// ─── Main Component ────────────────────────────────────────────────────────────
+
+export default function FilesTab({ customer, canEdit }) {
+ const { id } = customer
+ const toast = useToast()
+ const token = localStorage.getItem('access_token') || ''
+
+ const [media, setMedia] = useState([])
+ const [ncFiles, setNcFiles] = useState([])
+ const [thumbMap, setThumbMap] = useState({})
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState('')
+
+ const [category, setCategory] = useState('all')
+ const [search, setSearch] = useState('')
+ const [sort, setSort] = useState('date_desc')
+ const [groupView, setGroupView] = useState(false)
+ const [view, setView] = useState('grid')
+ const [page, setPage] = useState(1)
+ const [pageSize, setPageSize] = useState(20)
+ const [showUpload, setShowUpload] = useState(false)
+ const [syncLoading, setSyncLoading] = useState(false)
+ const [deleteTarget, setDeleteTarget] = useState(null)
+ const [previewFile, setPreviewFile] = useState(null)
+
+ // Display settings
+ const [showStripe, setShowStripe] = useState(true)
+ const [showExtTag, setShowExtTag] = useState(true)
+
+ // Gear / thumb gen
+ const [thumbGenLoading, setThumbGenLoading] = useState(false)
+ const [thumbGenResult, setThumbGenResult] = useState(null)
+
+ const loadMedia = useCallback(() => {
+ setLoading(true); setError('')
+ api.get(`/crm/media?customer_id=${id}`)
+ .then(data => setMedia(data.items || []))
+ .catch(() => setError('Failed to load files.'))
+ .finally(() => setLoading(false))
+ }, [id])
+
+ const browseNextcloud = useCallback(() => {
+ api.get(`/crm/nextcloud/browse-all?customer_id=${id}`)
+ .then(data => {
+ const items = data.items || []
+ setNcFiles(items)
+ const thumbsByKey = {}
+ for (const f of items) {
+ if (!f.path.includes('/.thumbs/')) continue
+ const parent = f.path.split('/.thumbs/')[0]
+ const stem = f.path.split('/').pop().replace(/\.[^.]+$/, '')
+ thumbsByKey[`${parent}|${stem}`] = f.path
+ }
+ const map = {}
+ for (const f of items) {
+ if (f.path.includes('/.thumbs/')) continue
+ const parent = f.path.split('/').slice(0, -1).join('/')
+ const stem = f.path.split('/').pop().replace(/\.[^.]+$/, '')
+ const thumb = thumbsByKey[`${parent}|${stem}`]
+ if (thumb) map[f.path] = thumb
+ }
+ setThumbMap(map)
+ })
+ .catch(() => { setNcFiles([]); setThumbMap({}) })
+ }, [id])
+
+ useEffect(() => { loadMedia(); browseNextcloud() }, [loadMedia, browseNextcloud])
+
+ const refresh = useCallback(() => { loadMedia(); browseNextcloud() }, [loadMedia, browseNextcloud])
+
+ const handleSync = async () => {
+ setSyncLoading(true)
+ try {
+ const fd = new FormData(); fd.append('customer_id', id)
+ const tok = localStorage.getItem('access_token') || ''
+ const resp = await fetch('/api/crm/nextcloud/sync', { method: 'POST', headers: { Authorization: `Bearer ${tok}` }, body: fd })
+ if (!resp.ok) throw new Error()
+ toast.success('Sync complete', 'File index is up to date.'); refresh()
+ } catch { toast.danger('Sync failed', 'Could not sync files from Nextcloud.') }
+ finally { setSyncLoading(false) }
+ }
+
+ const handleDeleteConfirm = async () => {
+ if (!deleteTarget) return
+ const path = deleteTarget.path || deleteTarget.nextcloud_path
+ try {
+ await api.delete(`/crm/nextcloud/file?path=${encodeURIComponent(path)}`)
+ toast.success('Deleted', `${deleteTarget.filename} removed.`)
+ setDeleteTarget(null); refresh()
+ } catch { toast.danger('Delete failed', 'Could not delete this file.') }
+ }
+
+ // Merged file list
+ const allFiles = (() => {
+ const ncFileMap = {}
+ for (const f of ncFiles) {
+ if (f.path.includes('/.thumbs/')) continue
+ const parts = f.path.split('/')
+ const rawSub = parts.length >= 3 ? parts[2] : 'other'
+ ncFileMap[f.path] = { ...f, category: SUBFOLDER_TO_CATEGORY[rawSub] || rawSub }
+ }
+ const dbMap = {}
+ for (const m of media) dbMap[m.nextcloud_path] = m
+ const merged = []
+ for (const m of media) {
+ merged.push({ id: m.id, filename: m.filename || m.nextcloud_path?.split('/').pop() || '—', path: m.nextcloud_path, mime_type: m.mime_type || '', size_bytes: m.size_bytes || (ncFileMap[m.nextcloud_path]?.size || 0), uploaded_at: m.uploaded_at || m.created_at, category: m.category || detectCategory(m.nextcloud_path || ''), thumbnail_path: thumbMap[m.nextcloud_path] || null, tracked: true })
+ }
+ for (const [path, f] of Object.entries(ncFileMap)) {
+ if (dbMap[path]) continue
+ merged.push({ id: `nc-${path}`, filename: f.name || path.split('/').pop(), path, mime_type: f.mime_type || '', size_bytes: f.size || 0, uploaded_at: f.last_modified || null, category: f.category, thumbnail_path: thumbMap[path] || null, tracked: false })
+ }
+ return merged
+ })()
+
+ const filtered = allFiles.filter(f => {
+ if (category !== 'all' && f.category !== category) return false
+ if (search) { const q = search.toLowerCase(); const n = (f.filename || '').toLowerCase(); const e = n.includes('.') ? n.split('.').pop() : ''; if (!n.includes(q) && e !== q) return false }
+ return true
+ })
+
+ const sorted = [...filtered].sort((a, b) => {
+ switch (sort) {
+ case 'date_desc': return new Date(b.uploaded_at || 0) - new Date(a.uploaded_at || 0)
+ case 'date_asc': return new Date(a.uploaded_at || 0) - new Date(b.uploaded_at || 0)
+ case 'name_asc': return a.filename.localeCompare(b.filename)
+ case 'name_desc': return b.filename.localeCompare(a.filename)
+ case 'size_desc': return (b.size_bytes || 0) - (a.size_bytes || 0)
+ case 'size_asc': return (a.size_bytes || 0) - (b.size_bytes || 0)
+ default: return 0
+ }
+ })
+
+ const totalCount = sorted.length
+ const paged = sorted.slice((page - 1) * pageSize, page * pageSize)
+ const isFiltered = category !== 'all' || !!search
+
+ useEffect(() => { setPage(1) }, [category, search, sort, pageSize])
+
+ const catCounts = {}
+ for (const f of allFiles) catCounts[f.category] = (catCounts[f.category] || 0) + 1
+
+ const GROUP_DEFS = [
+ { key: 'media', label: 'Media', values: ['media', 'sent_media', 'received_media'] },
+ { key: 'invoices', label: 'Invoices / Shipping', values: ['invoices'] },
+ { key: 'quotes', label: 'Quotations', values: ['quotations'] },
+ { key: 'documents', label: 'Documents', values: ['documents'] },
+ ]
+ const showGroups = groupView && category === 'all' && !search
+ const groupedSections = showGroups
+ ? GROUP_DEFS.map(g => ({ ...g, files: sorted.filter(f => g.values.includes(f.category)) })).filter(g => g.files.length > 0)
+ : null
+
+ // Previewable list for prev/next nav
+ const previewList = sorted.filter(f => isPreviewable(f.mime_type, f.filename))
+
+ const renderGrid = files => (
+
+ {files.map(f => )}
+
+ )
+
+ const renderList = files => (
+
+
+ {['', '', 'Name', 'Category', 'Size', 'Uploaded', ''].map((h, i) => (
+ {h}
+ ))}
+
+ {files.map(f =>
)}
+
+ )
+
+ return (
+
+
+ {/* Scoped overrides */}
+
+
+ {/* Category chips */}
+
+ {CATEGORIES.map(cat => {
+ const active = category === cat.value
+ const count = cat.value === 'all' ? allFiles.length : (catCounts[cat.value] || 0)
+ const col = cat.color
+ return (
+ setCategory(cat.value)} style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '5px 12px', borderRadius: 'var(--radius-md)', border: `1px solid ${active ? (col?.border || 'var(--color-primary-container)') : 'var(--color-border-strong)'}`, backgroundColor: active ? (col?.bg || 'var(--color-primary-subtle)') : 'var(--color-bg-surface)', color: active ? (col?.text || 'var(--color-primary)') : 'var(--color-text-muted)', fontSize: 'var(--font-size-xs)', fontWeight: 'var(--font-weight-bold)', fontFamily: 'var(--font-family-base)', textTransform: 'uppercase', letterSpacing: 'var(--tracking-wide)', cursor: 'pointer', transition: 'all 0.15s', whiteSpace: 'nowrap' }}>
+ {col && active && }
+ {cat.label}
+
+ {count}
+
+
+ )
+ })}
+
+
+ {/* Toolbar */}
+
+
+
+
+
+
+ {/* Search — constrained to 34px toolbar height */}
+
+ { setSearch(v); setPage(1) }} placeholder="Search files…" />
+
+
+
+
+
{ setPageSize(n); setPage(1) }} />
+ {totalCount > 0 && }
+
+
+
+
+
+ {canEdit && (
+
+ {syncLoading ? : }
+ Sync
+
+ )}
+
+ {canEdit && (
+ setShowUpload(true)}>
+ Upload
+
+ )}
+
+ {canEdit && (
+
+ )}
+
+
+ {/* Thumb gen result banner */}
+ {thumbGenResult && (
+
+ ✓ {thumbGenResult.generated} generated, {thumbGenResult.skipped} skipped{thumbGenResult.failed > 0 ? `, ${thumbGenResult.failed} failed` : ''}
+ setThumbGenResult(null)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--color-text-muted)', lineHeight: 1 }}>
+
+ )}
+
+ {/* File area */}
+ {loading ? (
+
+ ) : error ? (
+
{error}
+ ) : showGroups ? (
+
+ {groupedSections.length === 0 ?
: groupedSections.map(g => (
+
+
+ {g.label} ({g.files.length})
+
+ {view === 'grid' ? renderGrid(g.files) : renderList(g.files)}
+
+ ))}
+
+ ) : paged.length === 0 ? (
+
+ ) : view === 'grid' ? renderGrid(paged) : renderList(paged)}
+
+ {/* Modals */}
+
setShowUpload(false)} onSuccess={refresh} />
+
+ setPreviewFile(null)}
+ onDelete={f => { setPreviewFile(null); setDeleteTarget(f) }}
+ onNavigate={setPreviewFile}
+ />
+
+ setDeleteTarget(null)}
+ />
+
+ )
+}
diff --git a/frontend/src/pages/crm/customers/tabs/FinancialsTab.jsx b/frontend/src/pages/crm/customers/tabs/FinancialsTab.jsx
new file mode 100644
index 0000000..4999f72
--- /dev/null
+++ b/frontend/src/pages/crm/customers/tabs/FinancialsTab.jsx
@@ -0,0 +1,441 @@
+// frontend/src/pages/crm/customers/tabs/FinancialsTab.jsx
+// Financial overview: overall stats, per-order payment status, transaction history
+
+import { useState } from 'react'
+import api from '@/lib/api'
+import Card from '@/components/ui/Card'
+import Button from '@/components/ui/Button'
+import StatusBadge from '@/components/ui/StatusBadge'
+import ConfirmDialog from '@/components/ui/ConfirmDialog'
+import { useToast } from '@/components/ui/Toast'
+import TransactionModal from '@/modals/crm/customers/TransactionModal'
+import { fmtDateMedium } from '@/lib/formatters'
+
+// ─── Constants ────────────────────────────────────────────────────────────────
+
+const FLOW_LABELS = { invoice: 'Invoice', payment: 'Payment', refund: 'Refund', credit: 'Credit' }
+const PAYMENT_TYPE_LABELS = { cash: 'Cash', bank_transfer: 'Bank Transfer', card: 'Card', paypal: 'PayPal' }
+const CATEGORY_LABELS = { full_payment: 'Full Payment', advance: 'Advance', installment: 'Installment' }
+
+const ORDER_STATUS_LABELS = {
+ negotiating: 'Negotiating', awaiting_quotation: 'Awaiting Quotation',
+ awaiting_customer_confirmation: 'Awaiting Confirmation', awaiting_fulfilment: 'Accepted - Waiting',
+ awaiting_payment: 'Awaiting Payment', manufacturing: 'Manufacturing',
+ shipped: 'Shipped', installed: 'Installed', declined: 'Declined', complete: 'Complete',
+}
+
+const TERMINAL = new Set(['declined', 'complete'])
+
+const FLOW_VARIANT = { payment: 'success', invoice: 'info', refund: 'warning', credit: 'neutral' }
+
+// ─── Helpers ─────────────────────────────────────────────────────────────────
+
+// Format as European currency: 12500800.70 → "€12.500.800,70"
+function fmtEuro(amount, currency = 'EUR') {
+ const n = Number(amount || 0)
+ const [intPart, decPart] = n.toFixed(2).split('.')
+ const intFormatted = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, '.')
+ const suffix = currency && currency !== 'EUR' ? ` ${currency}` : ''
+ return `€${intFormatted},${decPart}${suffix}`
+}
+
+function fmtDate(iso) { return fmtDateMedium(iso) }
+
+// ─── Stat Box Icons ───────────────────────────────────────────────────────────
+
+const InvoiceIcon = ({ size = 20 }) => (
+
+
+
+)
+
+const MoneyIcon = ({ size = 20 }) => (
+
+
+
+
+
+
+)
+
+const BalanceDueIcon = ({ size = 20 }) => (
+
+
+
+
+)
+
+const TotalOrdersIcon = ({ size = 20 }) => (
+
+
+
+)
+
+const PaymentStatusIcon = ({ size = 20 }) => (
+
+
+
+
+
+
+
+)
+
+// ─── Stat Box ─────────────────────────────────────────────────────────────────
+
+function StatBox({ label, value, accentColor, note, icon: IconComponent }) {
+ return (
+
+ {IconComponent && (
+
+
+
+ )}
+
+ {label}
+
+ {value}
+
+ {note && (
+ {note}
+ )}
+
+
+ )
+}
+
+// ─── Main Component ───────────────────────────────────────────────────────────
+
+export default function FinancialsTab({ customer, orders, canEdit, onCustomerUpdated, onReloadOrders }) {
+ const { toast } = useToast()
+
+ const [showTxnModal, setShowTxnModal] = useState(false)
+ const [editTxnIndex, setEditTxnIndex] = useState(null)
+ const [deleteTxnIndex, setDeleteTxnIndex] = useState(null)
+ const [deleting, setDeleting] = useState(false)
+
+ const txns = [...(customer.transaction_history || [])].sort(
+ (a, b) => (b.date || '').localeCompare(a.date || '')
+ )
+
+ // Overall financial totals
+ const totalInvoiced = (customer.transaction_history || [])
+ .filter(t => t.flow === 'invoice')
+ .reduce((s, t) => s + (t.amount || 0), 0)
+ const totalPaid = (customer.transaction_history || [])
+ .filter(t => t.flow === 'payment')
+ .reduce((s, t) => s + (t.amount || 0), 0)
+ const outstanding = totalInvoiced - totalPaid
+
+ const activeOrders = (orders || []).filter(o => !TERMINAL.has(o.status))
+
+ const handleDelete = async () => {
+ setDeleting(true)
+ try {
+ const updated = await api.delete(`/crm/customers/${customer.id}/transactions/${deleteTxnIndex}`)
+ onCustomerUpdated?.(updated)
+ setDeleteTxnIndex(null)
+ toast.success('Deleted', 'Transaction removed.')
+ } catch (err) {
+ toast.danger('Error', err.message || 'Failed to delete transaction.')
+ } finally {
+ setDeleting(false)
+ }
+ }
+
+ const openAdd = () => { setEditTxnIndex(null); setShowTxnModal(true) }
+ const openEdit = (origIdx) => { setEditTxnIndex(origIdx); setShowTxnModal(true) }
+
+ return (
+
+
+ {/* ── Overall Financial Status ──────────────────────────────────────── */}
+
+
+
+
+ 0 ? 'var(--color-warning)' : 'var(--color-success)'}
+ icon={BalanceDueIcon}
+ />
+
+
+
+
+ {/* ── Active Orders Payment Status ──────────────────────────────────── */}
+ {activeOrders.length > 0 && (
+
+
+ {activeOrders.map((order, orderIdx) => {
+ const ps = order.payment_status || {}
+ return (
+
+ {/* Order label row */}
+
+
+ {order.order_number}
+
+ {order.title && (
+
+ — {order.title}
+
+ )}
+
+ {ORDER_STATUS_LABELS[order.status] || order.status}
+
+
+
+
+
+
+ 0 ? 'var(--color-warning)' : 'var(--color-success)'}
+ icon={BalanceDueIcon}
+ />
+
+
+
+ {orderIdx < activeOrders.length - 1 && (
+
+ )}
+
+ )
+ })}
+
+
+ )}
+
+ {/* ── Transaction History ───────────────────────────────────────────── */}
+
0 ? `${txns.length} record${txns.length !== 1 ? 's' : ''}` : undefined}
+ >
+ {/* Header action */}
+ {canEdit && (
+
+
+ + Add Transaction
+
+
+ )}
+
+ {txns.length === 0 ? (
+
+
+
+
+
+
No transactions recorded yet.
+
+ ) : (
+
+
+
+
+ {['Date', 'Flow', 'Amount', 'Method', 'Category', 'Order', 'Note', 'By', ''].map(h => (
+
+ {h}
+
+ ))}
+
+
+
+ {txns.map((t, i) => {
+ const origIdx = (customer.transaction_history || []).findIndex(
+ x => x.date === t.date && x.amount === t.amount && x.note === t.note && x.recorded_by === t.recorded_by
+ )
+ const linkedOrder = t.order_ref ? (orders || []).find(o => o.id === t.order_ref) : null
+
+ return (
+ e.currentTarget.style.backgroundColor = 'var(--color-bg-elevated)'}
+ onMouseLeave={e => e.currentTarget.style.backgroundColor = 'transparent'}
+ >
+
+ {fmtDate(t.date)}
+
+
+
+ {FLOW_LABELS[t.flow] || t.flow}
+
+
+
+ {t.flow === 'payment' ? '+' : t.flow === 'invoice' ? '−' : ''}{fmtEuro(t.amount, t.currency)}
+
+
+ {PAYMENT_TYPE_LABELS[t.payment_type] || '—'}
+
+
+ {t.flow === 'invoice' ? '—' : (CATEGORY_LABELS[t.category] || t.category || '—')}
+
+
+ {linkedOrder ? (
+
+ {linkedOrder.order_number}
+
+ ) : t.order_ref ? (
+
+ {t.order_ref.slice(0, 8)}
+
+ ) : (
+ —
+ )}
+
+
+ {t.note || '—'}
+
+
+ {t.recorded_by || '—'}
+
+ {canEdit && (
+
+
+ openEdit(origIdx)}>
+ Edit
+
+ setDeleteTxnIndex(origIdx)}>
+ Delete
+
+
+
+ )}
+
+ )
+ })}
+
+
+
+ )}
+
+
+ {/* ── Modals ─────────────────────────────────────────────────────────── */}
+
{ setShowTxnModal(false); setEditTxnIndex(null) }}
+ onSaved={updated => { onCustomerUpdated?.(updated); onReloadOrders?.() }}
+ />
+
+ setDeleteTxnIndex(null)}
+ />
+
+ )
+}
diff --git a/frontend/src/pages/crm/customers/tabs/OrdersTab.jsx b/frontend/src/pages/crm/customers/tabs/OrdersTab.jsx
new file mode 100644
index 0000000..69739cd
--- /dev/null
+++ b/frontend/src/pages/crm/customers/tabs/OrdersTab.jsx
@@ -0,0 +1,481 @@
+// frontend/src/pages/crm/customers/tabs/OrdersTab.jsx
+// List of orders for a customer, each expandable with a timeline
+
+import { useState } from 'react'
+import api from '@/lib/api'
+import { useAuth } from '@/hooks/useAuth'
+import Card from '@/components/ui/Card'
+import Button from '@/components/ui/Button'
+import StatusBadge from '@/components/ui/StatusBadge'
+import ConfirmDialog from '@/components/ui/ConfirmDialog'
+import { useToast } from '@/components/ui/Toast'
+import OrderModal from '@/modals/crm/customers/OrderModal'
+import OrderStatusModal from '@/modals/crm/customers/OrderStatusModal'
+import TimelineEventModal from '@/modals/crm/customers/TimelineEventModal'
+import { InitNegotiationsModal } from '@/modals/crm/QuickEntryModals'
+import { fmtDateLong, fmtDateTime as fmtDateTimeCentral } from '@/lib/formatters'
+
+// ─── Constants ────────────────────────────────────────────────────────────────
+
+const ORDER_STATUS_LABELS = {
+ negotiating: 'Negotiating', awaiting_quotation: 'Awaiting Quotation',
+ awaiting_customer_confirmation: 'Awaiting Confirmation', awaiting_fulfilment: 'Accepted - Waiting',
+ awaiting_payment: 'Awaiting Payment', manufacturing: 'Manufacturing',
+ shipped: 'Shipped', installed: 'Installed', declined: 'Declined', complete: 'Complete',
+}
+
+const ORDER_STATUS_VARIANT = {
+ negotiating: 'neutral', awaiting_quotation: 'warning',
+ awaiting_customer_confirmation: 'info', awaiting_fulfilment: 'info',
+ awaiting_payment: 'warning', manufacturing: 'warning',
+ shipped: 'info', installed: 'success', declined: 'danger', complete: 'success',
+}
+
+const TIMELINE_TYPE_LABELS = {
+ negotiations_started: 'Started Negotiations', quote_request: 'Quote Requested',
+ quote_sent: 'Quote Sent', quote_accepted: 'Quote Accepted', quote_declined: 'Quote Declined',
+ mfg_started: 'Manufacturing Started', mfg_complete: 'Manufacturing Complete',
+ order_shipped: 'Order Shipped', installed: 'Installed', payment_received: 'Payment Received',
+ invoice_sent: 'Invoice Sent', note: 'Note',
+}
+
+// ─── Helpers ─────────────────────────────────────────────────────────────────
+
+function fmtDate(iso) { return fmtDateLong(iso) }
+function fmtDateTime(iso) { return fmtDateTimeCentral(iso) }
+
+// ─── Timeline Event Row ───────────────────────────────────────────────────────
+
+function TimelineRow({ event, index, canEdit, onEdit, onDelete }) {
+ const [hovered, setHovered] = useState(false)
+
+ return (
+
setHovered(true)}
+ onMouseLeave={() => setHovered(false)}
+ >
+ {/* Dot + line */}
+
+
+ {/* Content */}
+
+
+ {TIMELINE_TYPE_LABELS[event.type] || event.type}
+
+ {event.note && (
+
+ {event.note}
+
+ )}
+
+ {fmtDateTime(event.date)}
+ {event.updated_by && (
+ · {event.updated_by}
+ )}
+
+
+
+ {/* Hover actions */}
+ {canEdit && hovered && (
+
+ onEdit(index, event)}>Edit
+ onDelete(index)}>Delete
+
+ )}
+
+ )
+}
+
+// ─── Order Card ───────────────────────────────────────────────────────────────
+
+function OrderCard({ order, customerId, canEdit, user, onReload, initialExpanded = false }) {
+ const { toast } = useToast()
+ const [expanded, setExpanded] = useState(initialExpanded)
+
+ // Edit order modal
+ const [showEdit, setShowEdit] = useState(false)
+ // Update status modal
+ const [showStatus, setShowStatus] = useState(false)
+ // Timeline event modal
+ const [timelineModal, setTimelineModal] = useState(null) // null | { editIndex, initialData }
+ // Delete timeline
+ const [deleteTlIndex, setDeleteTlIndex] = useState(null)
+ const [deletingTl, setDeletingTl] = useState(false)
+ // Delete order
+ const [showDeleteOrder, setShowDeleteOrder] = useState(false)
+ const [deletingOrder, setDeletingOrder] = useState(false)
+
+ const timeline = [...(order.timeline || [])].sort(
+ (a, b) => (b.date || '').localeCompare(a.date || '')
+ )
+
+ const handleDeleteTimeline = async () => {
+ setDeletingTl(true)
+ try {
+ // Find original index in unsorted timeline
+ const sorted = [...(order.timeline || [])].sort((a, b) => (b.date || '').localeCompare(a.date || ''))
+ const origIdx = (order.timeline || []).findIndex(e =>
+ e.date === sorted[deleteTlIndex].date &&
+ e.note === sorted[deleteTlIndex].note &&
+ e.type === sorted[deleteTlIndex].type
+ )
+ await api.delete(`/crm/customers/${customerId}/orders/${order.id}/timeline/${origIdx}`)
+ toast.success('Deleted', 'Timeline event removed.')
+ setDeleteTlIndex(null)
+ onReload()
+ } catch (err) {
+ toast.danger('Error', err.message || 'Failed to delete event.')
+ } finally {
+ setDeletingTl(false)
+ }
+ }
+
+ const handleDeleteOrder = async () => {
+ setDeletingOrder(true)
+ try {
+ await api.delete(`/crm/customers/${customerId}/orders/${order.id}`)
+ toast.success('Deleted', 'Order removed.')
+ setShowDeleteOrder(false)
+ onReload()
+ } catch (err) {
+ toast.danger('Error', err.message || 'Failed to delete order.')
+ } finally {
+ setDeletingOrder(false)
+ }
+ }
+
+ const startEditTimeline = (_sortedIndex, event) => {
+ // Resolve original index
+ const origIdx = (order.timeline || []).findIndex(e =>
+ e.date === event.date && e.note === event.note && e.type === event.type
+ )
+ setTimelineModal({ editIndex: origIdx, initialData: event })
+ }
+
+ const variant = ORDER_STATUS_VARIANT[order.status] || 'neutral'
+
+ return (
+
+ {/* ── Order Header ─────────────────────────────────────────────────── */}
+
{
+ if (e.target.closest('button')) return
+ setExpanded(v => !v)
+ }}
+ >
+ {/* Info block */}
+
+
+ {order.order_number && (
+
+ {order.order_number}
+
+ )}
+
+ {ORDER_STATUS_LABELS[order.status] || order.status}
+
+
+
+
+ {order.title || (
+
+ Untitled order
+
+ )}
+
+
+ {order.notes && (
+
+ {order.notes}
+
+ )}
+
+
+ Created {fmtDate(order.created_at)}
+ {order.created_by ? ` by ${order.created_by}` : ''}
+ {order.status_updated_date && order.status_updated_by && (
+ <> · Status updated {fmtDate(order.status_updated_date)} by {order.status_updated_by}>
+ )}
+
+
+
+ {/* Action buttons */}
+
+ {canEdit && (
+
{ e.stopPropagation(); setShowStatus(true) }}
+ >
+ Update Status
+
+ )}
+
{ e.stopPropagation(); setExpanded(v => !v) }}
+ >
+ {expanded ? 'Hide Timeline' : `Timeline (${timeline.length})`}
+
+ {canEdit && (
+
{ e.stopPropagation(); setShowEdit(true) }}
+ >
+ Edit
+
+ )}
+ {canEdit && (
+
{ e.stopPropagation(); setShowDeleteOrder(true) }}
+ aria-label="Delete order"
+ >
+
+
+
+
+ )}
+
+
+
+ {/* ── Timeline (expanded) ───────────────────────────────────────────── */}
+ {expanded && (
+
+ {timeline.length === 0 ? (
+
+ No timeline events yet.
+
+ ) : (
+
6 ? 360 : 'none', overflowY: 'auto', paddingRight: 'var(--space-2)' }}>
+ {timeline.map((ev, i) => (
+ setDeleteTlIndex(idx)}
+ />
+ ))}
+
+ )}
+
+ {canEdit && (
+
+ setTimelineModal({ editIndex: null, initialData: null })}
+ >
+ + Add Event
+
+
+ )}
+
+ )}
+
+ {/* ── Modals ───────────────────────────────────────────────────────── */}
+
setShowEdit(false)}
+ onSaved={onReload}
+ />
+
+ setShowStatus(false)}
+ onSaved={onReload}
+ />
+
+ {timelineModal && (
+ setTimelineModal(null)}
+ onSaved={onReload}
+ />
+ )}
+
+ setDeleteTlIndex(null)}
+ onClose={() => setDeleteTlIndex(null)}
+ />
+
+ setShowDeleteOrder(false)}
+ onClose={() => setShowDeleteOrder(false)}
+ />
+
+ )
+}
+
+// ─── Main Component ───────────────────────────────────────────────────────────
+
+export default function OrdersTab({ customer, orders, canEdit, onReloadOrders }) {
+ const { user } = useAuth()
+ const [showInitNeg, setShowInitNeg] = useState(false)
+
+ if (!orders) {
+ return (
+
+ Loading orders…
+
+ )
+ }
+
+ if (orders.length === 0) {
+ return (
+
+
+
+
+
+
+
+
+ No orders yet
+
+
+ Use "Init Negotiations" from the action menu to create the first order.
+
+
+
+ )
+ }
+
+ return (
+
+ {/* Summary row */}
+
+
+ {orders.length} order{orders.length !== 1 ? 's' : ''}
+
+ {/* Status summary pills */}
+ {Object.entries(
+ orders.reduce((acc, o) => { acc[o.status] = (acc[o.status] || 0) + 1; return acc }, {})
+ ).map(([status, count]) => (
+
+ {count} {ORDER_STATUS_LABELS[status] || status}
+
+ ))}
+ {/* Add Order button — far right */}
+ {canEdit && (
+
+ setShowInitNeg(true)}>
+ + Add Order
+
+
+ )}
+
+
+ {/* Order cards */}
+ {orders.map((order, idx) => (
+
+ ))}
+
+
setShowInitNeg(false)}
+ onSuccess={() => { setShowInitNeg(false); onReloadOrders?.() }}
+ />
+
+ )
+}
diff --git a/frontend/src/pages/crm/customers/tabs/OverviewTab.jsx b/frontend/src/pages/crm/customers/tabs/OverviewTab.jsx
new file mode 100644
index 0000000..61b6327
--- /dev/null
+++ b/frontend/src/pages/crm/customers/tabs/OverviewTab.jsx
@@ -0,0 +1,1093 @@
+// frontend/src/pages/crm/customers/tabs/OverviewTab.jsx
+//
+// Overview tab — hero stats bar + two-column layout.
+// Left (~40%, max 800px): Account Info · Contact Details · Staff Notes
+// Right (flex 1): Latest Orders · Recent Communications
+//
+// --- DATA STRATEGY ---
+// All hero stat values are derived from data the tab already fetches
+// (orders, comms, quotations) to avoid extra Firestore reads.
+// Fields that can't be derived cheaply (files_count, files_size_bytes,
+// outstanding_balance, last_payment_date, open_issues_count,
+// open_support_count, last_issue_date) should be denormalized onto the
+// customer document by the backend whenever the respective sub-collection
+// changes. This is the correct pattern — one read, no fan-out.
+
+import { useState, useEffect, useRef } from 'react'
+import { useNavigate } from 'react-router-dom'
+import api from '@/lib/api'
+import Card from '@/components/ui/Card'
+import Icon from '@/components/ui/Icon'
+import Button from '@/components/ui/Button'
+import StatusBadge from '@/components/ui/StatusBadge'
+import Spinner from '@/components/ui/Spinner'
+import NoteModal from '@/modals/crm/customers/NoteModal'
+
+// ─── Constants ────────────────────────────────────────────────────────────────
+
+const LANGUAGE_LABELS = {
+ af:'Afrikaans',sq:'Albanian',am:'Amharic',ar:'Arabic',hy:'Armenian',
+ az:'Azerbaijani',eu:'Basque',be:'Belarusian',bn:'Bengali',bs:'Bosnian',
+ bg:'Bulgarian',ca:'Catalan',zh:'Chinese',hr:'Croatian',cs:'Czech',
+ da:'Danish',nl:'Dutch',en:'English',et:'Estonian',fi:'Finnish',
+ fr:'French',ka:'Georgian',de:'German',el:'Greek',gu:'Gujarati',
+ he:'Hebrew',hi:'Hindi',hu:'Hungarian',id:'Indonesian',it:'Italian',
+ ja:'Japanese',kn:'Kannada',kk:'Kazakh',ko:'Korean',lv:'Latvian',
+ lt:'Lithuanian',mk:'Macedonian',ms:'Malay',ml:'Malayalam',mt:'Maltese',
+ mr:'Marathi',mn:'Mongolian',ne:'Nepali',no:'Norwegian',fa:'Persian',
+ pl:'Polish',pt:'Portuguese',pa:'Punjabi',ro:'Romanian',ru:'Russian',
+ sr:'Serbian',si:'Sinhala',sk:'Slovak',sl:'Slovenian',es:'Spanish',
+ sw:'Swahili',sv:'Swedish',tl:'Tagalog',ta:'Tamil',te:'Telugu',
+ th:'Thai',tr:'Turkish',uk:'Ukrainian',ur:'Urdu',uz:'Uzbek',
+ vi:'Vietnamese',cy:'Welsh',yi:'Yiddish',zu:'Zulu',
+}
+
+const REL_STATUS_META = {
+ lead: { label: 'Lead', variant: 'info', description: 'Potential new lead — no active engagement yet.' },
+ prospect: { label: 'Prospect', variant: 'warning', description: 'Actively engaged — exploring possibilities.' },
+ active: { label: 'Active', variant: 'success', description: 'Active customer with ongoing commercial activity.' },
+ inactive: { label: 'Archived', variant: 'neutral', description: 'Customer has completed all engagements.' },
+ churned: { label: 'Went Cold', variant: 'danger', description: 'Customer disengaged. Might need a follow up' },
+}
+
+const ACCENT_COLOR = {
+ success: 'var(--color-success)',
+ warning: 'var(--color-warning)',
+ danger: 'var(--color-danger)',
+ info: 'var(--color-info)',
+ neutral: 'var(--color-text-muted)',
+}
+const ACCENT_BG = {
+ success: 'var(--color-success-bg)',
+ warning: 'var(--color-warning-bg)',
+ danger: 'var(--color-danger-bg)',
+ info: 'var(--color-info-bg)',
+ neutral: 'var(--color-bg-elevated)',
+}
+
+const TYPE_META = {
+ email: { label: 'Email', icon: '/src/assets/comms/email.svg', color: 'var(--color-info)', bg: 'var(--color-info-bg)' },
+ whatsapp: { label: 'WhatsApp', icon: '/src/assets/comms/whatsapp.svg', color: 'var(--color-success)', bg: 'var(--color-success-bg)' },
+ call: { label: 'Call', icon: '/src/assets/comms/call.svg', color: 'var(--color-warning)', bg: 'var(--color-warning-bg)' },
+ sms: { label: 'SMS', icon: '/src/assets/comms/sms.svg', color: 'var(--color-warning)', bg: 'var(--color-warning-bg)' },
+ note: { label: 'Note', icon: '/src/assets/comms/note.svg', color: 'var(--color-text-muted)', bg: 'var(--color-bg-elevated)' },
+ in_person: { label: 'In person', icon: '/src/assets/comms/inperson.svg', color: 'var(--color-primary)', bg: 'var(--color-primary-subtle)' },
+}
+
+const ORDER_STATUS_META = {
+ draft: { label: 'Draft', variant: 'neutral' },
+ confirmed: { label: 'Confirmed', variant: 'info' },
+ in_production: { label: 'In Production', variant: 'warning' },
+ ready_to_ship: { label: 'Ready to Ship', variant: 'info' },
+ shipped: { label: 'Shipped', variant: 'info' },
+ delivered: { label: 'Delivered', variant: 'success' },
+ cancelled: { label: 'Cancelled', variant: 'danger' },
+ awaiting_payment: { label: 'Awaiting Payment', variant: 'warning' },
+ paid: { label: 'Paid', variant: 'success' },
+}
+
+// ─── Helpers ──────────────────────────────────────────────────────────────────
+
+function formatRelative(val) {
+ if (!val) return ''
+ const diff = Date.now() - new Date(val).getTime()
+ if (diff < 0) return 'just now'
+ const s = Math.floor(diff / 1000)
+ if (s < 60) return 'just now'
+ const m = Math.floor(s / 60)
+ if (m < 60) return `${m}m ago`
+ const h = Math.floor(m / 60)
+ if (h < 24) return `${h}h ago`
+ const d = Math.floor(h / 24)
+ if (d === 1) return 'yesterday'
+ if (d < 7) return `${d} days ago`
+ const w = Math.floor(d / 7)
+ if (w < 5) return `${w} week${w !== 1 ? 's' : ''} ago`
+ const mo = Math.floor(d / 30)
+ if (mo < 12) return `${mo} month${mo !== 1 ? 's' : ''} ago`
+ return `${Math.floor(d / 365)} year${Math.floor(d / 365) !== 1 ? 's' : ''} ago`
+}
+
+
+function formatCurrency(val, currency = 'EUR') {
+ if (val == null) return null
+ return new Intl.NumberFormat('en-DE', { style: 'currency', currency, minimumFractionDigits: 2 }).format(val)
+}
+
+
+function buildAddress(loc) {
+ if (!loc) return null
+ return [loc.address, loc.city, loc.postal_code, loc.region, loc.country].filter(Boolean).join(', ')
+}
+
+// ─── Helpers ──────────────────────────────────────────────────────────────────
+
+// Zero-pad single digit numbers: 5 → "05", 12 → "12", 0 → "00"
+function padNum(n) {
+ if (n == null) return '00'
+ const num = typeof n === 'number' ? n : parseFloat(n)
+ if (Number.isNaN(num)) return String(n) // non-numeric (currency strings etc) — pass through
+ return num < 10 && num >= 0 ? `0${Math.floor(num)}` : String(Math.floor(num))
+}
+
+// ─── Stat cell — label / (value + subtitle inline) ────────────────────────────
+// Each cell is centered in its grid column. All cells in a row share the same
+// column width via CSS grid on the parent (see HeroStatsBar).
+
+function StatCell({ label, value, sub, accentColor, loading }) {
+ const isResolved = !loading && value != null
+ const displayed = isResolved ? (typeof value === 'number' ? padNum(value) : String(value)) : '00'
+
+ return (
+
+ {/* Label */}
+
+ {label}
+
+
+ {/* Value + subtitle inline */}
+
+
+ {displayed}
+
+ {sub && (
+
+ {loading ? '—' : sub}
+
+ )}
+
+
+ )
+}
+
+// ─── Status panel — left 20%, full height of the hero ─────────────────────────
+
+function StatusPanel({ customer, canEdit, onUpdated }) {
+ const status = customer.relationship_status || 'lead'
+ const meta = REL_STATUS_META[status] || REL_STATUS_META.lead
+ const accent = ACCENT_COLOR[meta.variant]
+ const bg = ACCENT_BG[meta.variant]
+
+ const [open, setOpen] = useState(false)
+ const [saving, setSaving] = useState(false)
+ const ref = useRef(null)
+
+ useEffect(() => {
+ if (!open) return
+ const handler = e => { if (ref.current && !ref.current.contains(e.target)) setOpen(false) }
+ document.addEventListener('mousedown', handler)
+ return () => document.removeEventListener('mousedown', handler)
+ }, [open])
+
+ const handleSelect = async newStatus => {
+ if (!canEdit || newStatus === status) { setOpen(false); return }
+ setSaving(true); setOpen(false)
+ try {
+ const updated = await api.patch(`/crm/customers/${customer.id}/relationship-status`, { status: newStatus })
+ onUpdated(updated)
+ } catch (err) {
+ console.error('Status update failed:', err.message)
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ return (
+
+
+ {/* Label */}
+
+ Relationship
+
+
+ {/* Big pulsing dot + status name */}
+
+
+
+ {meta.label}
+
+
+
+ {/* Description */}
+
+ {meta.description}
+
+
+ {/* Change button */}
+ {canEdit && (
+
setOpen(v => !v)}
+ style={{
+ marginTop: 'var(--space-1)',
+ display: 'inline-flex',
+ alignItems: 'center',
+ gap: 'var(--space-1)',
+ padding: '4px 10px',
+ borderRadius: 'var(--radius-full)',
+ backgroundColor: 'transparent',
+ border: `1px solid ${accent}`,
+ color: accent,
+ cursor: 'pointer',
+ fontSize: 'var(--font-size-xs)',
+ fontWeight: 'var(--font-weight-semibold)',
+ fontFamily: 'var(--font-family-base)',
+ opacity: saving ? 0.5 : 0.8,
+ transition: 'opacity var(--transition-fast)',
+ }}
+ onMouseEnter={e => { e.currentTarget.style.opacity = '1' }}
+ onMouseLeave={e => { e.currentTarget.style.opacity = saving ? '0.5' : '0.8' }}
+ >
+ Change
+
+
+
+
+ )}
+
+ {/* Dropdown — opens downward */}
+ {open && (
+
+ {Object.entries(REL_STATUS_META).map(([key, m], i) => (
+
handleSelect(key)}
+ style={{
+ display: 'flex', alignItems: 'flex-start', gap: 'var(--space-3)',
+ width: '100%',
+ padding: 'var(--space-3) var(--space-4)',
+ borderTop: i > 0 ? '1px solid var(--color-border)' : 'none',
+ backgroundColor: key === status ? ACCENT_BG[m.variant] : 'transparent',
+ border: 'none', cursor: 'pointer', textAlign: 'left',
+ transition: 'background-color var(--transition-fast)',
+ }}
+ onMouseEnter={e => { if (key !== status) e.currentTarget.style.backgroundColor = 'var(--color-bg-island)' }}
+ onMouseLeave={e => { if (key !== status) e.currentTarget.style.backgroundColor = key === status ? ACCENT_BG[m.variant] : 'transparent' }}
+ >
+
+
+
+ {m.label}
+
+
+ {m.description}
+
+
+
+ ))}
+
+ )}
+
+ )
+}
+
+// ─── Hero section — 20/80 split ───────────────────────────────────────────────
+//
+// Left 20%: StatusPanel — tinted bg, big status name, description, change button
+// Right 80%: 4-column × 2-row stats grid
+// Row 1: Orders | Balance | Issues | Support
+// Row 2: Communications | Quotations | Files | Devices
+
+function HeroStatsBar({ customer, orders, comms, quotations, ordersLoading, commsLoading, quotesLoading, onStatusUpdated }) {
+
+ // ── Communications ─────────────────────────────────────────────────────────
+ const commsCount = comms.length
+ const lastComm = comms[0]
+ const lastCommDate = lastComm ? formatRelative(lastComm.occurred_at || lastComm.created_at) : null
+
+ // ── Quotations ─────────────────────────────────────────────────────────────
+ const quotesCount = quotations.length
+ const lastQuote = quotations[0]
+ const lastQuoteDate = lastQuote ? formatRelative(lastQuote.created_at || lastQuote.date) : null
+
+ // ── Orders ─────────────────────────────────────────────────────────────────
+ const openOrders = orders.filter(o => !['delivered', 'cancelled', 'paid'].includes(o.status))
+ const latestOrder = orders[0]
+ const latestOrderStatus = latestOrder ? (ORDER_STATUS_META[latestOrder.status]?.label || latestOrder.status) : null
+
+ // ── Balance ────────────────────────────────────────────────────────────────
+ const txHistory = customer.transaction_history || []
+ const totalInvoiced = txHistory.filter(tx => tx.flow === 'invoice').reduce((s, tx) => s + (tx.amount || 0), 0)
+ const totalPaid = txHistory.filter(tx => tx.flow === 'payment').reduce((s, tx) => s + (tx.amount || 0), 0)
+ const outstanding = txHistory.length ? (totalInvoiced - totalPaid) : null
+ const lastPayTx = [...txHistory].reverse().find(tx => tx.flow === 'payment')
+ const lastPayDate = lastPayTx?.date ? formatRelative(lastPayTx.date) : null
+ const balanceDisplay = outstanding == null ? null
+ : Math.abs(outstanding) < 0.01 ? '€0'
+ : formatCurrency(Math.abs(outstanding))
+ const balanceSub = outstanding == null ? null
+ : Math.abs(outstanding) < 0.01 ? 'all settled'
+ : outstanding > 0 ? (lastPayDate || 'outstanding')
+ : 'credit'
+ const balanceColor = outstanding == null ? undefined
+ : Math.abs(outstanding) < 0.01 ? 'var(--color-success)'
+ : outstanding > 0 ? 'var(--color-warning)'
+ : 'var(--color-info)'
+
+ // ── Files ──────────────────────────────────────────────────────────────────
+ const filesCount = null // no backend field yet
+
+ // ── Devices ────────────────────────────────────────────────────────────────
+ const totalDevices = (customer.owned_items || []).length
+
+ // ── Issues & Support ───────────────────────────────────────────────────────
+ const summary = customer.crm_summary || {}
+ const openIssues = summary.active_issues_count ?? (customer.technical_issues || []).filter(i => i.active).length
+ const openSupport = summary.active_support_count ?? (customer.install_support || []).filter(i => i.active).length
+ const lastIssueDateRel = summary.latest_issue_date ? formatRelative(summary.latest_issue_date) : null
+ const lastSupportDateRel = summary.latest_support_date ? formatRelative(summary.latest_support_date) : null
+
+ return (
+
+
+ {/* ── Left 20%: Status panel ── */}
+
+
+
+
+ {/* ── Right 80%: 4×2 grid, columns fixed-width, centered in the space ── */}
+
+ {/* Row 1: Orders | Balance | Issues | Support */}
+
+
+ 0 ? 'var(--color-danger)' : undefined}
+ loading={false}
+ />
+ 0 ? 'var(--color-warning)' : undefined}
+ loading={false}
+ />
+ {/* Row 2: Communications | Quotations | Files | Devices */}
+
+
+
+
+
+
+
+ )
+}
+
+// ─── Vertical info field: label above value (matches card-body spacing) ────────
+
+function InfoField({ label, children }) {
+ return (
+
+
{label}
+
+ {children || — }
+
+
+ )
+}
+
+// ─── Contact row ──────────────────────────────────────────────────────────────
+
+const CONTACT_ICONS = {
+ email: (
+
+
+
+ ),
+ phone: (
+
+
+
+ ),
+ whatsapp: (
+
+
+
+ ),
+ other: (
+
+
+
+
+ ),
+}
+
+const CONTACT_COLOR = {
+ email: { color: 'var(--color-info)', bg: 'var(--color-info-bg)' },
+ phone: { color: 'var(--color-success)', bg: 'var(--color-success-bg)' },
+ whatsapp: { color: 'var(--color-success)', bg: 'var(--color-success-bg)' },
+ other: { color: 'var(--color-text-muted)', bg: 'var(--color-bg-elevated)' },
+}
+
+function ContactRow({ type, value, label, primary }) {
+ const icon = CONTACT_ICONS[type] || CONTACT_ICONS.other
+ const style = CONTACT_COLOR[type] || CONTACT_COLOR.other
+ return (
+
+
+ {icon}
+
+
+
+ {value}
+
+
+ {primary && (
+
+ Primary
+
+ )}
+ {label && (
+ {label}
+ )}
+
+
+
+ )
+}
+
+// ─── Staff note row ────────────────────────────────────────────────────────────
+
+function NoteRow({ note, index, canEdit, onEdit }) {
+ const [hovered, setHovered] = useState(false)
+ const text = note.text || note.content || note.body || ''
+ const author = note.by || note.author_name || note.staff_name || 'Staff'
+ const date = note.at || note.updated_at || note.created_at
+
+ return (
+
setHovered(true)}
+ onMouseLeave={() => setHovered(false)}
+ style={{
+ padding: 'var(--space-3) var(--space-4)',
+ borderRadius: 'var(--radius-md)',
+ backgroundColor: 'var(--color-bg-abyss)',
+ border: '1px solid var(--color-border)',
+ position: 'relative',
+ transition: 'border-color var(--transition-fast)',
+ borderColor: hovered ? 'var(--color-border-strong)' : 'var(--color-border)',
+ }}
+ >
+ {/* Note text — preserves newlines */}
+
+ {text || Empty note }
+
+
+ {/* Footer: author · date */}
+
+
+ {author}
+
+ {date && (
+ <>
+ ·
+
+ {formatRelative(date)}
+
+ >
+ )}
+
+
+ {/* Hover edit button */}
+ {canEdit && hovered && (
+
+ onEdit(index, note)}>Edit
+
+ )}
+
+ )
+}
+
+function StaffNotes({ notes, canEdit, onEdit }) {
+ if (!notes?.length) {
+ return
No notes yet.
+ }
+ return (
+
+ {notes.map((note, i) => (
+
+ ))}
+
+ )
+}
+
+// ─── Comm timeline entry ──────────────────────────────────────────────────────
+
+function CommEntry({ comm, isLast, onNavigateToComms }) {
+ const [hovered, setHovered] = useState(false)
+ const meta = TYPE_META[comm.type] || TYPE_META.note
+ return (
+
setHovered(true)}
+ onMouseLeave={() => setHovered(false)}
+ onClick={onNavigateToComms}
+ style={{
+ display: 'flex', gap: 'var(--space-4)',
+ paddingBottom: isLast ? 0 : 'var(--space-5)',
+ position: 'relative',
+ cursor: 'pointer',
+ borderRadius: 'var(--radius-md)',
+ margin: '0 calc(-1 * var(--space-4))',
+ padding: `var(--space-3) var(--space-4) ${isLast ? 'var(--space-3)' : 'var(--space-5)'}`,
+ backgroundColor: hovered ? 'var(--color-bg-elevated)' : 'transparent',
+ transition: 'background-color var(--transition-fast)',
+ }}>
+ {!isLast && (
+
+ )}
+
+
+
+
+
+
+ {comm.subject || `${meta.label} ${comm.direction === 'inbound' ? 'received' : 'sent'}`}
+
+
+ {formatRelative(comm.occurred_at || comm.created_at)}
+
+
+ {(comm.from_name || comm.staff_name) && (
+
+
+ {(comm.from_name || comm.staff_name || '?').charAt(0).toUpperCase()}
+
+
+ {comm.from_name || comm.staff_name}
+
+
+ )}
+ {comm.body && (
+
+ {comm.body}
+
+ )}
+
+
+ )
+}
+
+// ─── Main component ───────────────────────────────────────────────────────────
+
+export default function OverviewTab({ customer, canEdit, onCustomerUpdated, onTabChange }) {
+ const navigate = useNavigate()
+ const customerId = customer.id
+
+ const [orders, setOrders] = useState([])
+ const [comms, setComms] = useState([])
+ const [quotations, setQuotations] = useState([])
+ const [ordersLoading, setOrdersLoading] = useState(true)
+ const [commsLoading, setCommsLoading] = useState(true)
+ const [quotesLoading, setQuotesLoading] = useState(true)
+
+ // Note modal: null | { editIndex: number|null, initialData: object|null }
+ const [noteModal, setNoteModal] = useState(null)
+
+ useEffect(() => {
+ setOrdersLoading(true)
+ api.get(`/crm/customers/${customerId}/orders`)
+ .then(d => setOrders(d.orders || []))
+ .catch(() => setOrders([]))
+ .finally(() => setOrdersLoading(false))
+ }, [customerId])
+
+ useEffect(() => {
+ setCommsLoading(true)
+ api.get(`/crm/comms?customer_id=${customerId}&limit=5`)
+ .then(d => setComms(d.entries || []))
+ .catch(() => setComms([]))
+ .finally(() => setCommsLoading(false))
+ }, [customerId])
+
+ useEffect(() => {
+ setQuotesLoading(true)
+ api.get(`/crm/quotations/customer/${customerId}`)
+ .then(d => setQuotations((d.quotations || []).slice(0, 5)))
+ .catch(() => setQuotations([]))
+ .finally(() => setQuotesLoading(false))
+ }, [customerId])
+
+ // Derived values
+ const contacts = customer.contacts || []
+ const billingAddr = buildAddress(customer.billing_location || customer.location)
+ const langLabel = LANGUAGE_LABELS[customer.language] || customer.language || null
+ const tags = customer.tags || []
+ const notes = customer.notes || []
+ const latestOrders = orders.slice(0, 5)
+ const recentComms = comms.slice(0, 5)
+
+ return (
+
+
+ {/* ── Hero Stats Bar ─────────────────────────────────────────────── */}
+
+
+ {/* ── Two-column layout ──────────────────────────────────────────── */}
+
+
+ {/* LEFT column — 40%, max 800px */}
+
+
+ {/* Account Information */}
+
}>
+
+
+
+ {/* Contact Details */}
+
}
+ action={canEdit && (
+
navigate(`/crm/customers/${customerId}/edit`)}>
+ Edit
+
+ )}
+ >
+ {contacts.length === 0 ? (
+
No contacts on record.
+ ) : (
+
+ {contacts.map((c, i) => (
+
+ ))}
+
+ )}
+
+
+ {/* Staff Notes */}
+
}
+ action={canEdit && (
+
setNoteModal({ editIndex: null, initialData: null })}>
+ + Add Note
+
+ )}
+ >
+
setNoteModal({ editIndex: index, initialData: note })}
+ />
+
+
+
+
+ {/* RIGHT column — flex, expands freely */}
+
+
+ {/* Latest Orders */}
+
}
+ action={
+
onTabChange('orders')}>
+ View All
+
+ }
+ >
+
+ {ordersLoading ? (
+
+
+
+ ) : latestOrders.length === 0 ? (
+
+ No orders yet.
+
+ ) : (
+
+
+
+
+ {['Order', 'Last Updated', 'Status', 'Value'].map(h => (
+
+ {h}
+
+ ))}
+
+
+
+ {latestOrders.map(order => {
+ const sm = ORDER_STATUS_META[order.status] || { label: order.status, variant: 'neutral' }
+ const lastUpdated = order.updated_at || order.status_updated_at || order.created_at
+ const value = order.total_value ?? order.total ?? order.amount ?? null
+ return (
+ onTabChange('orders')}
+ onMouseEnter={e => e.currentTarget.style.backgroundColor = 'var(--color-bg-elevated)'}
+ onMouseLeave={e => e.currentTarget.style.backgroundColor = 'transparent'}
+ >
+
+
+
+ {order.title || order.name || order.order_number || `Order #${order.id}`}
+
+ {order.order_number && (order.title || order.name) && (
+
+ {order.order_number}
+
+ )}
+
+
+
+ {lastUpdated ? formatRelative(lastUpdated) : '—'}
+
+
+ {sm.label}
+
+
+ {value != null ? formatCurrency(value, order.currency || 'EUR') : unavailable }
+
+
+ )
+ })}
+
+
+
+ )}
+
+
+ {/* Recent Communications */}
+
}
+ action={
+
onTabChange('communication')}>
+ View All
+
+ }
+ >
+
+ {commsLoading ? (
+
+
+
+ ) : recentComms.length === 0 ? (
+
+ No communications on record.
+
+ ) : (
+ recentComms.map((comm, i) => (
+
onTabChange('communication')}
+ />
+ ))
+ )}
+
+
+
+
+
+ {/* Keyframes injected once */}
+
+
+ {/* Note modal */}
+
setNoteModal(null)}
+ onSaved={updated => { setNoteModal(null); onCustomerUpdated(updated) }}
+ />
+
+ )
+}
diff --git a/frontend/src/pages/crm/customers/tabs/QuotationsTab.jsx b/frontend/src/pages/crm/customers/tabs/QuotationsTab.jsx
new file mode 100644
index 0000000..a814013
--- /dev/null
+++ b/frontend/src/pages/crm/customers/tabs/QuotationsTab.jsx
@@ -0,0 +1,572 @@
+// frontend/src/pages/crm/customers/tabs/QuotationsTab.jsx
+// Customer-scoped quotation list — fetches from /crm/quotations/customer/{id}
+// Reuses the same PDF thumbnail + row design as the global QuotationList page.
+
+import { useState, useEffect, useCallback, useRef } from 'react'
+import { useNavigate } from 'react-router-dom'
+import api from '@/lib/api'
+import Button from '@/components/ui/Button'
+import StatusBadge from '@/components/ui/StatusBadge'
+import Spinner from '@/components/ui/Spinner'
+import SearchBar from '@/components/ui/SearchBar'
+import Select from '@/components/ui/Select'
+import ConfirmDialog from '@/components/ui/ConfirmDialog'
+import Icon from '@/components/ui/Icon'
+import PdfViewModal from '@/modals/crm/PdfViewModal'
+import ComposeEmailModal from '@/modals/crm/ComposeEmailModal'
+import { useToast } from '@/components/ui/Toast'
+import { fmtDate as fmtDateCentral, fmtEuro } from '@/lib/formatters'
+
+// ─── Helpers ──────────────────────────────────────────────────────────────────
+
+const STATUS_BADGE_MAP = { draft: 'neutral', built: 'warning', sent: 'info', accepted: 'success', declined: 'danger' }
+const STATUS_OPTIONS = ['draft', 'built', 'sent', 'accepted', 'declined']
+
+function fmtDate(iso) { return fmtDateCentral(iso) }
+function fmt(n) { return fmtEuro(n) }
+
+// ─── PDF thumbnail (via PDF.js) ───────────────────────────────────────────────
+
+const THUMB_W = 60, THUMB_H = 78
+
+function loadPdfJs() {
+ return new Promise((res, rej) => {
+ if (window.pdfjsLib) { res(); return }
+ if (document.getElementById('__pdfjs__')) {
+ const check = setInterval(() => { if (window.pdfjsLib) { clearInterval(check); res() } }, 50)
+ return
+ }
+ const s = document.createElement('script')
+ s.id = '__pdfjs__'
+ 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 loadingTask = window.pdfjsLib.getDocument({
+ url: `/api/crm/quotations/${quotationId}/pdf`,
+ 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 vp = page.getViewport({ scale: 1 })
+ const scale = Math.min(THUMB_W / vp.width, THUMB_H / vp.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 baseStyle = {
+ width: THUMB_W, height: THUMB_H, borderRadius: 'var(--radius-sm)', overflow: 'hidden',
+ cursor: 'pointer', border: '1px solid var(--color-border)', display: 'flex',
+ alignItems: 'center', justifyContent: 'center', backgroundColor: 'var(--color-bg-abyss)',
+ flexShrink: 0, transition: 'border-color 0.15s, box-shadow 0.15s',
+ }
+
+ return (
+
{ e.currentTarget.style.borderColor = 'var(--color-primary)'; e.currentTarget.style.boxShadow = 'var(--shadow-focus)' }}
+ onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--color-border)'; e.currentTarget.style.boxShadow = 'none' }}
+ >
+ {failed ? (
+
+
+
+
+
+ ) : (
+
+ )}
+
+ )
+}
+
+function DraftThumbnail() {
+ return (
+
+ )
+}
+
+// ─── Actions dropdown ─────────────────────────────────────────────────────────
+
+function ActionsMenu({ quotation, onSend, onEdit, onCopy, onDelete, onStatusChange, deleting }) {
+ const [open, setOpen] = useState(false)
+ const [statusOpen, setStatusOpen] = useState(false)
+ const [menuPos, setMenuPos] = useState({ top: 0, right: 0 })
+ const ref = useRef(null)
+ const btnRef = useRef(null)
+
+ useEffect(() => {
+ if (!open) return
+ const h = e => { if (ref.current && !ref.current.contains(e.target)) { setOpen(false); setStatusOpen(false) } }
+ document.addEventListener('mousedown', h)
+ return () => document.removeEventListener('mousedown', h)
+ }, [open])
+
+ const openMenu = (e) => {
+ e.stopPropagation()
+ if (!open && btnRef.current) {
+ const r = btnRef.current.getBoundingClientRect()
+ setMenuPos({ top: r.bottom + 4, right: window.innerWidth - r.right })
+ }
+ setOpen(o => !o)
+ setStatusOpen(false)
+ }
+
+ const item = (label, icon, onClick, danger = false) => (
+
{ e.stopPropagation(); setOpen(false); setStatusOpen(false); onClick() }}
+ style={{
+ width: '100%', display: 'flex', alignItems: 'center', gap: 'var(--space-2)',
+ padding: 'var(--space-2) var(--space-3)', backgroundColor: 'transparent', border: 'none',
+ cursor: 'pointer', fontSize: 'var(--font-size-sm)', textAlign: 'left',
+ color: danger ? 'var(--color-danger)' : 'var(--color-text-secondary)',
+ transition: 'background-color 0.1s',
+ }}
+ onMouseEnter={e => e.currentTarget.style.backgroundColor = danger ? 'var(--color-danger-bg)' : 'var(--color-bg-elevated)'}
+ onMouseLeave={e => e.currentTarget.style.backgroundColor = 'transparent'}
+ >
+
+ {label}
+
+ )
+
+ return (
+
+
{ e.currentTarget.style.borderColor = 'var(--color-border-strong)'; e.currentTarget.style.backgroundColor = 'var(--color-bg-elevated)' }}
+ onMouseLeave={e => { if (!open) { e.currentTarget.style.borderColor = 'var(--color-border)'; e.currentTarget.style.backgroundColor = 'transparent' } }}
+ >
+ Actions
+
+
+
+
+
+ {open && (
+
+ {item('Send', 'mail', onSend)}
+ {item('Edit', 'edit', onEdit)}
+ {item('Copy', 'copy', onCopy)}
+
+ {/* Status sub-menu toggle */}
+
{ e.stopPropagation(); setStatusOpen(o => !o) }}
+ style={{
+ width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
+ padding: 'var(--space-2) var(--space-3)', backgroundColor: statusOpen ? 'var(--color-bg-elevated)' : 'transparent',
+ border: 'none', cursor: 'pointer', fontSize: 'var(--font-size-sm)', color: 'var(--color-text-secondary)',
+ transition: 'background-color 0.1s',
+ }}
+ onMouseEnter={e => e.currentTarget.style.backgroundColor = 'var(--color-bg-elevated)'}
+ onMouseLeave={e => { if (!statusOpen) e.currentTarget.style.backgroundColor = 'transparent' }}
+ >
+
+
+ Change Status
+
+
+
+
+
+
+ {statusOpen && (
+
+ {STATUS_OPTIONS.map(s => (
+ { e.stopPropagation(); setOpen(false); setStatusOpen(false); onStatusChange(s) }}
+ disabled={quotation.status === s}
+ style={{
+ width: '100%', display: 'flex', alignItems: 'center', gap: 'var(--space-2)',
+ padding: 'var(--space-2) var(--space-4)', backgroundColor: 'transparent', border: 'none',
+ cursor: quotation.status === s ? 'default' : 'pointer', fontSize: 'var(--font-size-sm)',
+ color: quotation.status === s ? 'var(--color-text-muted)' : 'var(--color-text-secondary)',
+ opacity: quotation.status === s ? 0.5 : 1, textAlign: 'left', transition: 'background-color 0.1s',
+ }}
+ onMouseEnter={e => { if (quotation.status !== s) e.currentTarget.style.backgroundColor = 'var(--color-bg-elevated)' }}
+ onMouseLeave={e => e.currentTarget.style.backgroundColor = 'transparent'}
+ >
+ {s.charAt(0).toUpperCase() + s.slice(1)}
+
+ ))}
+
+ )}
+
+
+
{ e.stopPropagation(); setOpen(false); setStatusOpen(false); onDelete(e) }}
+ disabled={deleting}
+ style={{
+ width: '100%', display: 'flex', alignItems: 'center', gap: 'var(--space-2)',
+ padding: 'var(--space-2) var(--space-3)', backgroundColor: 'transparent', border: 'none',
+ cursor: deleting ? 'not-allowed' : 'pointer', fontSize: 'var(--font-size-sm)',
+ color: 'var(--color-danger)', textAlign: 'left', opacity: deleting ? 0.5 : 1,
+ transition: 'background-color 0.1s',
+ }}
+ onMouseEnter={e => e.currentTarget.style.backgroundColor = 'var(--color-danger-bg)'}
+ onMouseLeave={e => e.currentTarget.style.backgroundColor = 'transparent'}
+ >
+
+ Delete
+
+
+ )}
+
+ )
+}
+
+// ─── Main component ───────────────────────────────────────────────────────────
+
+export default function QuotationsTab({ customer, canEdit }) {
+ const navigate = useNavigate()
+ const { toast } = useToast()
+
+ const [quotations, setQuotations] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState(null)
+ const [search, setSearch] = useState('')
+ const [filterStatus, setFilterStatus] = useState('')
+ const [pdfPreview, setPdfPreview] = useState(null)
+ const [deleting, setDeleting] = useState(null)
+ const [deleteTarget, setDeleteTarget] = useState(null)
+ const [composeTarget, setComposeTarget] = useState(null) // { quotation, defaultTo, attachments: File[] }
+
+ const load = useCallback(async () => {
+ setLoading(true)
+ setError(null)
+ try {
+ const res = await api.get(`/crm/quotations/customer/${customer.id}`)
+ setQuotations(Array.isArray(res) ? res : (res.quotations || []))
+ } catch {
+ setError('Failed to load quotations.')
+ setQuotations([])
+ } finally {
+ setLoading(false)
+ }
+ }, [customer.id])
+
+ useEffect(() => { load() }, [load])
+
+ const handleDelete = async () => {
+ if (!deleteTarget) return
+ setDeleting(deleteTarget.id)
+ try {
+ await api.delete(`/crm/quotations/${deleteTarget.id}`)
+ toast.success('Deleted', `Quotation ${deleteTarget.quotation_number} removed.`)
+ setQuotations(prev => prev.filter(x => x.id !== deleteTarget.id))
+ setDeleteTarget(null)
+ } catch {
+ toast.danger('Error', 'Failed to delete quotation.')
+ } finally {
+ setDeleting(null)
+ }
+ }
+
+ // Navigate to global quotation form pre-scoped to this customer
+ const handleNew = () => navigate(`/crm/quotations/new?customer_id=${customer.id}`)
+
+ const handleSend = async (q) => {
+ const customerEmail = (customer.contacts || []).find(c => c.type === 'email')?.value || ''
+ const hasPdf = q.is_legacy ? !!q.legacy_pdf_path : !!q.nextcloud_pdf_url
+ let attachments = []
+ if (hasPdf) {
+ try {
+ const token = localStorage.getItem('access_token')
+ const resp = await fetch(`/api/crm/quotations/${q.id}/pdf`, {
+ headers: { Authorization: `Bearer ${token}` },
+ })
+ if (resp.ok) {
+ const blob = await resp.blob()
+ attachments = [new File([blob], `${q.quotation_number}.pdf`, { type: 'application/pdf' })]
+ }
+ } catch { /* open without attachment if fetch fails */ }
+ }
+ setComposeTarget({ quotation: q, defaultTo: customerEmail, attachments })
+ }
+
+ const handleStatusChange = async (q, newStatus) => {
+ try {
+ await api.put(`/crm/quotations/${q.id}`, { status: newStatus })
+ setQuotations(prev => prev.map(x => x.id === q.id ? { ...x, status: newStatus } : x))
+ toast.success('Status updated', `Quotation ${q.quotation_number} marked as ${newStatus}.`)
+ } catch {
+ toast.danger('Error', 'Failed to update status.')
+ }
+ }
+
+ const handleEdit = (q) => navigate(`/crm/quotations/${q.id}`)
+
+ const handleCopy = (q) => navigate(`/crm/quotations/new?copy_from=${q.id}&customer_id=${customer.id}`)
+
+ const filtered = quotations.filter(q => {
+ if (filterStatus && q.status !== filterStatus) return false
+ if (search.trim()) {
+ const s = search.toLowerCase()
+ if (
+ !(q.quotation_number || '').toLowerCase().includes(s) &&
+ !(q.title || '').toLowerCase().includes(s)
+ ) return false
+ }
+ return true
+ })
+
+ return (
+
+ {/* Toolbar */}
+
+
+
+
+
setFilterStatus(e.target.value)} style={{ width: 150 }}>
+ All statuses
+ {STATUS_OPTIONS.map(s => (
+ {s.charAt(0).toUpperCase() + s.slice(1)}
+ ))}
+
+ {(search || filterStatus) && (
+
{ setSearch(''); setFilterStatus('') }}>Clear
+ )}
+ {canEdit && (
+
+
+ + New Quotation
+
+
+ )}
+
+
+ {/* Loading */}
+ {loading && (
+
+
+
+ )}
+
+ {/* Error */}
+ {!loading && error && (
+
+ {error}
+
+ )}
+
+ {/* Empty */}
+ {!loading && !error && filtered.length === 0 && (
+
+
+
+
+
+
+
+
+ {quotations.length === 0 ? 'No quotations yet' : 'No results match your filters'}
+
+
+ {quotations.length === 0
+ ? 'Create the first quotation for this customer.'
+ : 'Try adjusting your search or status filter.'}
+
+ {canEdit && quotations.length === 0 && (
+
+ New Quotation
+
+ )}
+
+ )}
+
+ {/* Quotation rows */}
+ {!loading && !error && filtered.length > 0 && (
+
+ {/* Header */}
+
+ {['', 'Number', 'Title', 'Date', 'Status', 'Total', ''].map((h, i) => (
+
+ {h}
+
+ ))}
+
+
+ {/* Rows */}
+ {filtered.map((q, i) => {
+ const hasPdf = q.is_legacy ? !!q.legacy_pdf_path : !!q.nextcloud_pdf_url
+ const displayDate = q.is_legacy
+ ? (q.legacy_date ? fmtDate(q.legacy_date) : fmtDate(q.created_at))
+ : fmtDate(q.created_at)
+
+ return (
+
navigate(`/crm/quotations/${q.id}`)}
+ style={{
+ display: 'grid',
+ gridTemplateColumns: `${THUMB_W}px 1fr minmax(0,2fr) 100px 110px 140px 110px`,
+ gap: 'var(--space-3)',
+ padding: 'var(--space-4) var(--space-5)',
+ borderBottom: i < filtered.length - 1 ? '1px solid var(--color-border)' : 'none',
+ alignItems: 'center',
+ cursor: 'pointer',
+ transition: 'background-color 0.12s',
+ minHeight: 88,
+ }}
+ onMouseEnter={e => e.currentTarget.style.backgroundColor = 'var(--color-bg-elevated)'}
+ onMouseLeave={e => e.currentTarget.style.backgroundColor = ''}
+ >
+ {/* Thumbnail */}
+
e.stopPropagation()}>
+ {hasPdf
+ ?
setPdfPreview({ id: q.id, number: q.quotation_number })} />
+ : }
+
+
+ {/* Number */}
+
+
+ {q.quotation_number}
+
+ {q.is_legacy && (
+
+ Legacy
+
+ )}
+
+
+ {/* Title */}
+
+ {q.title || Untitled }
+
+
+ {/* Date */}
+
+ {displayDate}
+
+
+ {/* Status */}
+
+
+ {q.status ? q.status.charAt(0).toUpperCase() + q.status.slice(1) : '—'}
+
+
+
+ {/* Total */}
+
+ {fmt(q.final_total)}
+
+
+ {/* Actions */}
+
e.stopPropagation()}>
+ {canEdit && (
+
handleSend(q)}
+ onEdit={() => handleEdit(q)}
+ onCopy={() => handleCopy(q)}
+ onDelete={() => setDeleteTarget(q)}
+ onStatusChange={s => handleStatusChange(q, s)}
+ />
+ )}
+
+
+ )
+ })}
+
+ )}
+
+ {/* Compose email modal */}
+
setComposeTarget(null)}
+ defaultTo={composeTarget?.defaultTo || ''}
+ defaultSubject={composeTarget ? `Quotation ${composeTarget.quotation.quotation_number}` : ''}
+ defaultAttachments={composeTarget?.attachments || []}
+ customerId={customer.id}
+ />
+
+ {/* PDF preview modal */}
+ setPdfPreview(null)}
+ />
+
+ {/* Delete confirm */}
+ setDeleteTarget(null)}
+ />
+
+ )
+}
diff --git a/frontend/src/pages/crm/customers/tabs/SupportTab.jsx b/frontend/src/pages/crm/customers/tabs/SupportTab.jsx
new file mode 100644
index 0000000..6c02fb1
--- /dev/null
+++ b/frontend/src/pages/crm/customers/tabs/SupportTab.jsx
@@ -0,0 +1,501 @@
+// frontend/src/pages/crm/customers/tabs/SupportTab.jsx
+// Issues and Notes linked to this customer — sourced from the Postgres /notes system.
+
+import { useState, useEffect, useCallback } from 'react'
+import api from '@/lib/api'
+import { useAuth } from '@/hooks/useAuth'
+import { useToast } from '@/components/ui/Toast'
+import Card from '@/components/ui/Card'
+import Button from '@/components/ui/Button'
+import StatusBadge from '@/components/ui/StatusBadge'
+import Spinner from '@/components/ui/Spinner'
+import ConfirmDialog from '@/components/ui/ConfirmDialog'
+import Icon from '@/components/ui/Icon'
+import { fmtDateMedium, fmtRelative } from '@/lib/formatters'
+import EntryFormModal from '@/modals/crm/helpdesk/EntryFormModal'
+
+// ─── Constants ────────────────────────────────────────────────────────────────
+
+const STATUS_META = {
+ open: { label: 'Open', color: 'var(--color-danger)', bg: 'var(--color-danger-bg)' },
+ researching: { label: 'Researching', color: 'var(--color-warning)', bg: 'var(--color-warning-bg)' },
+ resolved: { label: 'Resolved', color: 'var(--color-success)', bg: 'var(--color-success-bg)' },
+}
+
+const SEVERITY_META = {
+ low: { label: 'Low', color: 'var(--color-info)' },
+ medium: { label: 'Medium', color: 'var(--color-warning)' },
+ high: { label: 'High', color: 'var(--color-danger)' },
+ critical: { label: 'Critical', color: 'var(--color-danger)', breathe: true },
+}
+
+const CATEGORY_META = {
+ technical: { label: 'Technical', color: 'var(--color-info)', bg: 'var(--color-info-bg)' },
+ install_support: { label: 'Install Support', color: 'var(--color-warning)', bg: 'var(--color-warning-bg)' },
+ general: { label: 'General', color: 'var(--color-text-muted)', bg: 'var(--color-bg-elevated)' },
+}
+
+// ─── Sub-components ───────────────────────────────────────────────────────────
+
+function StatusPill({ status }) {
+ const m = STATUS_META[status] || { label: status, color: 'var(--color-text-muted)', bg: 'var(--color-bg-elevated)' }
+ return (
+
+
+ {m.label}
+
+ )
+}
+
+function SeverityBars({ severity }) {
+ if (!severity) return null
+ const m = SEVERITY_META[severity] || { label: severity, color: 'var(--color-text-muted)' }
+ const bars = { low: 1, medium: 2, high: 3, critical: 4 }[severity] || 1
+ return (
+
+
+ {[1, 2, 3, 4].map(b => (
+
+ ))}
+
+
+ {m.label}
+
+
+ )
+}
+
+function CategoryPill({ category }) {
+ if (!category) return null
+ const m = CATEGORY_META[category] || { label: category, color: 'var(--color-text-muted)', bg: 'var(--color-bg-elevated)' }
+ return (
+
+ {m.label}
+
+ )
+}
+
+// ─── IssuesCard ───────────────────────────────────────────────────────────────
+
+function IssuesCard({ customerId, customerName, canEdit }) {
+ const { toast } = useToast()
+ const [issues, setIssues] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [showResolved, setShowResolved] = useState(false)
+ const [modal, setModal] = useState({ open: false, entry: null })
+ const [deleteId, setDeleteId] = useState(null)
+ const [deleting, setDeleting] = useState(false)
+
+ const load = useCallback(async () => {
+ setLoading(true)
+ try {
+ const data = await api.get(`/notes/by-entity/customer/${customerId}`)
+ const all = Array.isArray(data) ? data : []
+ setIssues(all.filter(e => e.type === 'issue').reverse())
+ } catch { setIssues([]) }
+ finally { setLoading(false) }
+ }, [customerId])
+
+ useEffect(() => { load() }, [load])
+
+ const handleDelete = async () => {
+ setDeleting(true)
+ try {
+ await api.delete(`/notes/${deleteId}`)
+ toast.success('Deleted', 'Issue removed.')
+ setDeleteId(null)
+ load()
+ } catch (err) {
+ toast.danger('Error', err.message || 'Failed to delete.')
+ } finally { setDeleting(false) }
+ }
+
+ const openCreate = () => setModal({ open: true, entry: null })
+ const openEdit = (entry) => setModal({ open: true, entry })
+
+ const open = issues.filter(i => i.status !== 'resolved')
+ const resolved = issues.filter(i => i.status === 'resolved')
+ const displayed = showResolved ? issues : open
+
+ return (
+ <>
+
+
+
+ {open.length > 0 && {open.length} open }
+ {resolved.length > 0 && {resolved.length} resolved }
+ {resolved.length > 0 && (
+ setShowResolved(v => !v)}>
+ {showResolved ? 'Hide Resolved' : `Show Resolved (${resolved.length})`}
+
+ )}
+ {canEdit && (
+
+ + Report Issue
+
+ )}
+
+ }
+ >
+
+ {/* List */}
+ {loading ? (
+
+
+
+ ) : displayed.length === 0 ? (
+
+
+
+
+
+
+ {showResolved ? 'No issues.' : 'No open issues — all clear.'}
+
+
+ ) : (
+
+ {displayed.map((issue) => (
+
canEdit && openEdit(issue)}
+ >
+ {/* Status */}
+
+
+ {/* Content */}
+
+
+ {issue.title}
+
+ {issue.body && (
+
+ {issue.body.replace(/<[^>]*>/g, '').slice(0, 100)}
+
+ )}
+
+
+ {/* Category */}
+
+
+ {/* Severity */}
+
+
+ {/* Date */}
+
+ {fmtRelative(issue.created_at)}
+
+
+ {/* Delete */}
+ {canEdit && (
+
{ e.stopPropagation(); setDeleteId(issue.id) }}
+ title="Delete issue"
+ aria-label="Delete issue"
+ style={{
+ width: 26, height: 26,
+ borderRadius: 'var(--radius-sm)',
+ border: '1px solid transparent',
+ background: 'none',
+ color: 'var(--color-text-muted)',
+ cursor: 'pointer',
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
+ flexShrink: 0,
+ transition: 'color 0.12s, background 0.12s, border-color 0.12s',
+ }}
+ onMouseEnter={e => {
+ e.currentTarget.style.color = 'var(--color-danger)'
+ e.currentTarget.style.background = 'var(--color-danger-bg)'
+ e.currentTarget.style.borderColor = 'var(--color-danger)'
+ }}
+ onMouseLeave={e => {
+ e.currentTarget.style.color = 'var(--color-text-muted)'
+ e.currentTarget.style.background = 'none'
+ e.currentTarget.style.borderColor = 'transparent'
+ }}
+ >
+
+
+ )}
+
+ ))}
+
+ )}
+
+
+
setModal({ open: false, entry: null })}
+ onSaved={() => { setModal({ open: false, entry: null }); load() }}
+ onDelete={canEdit ? (id) => { setModal({ open: false, entry: null }); setDeleteId(id) } : null}
+ />
+
+ setDeleteId(null)}
+ loading={deleting}
+ />
+ >
+ )
+}
+
+// ─── NotesCard ────────────────────────────────────────────────────────────────
+
+function NotesCard({ customerId, customerName, canEdit }) {
+ const { toast } = useToast()
+ const [notes, setNotes] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [modal, setModal] = useState({ open: false, entry: null })
+ const [deleteId, setDeleteId] = useState(null)
+ const [deleting, setDeleting] = useState(false)
+
+ const load = useCallback(async () => {
+ setLoading(true)
+ try {
+ const data = await api.get(`/notes/by-entity/customer/${customerId}`)
+ const all = Array.isArray(data) ? data : []
+ setNotes(all.filter(e => e.type === 'note').reverse())
+ } catch { setNotes([]) }
+ finally { setLoading(false) }
+ }, [customerId])
+
+ useEffect(() => { load() }, [load])
+
+ const handleDelete = async () => {
+ setDeleting(true)
+ try {
+ await api.delete(`/notes/${deleteId}`)
+ toast.success('Deleted', 'Note removed.')
+ setDeleteId(null)
+ load()
+ } catch (err) {
+ toast.danger('Error', err.message || 'Failed to delete.')
+ } finally { setDeleting(false) }
+ }
+
+ const openCreate = () => setModal({ open: true, entry: null })
+ const openEdit = (entry) => setModal({ open: true, entry })
+
+ return (
+ <>
+
+ {notes.length > 0 && {notes.length} }
+ {canEdit && (
+
+ + Add Note
+
+ )}
+
+ }
+ >
+
+ {/* List */}
+ {loading ? (
+
+
+
+ ) : notes.length === 0 ? (
+
+
+
+
+
+
+
+
+
No notes yet.
+
+ ) : (
+
+ {notes.map((note) => (
+
canEdit && openEdit(note)}
+ style={{
+ display: 'flex',
+ alignItems: 'flex-start',
+ gap: 'var(--space-3)',
+ padding: 'var(--space-3) var(--space-4)',
+ borderRadius: 'var(--radius-lg)',
+ backgroundColor: 'var(--color-bg-abyss)',
+ border: '1px solid var(--color-border)',
+ cursor: canEdit ? 'pointer' : 'default',
+ transition: 'background-color 0.15s',
+ }}
+ onMouseEnter={e => { if (canEdit) e.currentTarget.style.backgroundColor = 'var(--color-bg-elevated)' }}
+ onMouseLeave={e => { e.currentTarget.style.backgroundColor = 'var(--color-bg-abyss)' }}
+ >
+
+
+ {note.title}
+
+ {note.body && (
+
+ {note.body.replace(/<[^>]*>/g, '')}
+
+ )}
+
+ {[note.author_name, note.created_at ? fmtDateMedium(note.created_at) : null].filter(Boolean).join(' · ')}
+
+
+
+ {canEdit && (
+
{ e.stopPropagation(); setDeleteId(note.id) }}
+ title="Delete note"
+ aria-label="Delete note"
+ style={{
+ background: 'none', border: 'none', cursor: 'pointer',
+ color: 'var(--color-text-muted)', padding: 'var(--space-1)',
+ display: 'flex', alignItems: 'center', flexShrink: 0,
+ transition: 'color 0.15s',
+ }}
+ onMouseEnter={e => { e.currentTarget.style.color = 'var(--color-danger)' }}
+ onMouseLeave={e => { e.currentTarget.style.color = 'var(--color-text-muted)' }}
+ >
+
+
+ )}
+
+ ))}
+
+ )}
+
+
+ setModal({ open: false, entry: null })}
+ onSaved={() => { setModal({ open: false, entry: null }); load() }}
+ onDelete={canEdit ? (id) => { setModal({ open: false, entry: null }); setDeleteId(id) } : null}
+ />
+
+ setDeleteId(null)}
+ loading={deleting}
+ />
+ >
+ )
+}
+
+// ─── SupportTab ───────────────────────────────────────────────────────────────
+
+export default function SupportTab({ customer, canEdit }) {
+ const customerName = [customer.surname, customer.name].filter(Boolean).join(' ') || customer.organization || ''
+ return (
+
+
+
+
+ )
+}
diff --git a/frontend/src/pages/crm/orders/OrderDetail.jsx b/frontend/src/pages/crm/orders/OrderDetail.jsx
new file mode 100644
index 0000000..ea3f973
--- /dev/null
+++ b/frontend/src/pages/crm/orders/OrderDetail.jsx
@@ -0,0 +1,729 @@
+// frontend/src/pages/crm/orders/OrderDetail.jsx
+
+import { useState, useEffect, useCallback } from 'react'
+import { useParams, useNavigate, Link } from 'react-router-dom'
+import api from '@/lib/api'
+import { useAuth } from '@/hooks/useAuth'
+import PageHeader from '@/components/ui/PageHeader'
+import Card from '@/components/ui/Card'
+import StatusBadge from '@/components/ui/StatusBadge'
+import Button from '@/components/ui/Button'
+import Spinner from '@/components/ui/Spinner'
+import Breadcrumbs from '@/components/ui/Breadcrumbs'
+import FormField from '@/components/ui/FormField'
+import Modal from '@/components/ui/Modal'
+import ConfirmDialog from '@/components/ui/ConfirmDialog'
+import { useToast } from '@/components/ui/Toast'
+import { fmtDateMedium, fmtDateTime as fmtDateTimeFmt, toDatetimeLocal, nowLocal } from '@/lib/formatters'
+
+// ---------------------------------------------------------------------------
+// Constants
+// ---------------------------------------------------------------------------
+
+const ORDER_STATUS_LABELS = {
+ negotiating: 'Negotiating',
+ awaiting_quotation: 'Awaiting Quotation',
+ awaiting_customer_confirmation: 'Awaiting Confirmation',
+ awaiting_fulfilment: 'Accepted - Waiting',
+ awaiting_payment: 'Awaiting Payment',
+ manufacturing: 'Manufacturing',
+ shipped: 'Shipped',
+ installed: 'Installed',
+ declined: 'Declined',
+ complete: 'Complete',
+}
+
+const ORDER_STATUS_VARIANT = {
+ negotiating: 'info',
+ awaiting_quotation: 'info',
+ awaiting_customer_confirmation: 'warning',
+ awaiting_fulfilment: 'warning',
+ awaiting_payment: 'warning',
+ manufacturing: 'info',
+ shipped: 'success',
+ installed: 'success',
+ declined: 'danger',
+ complete: 'success',
+}
+
+const TIMELINE_TYPE_LABELS = {
+ negotiations_started: 'Started Negotiations',
+ quote_request: 'Quote Requested',
+ quote_sent: 'Quote Sent',
+ quote_accepted: 'Quote Accepted',
+ quote_declined: 'Quote Declined',
+ mfg_started: 'Manufacturing Started',
+ mfg_complete: 'Manufacturing Complete',
+ order_shipped: 'Order Shipped',
+ installed: 'Installed',
+ payment_received: 'Payment Received',
+ invoice_sent: 'Invoice Sent',
+ note: 'Note',
+}
+
+const STATUS_TO_TIMELINE_TYPE = {
+ negotiating: 'negotiations_started',
+ awaiting_quotation: 'quote_request',
+ awaiting_customer_confirmation: 'quote_sent',
+ awaiting_fulfilment: 'quote_accepted',
+ awaiting_payment: 'invoice_sent',
+ manufacturing: 'mfg_started',
+ shipped: 'order_shipped',
+ installed: 'installed',
+ declined: 'note',
+ complete: 'payment_received',
+}
+
+const STATUS_DEFAULT_NOTES = {
+ negotiating: 'Just started Negotiating with the customer on a possible new order',
+ awaiting_quotation: 'We agreed on what the customer needs, and currently drafting a Quote for them',
+ awaiting_customer_confirmation: 'The Quotation has been sent to the Customer. Awaiting their Confirmation',
+ awaiting_fulfilment: 'Customer has accepted the Quotation, and no further action is needed from them',
+ awaiting_payment: 'Customer has accepted the Quotation, but a payment/advance is due before we proceed',
+ manufacturing: 'We have begun manufacturing the Customer\'s Device',
+ shipped: 'The order has been Shipped! Awaiting Customer Feedback',
+ installed: 'Customer has informed us that the device has been successfully Installed!',
+ declined: 'Customer sadly declined our offer',
+ complete: 'Customer has successfully installed, and operated their product. The order is complete!',
+}
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+function fmtDate(iso) { return fmtDateMedium(iso) }
+function fmtDateTime(iso) { return fmtDateTimeFmt(iso) }
+
+// ---------------------------------------------------------------------------
+// Timeline event row
+// ---------------------------------------------------------------------------
+function TimelineEvent({ event, canEdit, onEdit, onDelete }) {
+ const [hovered, setHovered] = useState(false)
+ const typeLabel = TIMELINE_TYPE_LABELS[event.type] || event.type
+
+ // Choose icon color based on type
+ const dotColor = {
+ negotiations_started: 'var(--color-info)',
+ quote_sent: 'var(--color-warning)',
+ quote_accepted: 'var(--color-success)',
+ quote_declined: 'var(--color-danger)',
+ payment_received: 'var(--color-success)',
+ order_shipped: 'var(--color-primary)',
+ installed: 'var(--color-success)',
+ mfg_started: 'var(--color-primary)',
+ mfg_complete: 'var(--color-success)',
+ }[event.type] || 'var(--color-text-muted)'
+
+ return (
+ setHovered(true)}
+ onMouseLeave={() => setHovered(false)}
+ >
+ {/* Timeline spine */}
+
+
+ {/* Content */}
+
+
+ {typeLabel}
+
+ {event.note && (
+
+ {event.note}
+
+ )}
+
+ {fmtDateTime(event.date)}
+ {event.updated_by && · {event.updated_by} }
+
+
+
+ {/* Hover actions */}
+ {canEdit && hovered && (
+
+ onEdit(event)}>Edit
+ onDelete(event)}>Delete
+
+ )}
+
+ )
+}
+
+// ---------------------------------------------------------------------------
+// Page
+// ---------------------------------------------------------------------------
+export default function OrderDetail() {
+ const { id } = useParams()
+ const navigate = useNavigate()
+ const { user, hasPermission } = useAuth()
+ const { toast } = useToast()
+
+ const [order, setOrder] = useState(null)
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState('')
+
+ // Modal state
+ const [showEditModal, setShowEditModal] = useState(false)
+ const [showStatusModal, setShowStatusModal] = useState(false)
+ const [showTimelineModal, setShowTimelineModal] = useState(false)
+ const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
+ const [editingEvent, setEditingEvent] = useState(null)
+ const [deletingEvent, setDeletingEvent] = useState(null)
+
+ // Edit form
+ const [editForm, setEditForm] = useState({ order_number: '', title: '', status: 'negotiating', notes: '' })
+ const [savingEdit, setSavingEdit] = useState(false)
+
+ // Status update form
+ const [statusForm, setStatusForm] = useState({ newStatus: 'negotiating', title: '', note: '', datetime: nowLocal() })
+ const [savingStatus, setSavingStatus] = useState(false)
+
+ // Timeline form
+ const [timelineForm, setTimelineForm] = useState({ type: 'note', note: '', date: nowLocal() })
+ const [savingTimeline, setSavingTimeline] = useState(false)
+
+ // Delete order
+ const [deletingOrder, setDeletingOrder] = useState(false)
+
+ // Delete timeline event
+ const [deletingTlEvent, setDeletingTlEvent] = useState(false)
+
+ const canEdit = hasPermission('crm_orders', 'edit')
+
+ const load = useCallback(async () => {
+ setLoading(true)
+ setError('')
+ try {
+ const data = await api.get(`/crm/orders/${id}`)
+ setOrder(data)
+ } catch (err) {
+ setError(err.message)
+ } finally {
+ setLoading(false)
+ }
+ }, [id])
+
+ useEffect(() => { load() }, [load])
+
+ // Sync edit form when order loads
+ useEffect(() => {
+ if (order) {
+ setEditForm({
+ order_number: order.order_number || '',
+ title: order.title || '',
+ status: order.status || 'negotiating',
+ notes: order.notes || '',
+ })
+ setStatusForm(f => ({ ...f, newStatus: order.status || 'negotiating', title: order.title || '' }))
+ }
+ }, [order])
+
+ // ---------------------------------------------------------------------------
+ // Handlers
+ // ---------------------------------------------------------------------------
+ const handleSaveEdit = async () => {
+ setSavingEdit(true)
+ try {
+ await api.patch(`/crm/customers/${order.customer_id}/orders/${id}`, {
+ order_number: editForm.order_number || null,
+ title: editForm.title || null,
+ status: editForm.status,
+ notes: editForm.notes || null,
+ })
+ toast.success('Saved', 'Order updated.')
+ setShowEditModal(false)
+ load()
+ } catch (err) {
+ toast.danger('Error', err.message)
+ } finally {
+ setSavingEdit(false)
+ }
+ }
+
+ const handleUpdateStatus = async () => {
+ setSavingStatus(true)
+ try {
+ // Archive current status as timeline event if not already there
+ const existingMatch = (order.timeline || []).some(
+ e => e.archived_status === order.status && e.date === (order.status_updated_date || order.created_at)
+ )
+ if (!existingMatch) {
+ const tlType = STATUS_TO_TIMELINE_TYPE[order.status] || 'note'
+ await api.post(`/crm/customers/${order.customer_id}/orders/${id}/timeline`, {
+ type: tlType,
+ note: order.notes || '',
+ date: order.status_updated_date || order.created_at || new Date().toISOString(),
+ updated_by: order.status_updated_by || order.created_by || 'System',
+ archived_status: order.status,
+ })
+ }
+ await api.patch(`/crm/customers/${order.customer_id}/orders/${id}`, {
+ status: statusForm.newStatus,
+ title: statusForm.title || order.title || null,
+ status_updated_date: new Date(statusForm.datetime).toISOString(),
+ status_updated_by: user?.name || 'Staff',
+ notes: statusForm.note,
+ })
+ toast.success('Status updated', `Order moved to ${ORDER_STATUS_LABELS[statusForm.newStatus]}.`)
+ setShowStatusModal(false)
+ load()
+ } catch (err) {
+ toast.danger('Error', err.message)
+ } finally {
+ setSavingStatus(false)
+ }
+ }
+
+ const handleAddTimeline = async () => {
+ if (!timelineForm.type) return
+ setSavingTimeline(true)
+ try {
+ const payload = {
+ ...timelineForm,
+ date: timelineForm.date ? new Date(timelineForm.date).toISOString() : new Date().toISOString(),
+ updated_by: user?.name || 'Staff',
+ }
+ if (editingEvent) {
+ // Find index in original timeline
+ const origIdx = (order.timeline || []).findIndex(
+ e => e.date === editingEvent.date && e.note === editingEvent.note && e.type === editingEvent.type
+ )
+ await api.patch(`/crm/customers/${order.customer_id}/orders/${id}/timeline/${origIdx}`, {
+ type: timelineForm.type,
+ note: timelineForm.note,
+ date: new Date(timelineForm.date).toISOString(),
+ })
+ toast.success('Updated', 'Timeline event updated.')
+ } else {
+ await api.post(`/crm/customers/${order.customer_id}/orders/${id}/timeline`, payload)
+ toast.success('Added', 'Timeline event added.')
+ }
+ setTimelineForm({ type: 'note', note: '', date: nowLocal() })
+ setEditingEvent(null)
+ setShowTimelineModal(false)
+ load()
+ } catch (err) {
+ toast.danger('Error', err.message)
+ } finally {
+ setSavingTimeline(false)
+ }
+ }
+
+ const handleDeleteTimelineEvent = async () => {
+ if (!deletingEvent) return
+ setDeletingTlEvent(true)
+ try {
+ const origIdx = (order.timeline || []).findIndex(
+ e => e.date === deletingEvent.date && e.note === deletingEvent.note && e.type === deletingEvent.type
+ )
+ await api.delete(`/crm/customers/${order.customer_id}/orders/${id}/timeline/${origIdx}`)
+ toast.success('Deleted', 'Timeline event removed.')
+ setDeletingEvent(null)
+ load()
+ } catch (err) {
+ toast.danger('Error', err.message)
+ } finally {
+ setDeletingTlEvent(false)
+ }
+ }
+
+ const handleDeleteOrder = async () => {
+ setDeletingOrder(true)
+ try {
+ await api.delete(`/crm/customers/${order.customer_id}/orders/${id}`)
+ toast.success('Deleted', 'Order deleted.')
+ navigate('/crm/orders')
+ } catch (err) {
+ toast.danger('Error', err.message)
+ setDeletingOrder(false)
+ }
+ }
+
+ const openEditTimeline = (event) => {
+ setEditingEvent(event)
+ setTimelineForm({
+ type: event.type || 'note',
+ note: event.note || '',
+ date: event.date ? toDatetimeLocal(event.date) : nowLocal(),
+ })
+ setShowTimelineModal(true)
+ }
+
+ const openDeleteTimeline = (event) => {
+ setDeletingEvent(event)
+ }
+
+ // ---------------------------------------------------------------------------
+ // Render states
+ // ---------------------------------------------------------------------------
+ if (loading) {
+ return (
+
+ )
+ }
+
+ if (error) {
+ return (
+
+ )
+ }
+
+ if (!order) return null
+
+ const timeline = [...(order.timeline || [])].sort((a, b) => (b.date || '').localeCompare(a.date || ''))
+
+ const statusVariant = ORDER_STATUS_VARIANT[order.status] || 'neutral'
+ const statusLabel = ORDER_STATUS_LABELS[order.status] || order.status || '—'
+
+ return (
+ <>
+
+
+
+
+ {canEdit && (
+
+ setShowStatusModal(true)}>Update Status
+ setShowEditModal(true)}>Edit
+ setShowDeleteConfirm(true)}>Delete
+
+ )}
+
+
+ {/* Main two-column layout */}
+
+
+ {/* Left: timeline */}
+
+
+ {/* Add event button */}
+ {canEdit && (
+
+ {
+ setEditingEvent(null)
+ setTimelineForm({ type: 'note', note: '', date: nowLocal() })
+ setShowTimelineModal(true)
+ }}
+ >
+ + Add Event
+
+
+ )}
+
+ {timeline.length === 0 ? (
+
+ No timeline events yet.
+
+ ) : (
+
+ {timeline.map((ev, i) => (
+
+ ))}
+
+ )}
+
+
+
+ {/* Right: details */}
+
+ {/* Status card */}
+
+
+
+
Status
+
{statusLabel}
+
+ {order.status_updated_date && (
+
+
Updated
+
+ {fmtDate(order.status_updated_date)}
+ {order.status_updated_by && by {order.status_updated_by} }
+
+
+ )}
+
+
+
+ {/* Info card */}
+
+
+ {[
+ { label: 'Order Number', value: order.order_number, mono: true },
+ { label: 'Title', value: order.title },
+ { label: 'Customer', value: order.customer_name ? (
+
+ {order.customer_name}
+
+ ) : null },
+ { label: 'Created', value: fmtDate(order.created_at) },
+ { label: 'Created by', value: order.created_by },
+ ].map(row => (
+
+
+ {row.label}
+
+
+ {row.value || — }
+
+
+ ))}
+
+ {order.notes && (
+
+
Notes
+
+ {order.notes}
+
+
+ )}
+
+
+
+ {/* Timeline count */}
+
+ Timeline events
+
+ {timeline.length}
+
+
+
+
+
+
+ {/* Edit Order Modal */}
+ setShowEditModal(false)}
+ title="Edit Order"
+ size="md"
+ footer={
+
+ setShowEditModal(false)}>Cancel
+ Save Changes
+
+ }
+ >
+
+ setEditForm(f => ({ ...f, order_number: e.target.value }))}
+ placeholder="e.g. ORD-2025-001"
+ />
+ setEditForm(f => ({ ...f, title: e.target.value }))}
+ placeholder="Short description of the order"
+ />
+ setEditForm(f => ({ ...f, status: e.target.value }))}
+ >
+ {Object.entries(ORDER_STATUS_LABELS).map(([v, l]) => (
+ {l}
+ ))}
+
+ setEditForm(f => ({ ...f, notes: e.target.value }))}
+ placeholder="Internal notes about this order…"
+ rows={3}
+ />
+
+
+
+ {/* Update Status Modal */}
+ setShowStatusModal(false)}
+ title="Update Order Status"
+ size="md"
+ footer={
+
+ setShowStatusModal(false)}>Cancel
+ Update Status
+
+ }
+ >
+
+ {/* Info banner */}
+
+ Current status {ORDER_STATUS_LABELS[order?.status] || order?.status} will be archived as a timeline event.
+
+
setStatusForm(f => ({ ...f, newStatus: e.target.value, note: STATUS_DEFAULT_NOTES[e.target.value] || '' }))}
+ >
+ {Object.entries(ORDER_STATUS_LABELS).map(([v, l]) => (
+ {l}
+ ))}
+
+
setStatusForm(f => ({ ...f, title: e.target.value }))}
+ />
+ setStatusForm(f => ({ ...f, datetime: e.target.value }))}
+ hint="Format: YYYY-MM-DDTHH:MM"
+ />
+ setStatusForm(f => ({ ...f, note: e.target.value }))}
+ placeholder="Optional note about this status change…"
+ rows={3}
+ />
+
+
+
+ {/* Timeline Event Modal */}
+ { setShowTimelineModal(false); setEditingEvent(null) }}
+ title={editingEvent ? 'Edit Timeline Event' : 'Add Timeline Event'}
+ size="md"
+ footer={
+
+ { setShowTimelineModal(false); setEditingEvent(null) }}>Cancel
+
+ {editingEvent ? 'Save Changes' : 'Add Event'}
+
+
+ }
+ >
+
+ setTimelineForm(f => ({ ...f, type: e.target.value }))}
+ >
+ {Object.entries(TIMELINE_TYPE_LABELS).map(([v, l]) => (
+ {l}
+ ))}
+
+ setTimelineForm(f => ({ ...f, date: e.target.value }))}
+ hint="Format: YYYY-MM-DDTHH:MM"
+ />
+ setTimelineForm(f => ({ ...f, note: e.target.value }))}
+ placeholder="Description or notes for this event…"
+ rows={3}
+ />
+
+
+
+ {/* Confirm delete timeline event */}
+ {deletingEvent && (
+ setDeletingEvent(null)}
+ onConfirm={handleDeleteTimelineEvent}
+ loading={deletingTlEvent}
+ variant="danger"
+ title="Delete Timeline Event"
+ message={`Delete the "${TIMELINE_TYPE_LABELS[deletingEvent.type] || deletingEvent.type}" event? This cannot be undone.`}
+ />
+ )}
+
+ {/* Confirm delete order */}
+ setShowDeleteConfirm(false)}
+ onConfirm={handleDeleteOrder}
+ loading={deletingOrder}
+ variant="danger"
+ title="Delete Order"
+ message="Are you sure you want to delete this order? All timeline events will be lost. This cannot be undone."
+ />
+
+
+ >
+ )
+}
diff --git a/frontend/src/pages/crm/orders/OrderList.jsx b/frontend/src/pages/crm/orders/OrderList.jsx
new file mode 100644
index 0000000..eb3edc8
--- /dev/null
+++ b/frontend/src/pages/crm/orders/OrderList.jsx
@@ -0,0 +1,277 @@
+// frontend/src/pages/crm/orders/OrderList.jsx
+
+import { useState, useEffect, useMemo } from 'react'
+import { useNavigate } from 'react-router-dom'
+import api from '@/lib/api'
+import PageHeader from '@/components/ui/PageHeader'
+import SearchBar from '@/components/ui/SearchBar'
+import DataTable from '@/components/ui/DataTable'
+import StatusBadge from '@/components/ui/StatusBadge'
+import Spinner from '@/components/ui/Spinner'
+import Select from '@/components/ui/Select'
+import { fmtDateMedium } from '@/lib/formatters'
+
+// ---------------------------------------------------------------------------
+// Constants
+// ---------------------------------------------------------------------------
+
+const ORDER_STATUS_LABELS = {
+ negotiating: 'Negotiating',
+ awaiting_quotation: 'Awaiting Quotation',
+ awaiting_customer_confirmation: 'Awaiting Confirmation',
+ awaiting_fulfilment: 'Accepted - Waiting',
+ awaiting_payment: 'Awaiting Payment',
+ manufacturing: 'Manufacturing',
+ shipped: 'Shipped',
+ installed: 'Installed',
+ declined: 'Declined',
+ complete: 'Complete',
+}
+
+// Map order status → StatusBadge variant
+const ORDER_STATUS_VARIANT = {
+ negotiating: 'info',
+ awaiting_quotation: 'info',
+ awaiting_customer_confirmation: 'warning',
+ awaiting_fulfilment: 'warning',
+ awaiting_payment: 'warning',
+ manufacturing: 'info',
+ shipped: 'success',
+ installed: 'success',
+ declined: 'danger',
+ complete: 'success',
+}
+
+function fmtDate(iso) { return fmtDateMedium(iso) }
+
+// ---------------------------------------------------------------------------
+// Columns
+// ---------------------------------------------------------------------------
+const COLUMNS = [
+ {
+ key: 'order_number',
+ label: 'Order #',
+ sortable: true,
+ render: (row) => row.order_number
+ ? {row.order_number}
+ : — ,
+ },
+ {
+ key: 'customer_name',
+ label: 'Customer',
+ sortable: true,
+ render: (row) => row.customer_name || — ,
+ },
+ {
+ key: 'title',
+ label: 'Title',
+ sortable: true,
+ render: (row) => row.title
+ ? {row.title}
+ : Untitled ,
+ },
+ {
+ key: 'status',
+ label: 'Status',
+ sortable: true,
+ render: (row) => (
+
+ {ORDER_STATUS_LABELS[row.status] || row.status || '—'}
+
+ ),
+ },
+ {
+ key: 'created_at',
+ label: 'Created',
+ sortable: true,
+ render: (row) => {fmtDate(row.created_at)} ,
+ },
+]
+
+// ---------------------------------------------------------------------------
+// Page
+// ---------------------------------------------------------------------------
+export default function OrderList() {
+ const [orders, setOrders] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState('')
+ const [statusFilter, setStatusFilter] = useState('')
+ const [searchQuery, setSearchQuery] = useState('')
+ const [sortKey, setSortKey] = useState('created_at')
+ const [sortDir, setSortDir] = useState('desc')
+ const navigate = useNavigate()
+
+ useEffect(() => {
+ const load = async () => {
+ setLoading(true)
+ setError('')
+ try {
+ const params = new URLSearchParams()
+ if (statusFilter) params.set('status', statusFilter)
+ const qs = params.toString()
+ const data = await api.get(`/crm/orders${qs ? `?${qs}` : ''}`)
+ setOrders(data.orders || [])
+ } catch (err) {
+ setError(err.message)
+ } finally {
+ setLoading(false)
+ }
+ }
+ load()
+ }, [statusFilter])
+
+ const handleSort = (key) => {
+ if (sortKey === key) setSortDir(d => d === 'asc' ? 'desc' : 'asc')
+ else { setSortKey(key); setSortDir('asc') }
+ }
+
+ const filteredOrders = useMemo(() => {
+ let rows = orders
+ if (searchQuery) {
+ const q = searchQuery.toLowerCase()
+ rows = rows.filter(o =>
+ (o.order_number || '').toLowerCase().includes(q) ||
+ (o.customer_name || '').toLowerCase().includes(q) ||
+ (o.title || '').toLowerCase().includes(q)
+ )
+ }
+ return [...rows].sort((a, b) => {
+ const va = a[sortKey] ?? ''
+ const vb = b[sortKey] ?? ''
+ const cmp = va < vb ? -1 : va > vb ? 1 : 0
+ return sortDir === 'asc' ? cmp : -cmp
+ })
+ }, [orders, searchQuery, sortKey, sortDir])
+
+ const statusOptions = [
+ { value: '', label: 'All Statuses' },
+ ...Object.entries(ORDER_STATUS_LABELS).map(([v, l]) => ({ value: v, label: l })),
+ ]
+
+ // Status counts for the summary strip
+ const statusCounts = useMemo(() => {
+ const counts = {}
+ orders.forEach(o => { counts[o.status] = (counts[o.status] || 0) + 1 })
+ return counts
+ }, [orders])
+
+ const activeStatuses = Object.entries(statusCounts)
+ .filter(([, c]) => c > 0)
+ .sort((a, b) => b[1] - a[1])
+ .slice(0, 5)
+
+ return (
+
+
+
+ {/* Summary chips */}
+ {!loading && !error && activeStatuses.length > 0 && (
+
+ setStatusFilter('')}
+ style={{
+ display: 'inline-flex', alignItems: 'center', gap: 'var(--space-2)',
+ padding: 'var(--space-1) var(--space-3)',
+ borderRadius: 'var(--radius-full)',
+ fontSize: 'var(--font-size-sm)',
+ cursor: 'pointer',
+ border: '1px solid',
+ borderColor: !statusFilter ? 'var(--color-primary)' : 'var(--color-border-strong)',
+ backgroundColor: !statusFilter ? 'var(--color-primary-subtle)' : 'transparent',
+ color: !statusFilter ? 'var(--color-primary)' : 'var(--color-text-muted)',
+ transition: 'all 0.15s ease',
+ fontFamily: 'var(--font-family-base)',
+ }}
+ >
+ All
+
+ {orders.length}
+
+
+ {activeStatuses.map(([status, count]) => (
+ setStatusFilter(s => s === status ? '' : status)}
+ style={{
+ display: 'inline-flex', alignItems: 'center', gap: 'var(--space-2)',
+ padding: 'var(--space-1) var(--space-3)',
+ borderRadius: 'var(--radius-full)',
+ fontSize: 'var(--font-size-sm)',
+ cursor: 'pointer',
+ border: '1px solid',
+ borderColor: statusFilter === status ? 'var(--color-primary)' : 'var(--color-border-strong)',
+ backgroundColor: statusFilter === status ? 'var(--color-primary-subtle)' : 'transparent',
+ color: statusFilter === status ? 'var(--color-primary)' : 'var(--color-text-muted)',
+ transition: 'all 0.15s ease',
+ fontFamily: 'var(--font-family-base)',
+ }}
+ >
+
+ {ORDER_STATUS_LABELS[status] || status}
+
+
+ {count}
+
+
+ ))}
+
+ )}
+
+ {/* Filters row */}
+
+
+ {/* Error */}
+ {error && (
+
+ {error}
+
+ )}
+
+ {/* Loading */}
+ {loading ? (
+
+
+
+ ) : (
+
navigate(`/crm/orders/${row.id}`)}
+ emptyMessage={searchQuery || statusFilter ? 'No orders match your filters.' : 'No orders yet.'}
+ keyField="id"
+ />
+ )}
+
+ )
+}
diff --git a/frontend/src/pages/crm/products/ProductForm.jsx b/frontend/src/pages/crm/products/ProductForm.jsx
new file mode 100644
index 0000000..3883ae6
--- /dev/null
+++ b/frontend/src/pages/crm/products/ProductForm.jsx
@@ -0,0 +1,803 @@
+// frontend/src/pages/crm/products/ProductForm.jsx
+
+import { useState, useEffect, useRef } from 'react'
+import { useNavigate, useParams } from 'react-router-dom'
+import api from '@/lib/api'
+import { useAuth } from '@/hooks/useAuth'
+import PageHeader from '@/components/ui/PageHeader'
+import Button from '@/components/ui/Button'
+import Card from '@/components/ui/Card'
+import FormField from '@/components/ui/FormField'
+import Icon from '@/components/ui/Icon'
+import Spinner from '@/components/ui/Spinner'
+import ConfirmDialog from '@/components/ui/ConfirmDialog'
+
+// ─── Constants ────────────────────────────────────────────────────────────────
+
+const CATEGORY_LABELS = {
+ controller: 'Controller',
+ striker: 'Striker',
+ clock: 'Clock',
+ part: 'Part',
+ repair_service: 'Repair / Service',
+}
+
+const DEFAULT_FORM = {
+ name_en: '',
+ name_gr: '',
+ sku: '',
+ category: 'controller',
+ description_en: '',
+ description_gr: '',
+ price: '',
+ currency: 'EUR',
+ status: 'active',
+ costs: {
+ labor_hours: '',
+ labor_rate: '',
+ items: [],
+ },
+ stock: {
+ on_hand: '',
+ reserved: '',
+ },
+}
+
+// ─── Helpers ──────────────────────────────────────────────────────────────────
+
+function numOr(v, fallback = 0) {
+ const n = parseFloat(v)
+ return isNaN(n) ? fallback : n
+}
+
+function computeTotal(costs, priceField = 'price_last') {
+ const labor = numOr(costs.labor_hours) * numOr(costs.labor_rate)
+ const items = (costs.items || []).reduce(
+ (sum, it) => sum + numOr(it.quantity, 1) * numOr(it[priceField] || it.price_last),
+ 0,
+ )
+ return labor + items
+}
+
+// ─── Sub-components ───────────────────────────────────────────────────────────
+
+function SectionDivider({ label }) {
+ return (
+
+ )
+}
+
+function StatusToggle({ value, onChange, disabled }) {
+ const options = [
+ { value: 'active', label: 'Active', variant: 'success' },
+ { value: 'planned', label: 'Planned', variant: 'warning' },
+ { value: 'discontinued', label: 'Discontinued', variant: 'danger' },
+ ]
+
+ const colors = {
+ success: { active: 'var(--color-success)', activeBg: 'var(--color-success-bg)' },
+ warning: { active: 'var(--color-warning)', activeBg: 'var(--color-warning-bg)' },
+ danger: { active: 'var(--color-danger)', activeBg: 'var(--color-danger-bg)' },
+ }
+
+ return (
+
+
+ Status
+
+
+ {options.map((opt, idx) => {
+ const isActive = value === opt.value
+ const c = colors[opt.variant]
+ return (
+ !disabled && onChange(opt.value)}
+ style={{
+ flex: 1,
+ padding: 'var(--space-2) 0',
+ fontSize: 'var(--font-size-sm)',
+ fontWeight: 'var(--font-weight-semibold)',
+ fontFamily: 'var(--font-family-base)',
+ border: 'none',
+ borderRight: idx < options.length - 1 ? '1px solid var(--color-border-strong)' : 'none',
+ cursor: disabled ? 'default' : 'pointer',
+ backgroundColor: isActive ? c.activeBg : 'var(--color-bg-abyss)',
+ color: isActive ? c.active : 'var(--color-text-muted)',
+ transition: 'background-color 0.15s, color 0.15s',
+ }}
+ >
+ {opt.label}
+
+ )
+ })}
+
+
+ )
+}
+
+function SummaryRow({ label, children }) {
+ return (
+
+ {label}
+ {children}
+
+ )
+}
+
+// ─── ProductForm ──────────────────────────────────────────────────────────────
+
+export default function ProductForm() {
+ const { id } = useParams()
+ const isEdit = Boolean(id)
+ const navigate = useNavigate()
+ const { hasPermission } = useAuth()
+ const canEdit = hasPermission('crm_products', 'edit')
+ const fileRef = useRef(null)
+
+ const [form, setForm] = useState(DEFAULT_FORM)
+ const [loading, setLoading] = useState(isEdit)
+ const [saving, setSaving] = useState(false)
+ const [error, setError] = useState('')
+ const [photoPreview, setPhotoPreview] = useState(null)
+ const [photoFile, setPhotoFile] = useState(null)
+ const [existingPhoto, setExistingPhoto] = useState(null)
+ const [showDelete, setShowDelete] = useState(false)
+ const [deleting, setDeleting] = useState(false)
+
+ // ── Load existing product ──────────────────────────────────────────────────
+
+ useEffect(() => {
+ if (!isEdit) return
+ api.get(`/crm/products/${id}`)
+ .then((data) => {
+ setForm({
+ name_en: data.name_en || data.name || '',
+ name_gr: data.name_gr || data.name || '',
+ sku: data.sku || '',
+ category: data.category || 'controller',
+ description_en: data.description_en || data.description || '',
+ description_gr: data.description_gr || '',
+ 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) setExistingPhoto(`/api${data.photo_url}`)
+ })
+ .catch((err) => setError(err.message || 'Failed to load product.'))
+ .finally(() => setLoading(false))
+ }, [id, isEdit])
+
+ // ── Setters ───────────────────────────────────────────────────────────────
+
+ 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),
+ },
+ }))
+
+ // ── Photo ──────────────────────────────────────────────────────────────────
+
+ function handlePhotoChange(e) {
+ const file = e.target.files?.[0]
+ if (!file) return
+ setPhotoFile(file)
+ setPhotoPreview(URL.createObjectURL(file))
+ }
+
+ // ── Build payload ──────────────────────────────────────────────────────────
+
+ const buildPayload = () => ({
+ name: form.name_en.trim() || form.name_gr.trim(),
+ sku: form.sku.trim() || null,
+ category: form.category,
+ description: form.description_en.trim() || null,
+ name_en: form.name_en.trim() || null,
+ name_gr: form.name_gr.trim() || null,
+ description_en: form.description_en.trim() || null,
+ description_gr: form.description_gr.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),
+ price_last: numOr(it.price_last),
+ 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,
+ },
+ })
+
+ // ── Save ───────────────────────────────────────────────────────────────────
+
+ const handleSave = async () => {
+ if (!form.name_en.trim() && !form.name_gr.trim()) {
+ setError('Product name (English) is required.')
+ return
+ }
+ setSaving(true)
+ setError('')
+ try {
+ let saved
+ if (isEdit) {
+ saved = await api.put(`/crm/products/${id}`, buildPayload())
+ } else {
+ saved = await api.post('/crm/products', buildPayload())
+ }
+ if (photoFile && saved?.id) {
+ const fd = new FormData()
+ fd.append('file', photoFile)
+ await fetch(`/api/crm/products/${saved.id}/photo`, {
+ method: 'POST',
+ headers: { Authorization: `Bearer ${localStorage.getItem('access_token')}` },
+ body: fd,
+ })
+ }
+ navigate('/crm/products')
+ } catch (err) {
+ setError(err.message || 'Failed to save product.')
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ // ── Delete ─────────────────────────────────────────────────────────────────
+
+ const handleDelete = async () => {
+ setDeleting(true)
+ try {
+ await api.delete(`/crm/products/${id}`)
+ navigate('/crm/products')
+ } catch (err) {
+ setError(err.message || 'Failed to delete product.')
+ setDeleting(false)
+ setShowDelete(false)
+ }
+ }
+
+ // ── Computed values ────────────────────────────────────────────────────────
+
+ const totalEst = computeTotal(form.costs, 'price_last')
+ const totalMin = computeTotal(form.costs, 'price_min')
+ const totalMax = computeTotal(form.costs, 'price_max')
+ const price = parseFloat(form.price) || 0
+ const marginEst = price - totalEst
+ const marginMin = price - totalMax
+ const marginMax = price - totalMin
+ const stockAvail = (parseInt(form.stock.on_hand, 10) || 0) - (parseInt(form.stock.reserved, 10) || 0)
+ const currentPhoto = photoPreview || existingPhoto
+
+ // ── Loading state ──────────────────────────────────────────────────────────
+
+ if (loading) {
+ return (
+
+ )
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+
+ return (
+
+
+
+
+ {isEdit && canEdit && (
+ setShowDelete(true)}>
+ Delete
+
+ )}
+ navigate('/crm/products')}>
+ Cancel
+
+ {canEdit && (
+
+ {isEdit ? 'Save Changes' : 'Create Product'}
+
+ )}
+
+
+
+ {/* Error banner */}
+ {error && (
+
+ {error}
+
+ )}
+
+ {/* 2-column layout */}
+
+
+ {/* ── LEFT COLUMN ─────────────────────────────────────────────── */}
+
+
+ {/* Product Details card */}
+
+
+ {/* Photo upload */}
+
+
canEdit && fileRef.current?.click()}
+ style={{
+ width: 100,
+ height: 100,
+ borderRadius: 'var(--radius-lg)',
+ border: `2px dashed var(--color-border-strong)`,
+ backgroundColor: 'var(--color-bg-abyss)',
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ overflow: 'hidden',
+ flexShrink: 0,
+ cursor: canEdit ? 'pointer' : 'default',
+ transition: 'border-color 0.15s',
+ }}
+ >
+ {currentPhoto ? (
+
+ ) : (
+
+ )}
+
+
+ {canEdit && (
+ fileRef.current?.click()}>
+ {currentPhoto ? 'Change Photo' : 'Upload Photo'}
+
+ )}
+
+ JPG, PNG or WebP
+
+ {photoFile && (
+
+ {photoFile.name}
+
+ )}
+
+
+
+
+ {/* Names */}
+
+
+ set('name_en', e.target.value)}
+ placeholder="e.g. Vesper Plus"
+ required
+ disabled={!canEdit}
+ />
+ set('name_gr', e.target.value)}
+ placeholder="π.χ. Vesper Plus"
+ disabled={!canEdit}
+ />
+
+
+ {/* SKU + Category */}
+
+ set('sku', e.target.value)}
+ placeholder="e.g. VSP-001"
+ disabled={!canEdit}
+ />
+ set('category', e.target.value)}
+ disabled={!canEdit}
+ >
+ {Object.entries(CATEGORY_LABELS).map(([val, label]) => (
+ {label}
+ ))}
+
+
+
+ {/* Descriptions */}
+
+
+ set('description_en', e.target.value)}
+ placeholder="Optional product description in English…"
+ disabled={!canEdit}
+ />
+ set('description_gr', e.target.value)}
+ placeholder="Προαιρετική περιγραφή…"
+ disabled={!canEdit}
+ />
+
+
+ {/* Status */}
+ set('status', v)}
+ disabled={!canEdit}
+ />
+
+
+ {/* Stock card */}
+
+
+
setStock('on_hand', e.target.value)}
+ placeholder="0"
+ inputProps={{ min: 0, step: 1 }}
+ disabled={!canEdit}
+ />
+ setStock('reserved', e.target.value)}
+ placeholder="0"
+ inputProps={{ min: 0, step: 1 }}
+ disabled={!canEdit}
+ />
+ {/* Available — read-only derived */}
+
+
+ Available
+
+
0 ? 'var(--color-text-primary)' : 'var(--color-warning)',
+ fontWeight: 'var(--font-weight-semibold)',
+ cursor: 'default',
+ userSelect: 'none',
+ }}
+ >
+ {stockAvail}
+
+
+
+
+
+
+
+ {/* ── RIGHT COLUMN ────────────────────────────────────────────── */}
+
+
+ {/* Pricing & Mfg. Costs card */}
+
+
+ {/* Price */}
+
+
+
set('price', e.target.value)}
+ onBlur={(e) => { const v = parseFloat(e.target.value); if (!isNaN(v)) set('price', v.toFixed(2)) }}
+ placeholder="0.00"
+ inputProps={{ min: 0, step: 0.01 }}
+ disabled={!canEdit}
+ />
+
+
+
+ {/* Labor */}
+
+
+ setCost('labor_hours', e.target.value)}
+ placeholder="0"
+ inputProps={{ min: 0, step: 0.5 }}
+ disabled={!canEdit}
+ />
+ setCost('labor_rate', e.target.value)}
+ placeholder="0.00"
+ inputProps={{ min: 0, step: 0.01 }}
+ disabled={!canEdit}
+ />
+
+
+ {/* Cost line items */}
+
+
+ {form.costs.items.length > 0 && (
+
+ {/* Header row */}
+
+ {['Item Name', 'Qty', 'Min (€)', 'Max (€)', 'Est. (€)', ''].map((h) => (
+
+ {h}
+
+ ))}
+
+
+ {/* Item rows */}
+ {form.costs.items.map((it, i) => (
+
+ setCostItem(i, 'name', e.target.value)}
+ placeholder="e.g. PCB"
+ disabled={!canEdit}
+ />
+ setCostItem(i, 'quantity', e.target.value)}
+ onBlur={(e) => { const v = parseFloat(e.target.value); if (!isNaN(v)) setCostItem(i, 'quantity', v.toFixed(2)) }}
+ style={{ textAlign: 'center' }}
+ disabled={!canEdit}
+ />
+ 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)) }}
+ style={{ textAlign: 'center' }}
+ placeholder="0.00"
+ disabled={!canEdit}
+ />
+ 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)) }}
+ style={{ textAlign: 'center' }}
+ placeholder="0.00"
+ disabled={!canEdit}
+ />
+ 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)) }}
+ style={{ textAlign: 'center' }}
+ placeholder="0.00"
+ disabled={!canEdit}
+ />
+ {canEdit && (
+ removeCostItem(i)}
+ style={{
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ background: 'none',
+ border: 'none',
+ cursor: 'pointer',
+ color: 'var(--color-danger)',
+ padding: 0,
+ opacity: 0.7,
+ }}
+ aria-label="Remove item"
+ >
+
+
+ )}
+
+ ))}
+
+ )}
+
+ {canEdit && (
+
+
+
+ }
+ >
+ Add Cost Item
+
+ )}
+
+ {/* Summary */}
+
+
+
+
+ €{totalMin.toFixed(2)} – €{totalMax.toFixed(2)}
+
+ |
+
+ est. €{totalEst.toFixed(2)}
+
+
+
+
+
+ {form.price ? (
+
+
+ €{marginMax.toFixed(2)} – €{marginMin.toFixed(2)}
+
+ |
+ = 0 ? 'var(--color-success)' : 'var(--color-danger)',
+ }}>
+ est. €{marginEst.toFixed(2)}
+
+
+ ) : (
+ —
+ )}
+
+
+
+
+
+
+
+ {/* Delete confirm */}
+
setShowDelete(false)}
+ onConfirm={handleDelete}
+ title="Delete Product"
+ message={`Permanently delete "${form.name_en || form.name_gr}"? This cannot be undone.`}
+ confirmLabel="Delete Product"
+ variant="danger"
+ loading={deleting}
+ persistent={deleting}
+ />
+
+
+ )
+}
diff --git a/frontend/src/pages/crm/products/ProductList.jsx b/frontend/src/pages/crm/products/ProductList.jsx
new file mode 100644
index 0000000..3c93d52
--- /dev/null
+++ b/frontend/src/pages/crm/products/ProductList.jsx
@@ -0,0 +1,468 @@
+// frontend/src/pages/crm/products/ProductList.jsx
+
+import { useState, useEffect, useCallback } from 'react'
+import { useNavigate } from 'react-router-dom'
+import api from '@/lib/api'
+import { useAuth } from '@/hooks/useAuth'
+import PageHeader from '@/components/ui/PageHeader'
+import Button from '@/components/ui/Button'
+import DataTable from '@/components/ui/DataTable'
+import StatusBadge from '@/components/ui/StatusBadge'
+import Pagination from '@/components/ui/Pagination'
+import SearchBar from '@/components/ui/SearchBar'
+import Select from '@/components/ui/Select'
+import RowActions from '@/components/ui/RowActions'
+import Icon from '@/components/ui/Icon'
+import DeleteProductModal from '@/modals/crm/products/DeleteProductModal'
+
+// ─── Constants ────────────────────────────────────────────────────────────────
+
+const CATEGORY_LABELS = {
+ controller: 'Controller',
+ striker: 'Striker',
+ clock: 'Clock',
+ part: 'Part',
+ repair_service: 'Repair / Service',
+}
+
+const CATEGORY_BADGE_VARIANT = {
+ controller: 'primary',
+ striker: 'info',
+ clock: 'warning',
+ part: 'neutral',
+ repair_service: 'neutral',
+}
+
+const STATUS_VARIANT = {
+ active: 'success',
+ discontinued: 'danger',
+ planned: 'warning',
+}
+
+// ─── Helpers ──────────────────────────────────────────────────────────────────
+
+function Muted({ children }) {
+ return (
+ {children}
+ )
+}
+
+function MonoCell({ children }) {
+ return (
+
+ {children}
+
+ )
+}
+
+function ProductThumb({ src, name }) {
+ if (src) {
+ return (
+
+ )
+ }
+ return (
+
+
+
+ )
+}
+
+// ─── Sorting ──────────────────────────────────────────────────────────────────
+
+function getSortValue(p, key) {
+ switch (key) {
+ case 'name': return (p.name || '').toLowerCase()
+ case 'sku': return (p.sku || '').toLowerCase()
+ case 'price': return p.price != null ? Number(p.price) : -1
+ case 'margin': {
+ const price = p.price != null ? Number(p.price) : null
+ const cost = p.costs?.total != null ? Number(p.costs.total) : null
+ return price != null && cost != null ? price - cost : -Infinity
+ }
+ case 'stock': return p.stock?.available ?? -1
+ default: return 0
+ }
+}
+
+function sortProducts(products, key, dir) {
+ if (!key) return products
+ return [...products].sort((a, b) => {
+ const va = getSortValue(a, key)
+ const vb = getSortValue(b, key)
+ if (va < vb) return dir === 'asc' ? -1 : 1
+ if (va > vb) return dir === 'asc' ? 1 : -1
+ return 0
+ })
+}
+
+// ─── ProductList ──────────────────────────────────────────────────────────────
+
+export default function ProductList() {
+ const navigate = useNavigate()
+ const { hasPermission } = useAuth()
+ const canEdit = hasPermission('crm_products', 'edit')
+ const canAdd = hasPermission('crm_products', 'add')
+
+ // Data
+ const [products, setProducts] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState('')
+
+ // Filters
+ const [search, setSearch] = useState('')
+ const [categoryFilter, setCategoryFilter] = useState('')
+ const [statusFilter, setStatusFilter] = useState('')
+
+ // Sort + pagination
+ const [sortKey, setSortKey] = useState('')
+ const [sortDir, setSortDir] = useState('asc')
+ const [page, setPage] = useState(1)
+ const [pageSize, setPageSize] = useState(20)
+
+ // Delete modal
+ const [deleteTarget, setDeleteTarget] = useState(null)
+ const [deleteLoading, setDeleteLoading] = useState(false)
+ const [, setDeleteError] = useState('')
+
+ // ── Fetch ────────────────────────────────────────────────────────────────
+
+ const fetchProducts = useCallback(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 || 'Failed to load products.')
+ } finally {
+ setLoading(false)
+ }
+ }, [categoryFilter])
+
+ useEffect(() => { fetchProducts() }, [fetchProducts])
+
+ // Reset to page 1 on filter/sort change
+ useEffect(() => { setPage(1) }, [search, categoryFilter, statusFilter])
+
+ // ── Delete ───────────────────────────────────────────────────────────────
+
+ const handleDelete = async () => {
+ if (!deleteTarget) return
+ setDeleteLoading(true)
+ setDeleteError('')
+ try {
+ await api.delete(`/crm/products/${deleteTarget.id}`)
+ setDeleteTarget(null)
+ fetchProducts()
+ } catch (err) {
+ setDeleteError(err.message || 'Failed to delete product.')
+ } finally {
+ setDeleteLoading(false)
+ }
+ }
+
+ // ── Client-side filter + sort + paginate ──────────────────────────────────
+
+ const filtered = products.filter((p) => {
+ if (statusFilter && p.status !== statusFilter &&
+ !(statusFilter === 'active' && p.active === true && !p.status)) return false
+ if (search) {
+ const q = search.toLowerCase()
+ if (!(p.name || '').toLowerCase().includes(q) &&
+ !(p.sku || '').toLowerCase().includes(q)) return false
+ }
+ return true
+ })
+
+ const sorted = sortProducts(filtered, sortKey, sortDir)
+ const total = sorted.length
+ const paged = sorted.slice((page - 1) * pageSize, page * pageSize)
+
+ // ── Column definitions ────────────────────────────────────────────────────
+
+ const columns = [
+ {
+ key: 'photo',
+ label: '',
+ width: '52px',
+ render: (p) => (
+
+ ),
+ },
+ {
+ key: 'name',
+ label: 'Name',
+ sortable: true,
+ render: (p) => (
+
+ {p.name || Unnamed }
+
+ ),
+ },
+ {
+ key: 'sku',
+ label: 'SKU',
+ sortable: true,
+ render: (p) => p.sku ? {p.sku} : — ,
+ },
+ {
+ key: 'category',
+ label: 'Category',
+ render: (p) => (
+
+ {CATEGORY_LABELS[p.category] ?? p.category ?? '—'}
+
+ ),
+ },
+ {
+ key: 'price',
+ label: 'Price',
+ align: 'right',
+ sortable: true,
+ render: (p) => {
+ const v = p.price != null ? Number(p.price) : null
+ return v != null
+ ? €{v.toFixed(2)}
+ : —
+ },
+ },
+ {
+ key: 'mfgCost',
+ label: 'Mfg. Cost',
+ align: 'right',
+ render: (p) => {
+ const v = p.costs?.total != null ? Number(p.costs.total) : null
+ return v != null
+ ? €{v.toFixed(2)}
+ : —
+ },
+ },
+ {
+ key: 'margin',
+ label: 'Margin',
+ align: 'right',
+ sortable: true,
+ render: (p) => {
+ const price = p.price != null ? Number(p.price) : null
+ const cost = p.costs?.total != null ? Number(p.costs.total) : null
+ if (price == null || cost == null) return —
+ const margin = price - cost
+ return (
+ = 0 ? 'var(--color-success)' : 'var(--color-danger)',
+ fontWeight: 'var(--font-weight-medium)',
+ }}>
+ €{margin.toFixed(2)}
+
+ )
+ },
+ },
+ {
+ key: 'stock',
+ label: 'Stock',
+ align: 'right',
+ sortable: true,
+ render: (p) => {
+ const available = p.stock?.available ?? null
+ if (available == null) return —
+ const low = available <= 5
+ return (
+
+ {available}
+
+ )
+ },
+ },
+ {
+ key: 'status',
+ label: 'Status',
+ render: (p) => {
+ const status = p.status ?? (p.active !== false ? 'active' : 'discontinued')
+ const label = status.charAt(0).toUpperCase() + status.slice(1)
+ return (
+
+ {label}
+
+ )
+ },
+ },
+ {
+ key: '__actions',
+ label: '',
+ width: '110px',
+ align: 'right',
+ render: (p) => (
+ e.stopPropagation()} style={{ display: 'flex', justifyContent: 'flex-end' }}>
+ ,
+ onClick: () => navigate(`/crm/products/${p.id}`),
+ },
+ ...(canEdit ? [
+ {
+ label: 'Delete',
+ icon: ,
+ color: 'var(--color-danger)',
+ divider: true,
+ onClick: () => { setDeleteError(''); setDeleteTarget(p) },
+ },
+ ] : []),
+ ]}
+ />
+
+ ),
+ },
+ ]
+
+ // ─────────────────────────────────────────────────────────────────────────
+
+ return (
+
+
+
+ {canAdd && (
+ navigate('/crm/products/new')}>
+ New Product
+
+ )}
+
+
+ {/* Filter toolbar */}
+
+
+
+
+
+
setCategoryFilter(e.target.value)}
+ style={{ width: '160px', flexShrink: 0 }}
+ >
+ All Categories
+ {Object.entries(CATEGORY_LABELS).map(([val, label]) => (
+ {label}
+ ))}
+
+
+
setStatusFilter(e.target.value)}
+ style={{ width: '140px', flexShrink: 0 }}
+ >
+ All Statuses
+ Active
+ Discontinued
+ Planned
+
+
+
+ {/* Error banner */}
+ {error && (
+
+ {error}
+
+ )}
+
+ {/* Table */}
+
navigate(`/crm/products/${p.id}`)}
+ skeletonRows={8}
+ sortKey={sortKey}
+ sortDir={sortDir}
+ onSort={(key, dir) => { setSortKey(key); setSortDir(dir); setPage(1) }}
+ footer={
+ total > pageSize && (
+ { setPageSize(s); setPage(1) }}
+ pageSizes={[10, 20, 50, 100]}
+ />
+ )
+ }
+ />
+
+ {/* Delete confirmation */}
+ { setDeleteTarget(null); setDeleteError('') }}
+ loading={deleteLoading}
+ />
+
+
+ )
+}
diff --git a/frontend/src/pages/crm/quotations/QuotationForm.jsx b/frontend/src/pages/crm/quotations/QuotationForm.jsx
new file mode 100644
index 0000000..df58942
--- /dev/null
+++ b/frontend/src/pages/crm/quotations/QuotationForm.jsx
@@ -0,0 +1,1085 @@
+// frontend/src/pages/crm/quotations/QuotationForm.jsx
+// Create / Edit a quotation — full line-item builder with live totals
+
+import { useState, useEffect, useMemo, useCallback } from 'react'
+import { useNavigate, useParams, useSearchParams } from 'react-router-dom'
+import api from '@/lib/api'
+import PageHeader from '@/components/ui/PageHeader'
+import Button from '@/components/ui/Button'
+import Card from '@/components/ui/Card'
+import FormField from '@/components/ui/FormField'
+import StatusBadge from '@/components/ui/StatusBadge'
+import Spinner from '@/components/ui/Spinner'
+import Icon from '@/components/ui/Icon'
+import ProductSearchModal from '@/modals/crm/ProductSearchModal'
+import { fmtEuro } from '@/lib/formatters'
+
+// ─── Constants ────────────────────────────────────────────────────────────────
+
+const STATUS_OPTIONS = ['draft', 'built', 'sent', 'accepted', 'declined']
+const STATUS_BADGE_MAP = { draft: 'neutral', built: 'warning', sent: 'info', accepted: 'success', declined: 'danger' }
+
+const ORDER_TYPE_OPTIONS = [
+ { value: '', label: '— Select —' },
+ { value: 'Shipping', label: 'Shipping' },
+ { value: 'Pick-Up', label: 'Pick-Up' },
+ { value: 'Install On-Site', label: 'Install On-Site' },
+ { value: 'Other', label: 'Other' },
+]
+
+const SHIPPING_METHOD_OPTIONS = [
+ { value: '', label: '— Select —' },
+ { value: 'Courier', label: 'Courier' },
+ { value: 'Transport', label: 'Transport' },
+ { value: 'Arranged with Client', label: 'Arranged with Client' },
+]
+
+const UNIT_OPTIONS = [
+ { value: 'pcs', label: 'pcs' },
+ { value: 'kg', label: 'kg' },
+ { value: 'm', label: 'm' },
+]
+
+// ─── Helpers ──────────────────────────────────────────────────────────────────
+
+function fmt(n) { return fmtEuro(n) }
+
+const emptyItem = (sortOrder = 0) => ({
+ product_id: null,
+ description: '',
+ description_en: '',
+ description_gr: '',
+ unit_type: 'pcs',
+ unit_cost: 0,
+ discount_percent: 0,
+ quantity: 1,
+ sort_order: sortOrder,
+})
+
+function calcLineTotal(item) {
+ const cost = parseFloat(item.unit_cost) || 0
+ const qty = parseFloat(item.quantity) || 0
+ const disc = parseFloat(item.discount_percent) || 0
+ return cost * qty * (1 - disc / 100)
+}
+
+function calcTotals(form) {
+ const items = form.items || []
+ const itemsNet = items.reduce((sum, it) => sum + calcLineTotal(it), 0)
+
+ const shipNet = (parseFloat(form.shipping_cost) || 0) * (1 - (parseFloat(form.shipping_cost_discount) || 0) / 100)
+ const installNet = (parseFloat(form.install_cost) || 0) * (1 - (parseFloat(form.install_cost_discount) || 0) / 100)
+ const subtotal = itemsNet + shipNet + installNet
+
+ const globalDiscPct = parseFloat(form.global_discount_percent) || 0
+ const globalDiscAmt = subtotal * (globalDiscPct / 100)
+ const newSubtotal = subtotal - globalDiscAmt
+
+ // VAT applies to items only (not shipping/install), scaled by global discount
+ const vatPct = parseFloat(form.global_vat_percent) || 0
+ const vatAmt = subtotal > 0 && itemsNet > 0
+ ? newSubtotal * (itemsNet / subtotal) * (vatPct / 100)
+ : 0
+
+ const extras = parseFloat(form.extras_cost) || 0
+ const finalTotal = newSubtotal + vatAmt + extras
+
+ return { subtotal_before_discount: subtotal, global_discount_amount: globalDiscAmt, new_subtotal: newSubtotal, vat_amount: vatAmt, final_total: finalTotal }
+}
+
+// ─── Small reusable sub-components ────────────────────────────────────────────
+
+// Raw input styled to match FormField's "cutout" treatment — used in the items table
+// to keep dense columns without full FormField overhead
+function CellInput({ value, onChange, type = 'text', placeholder, min, max, step, style, readOnly, className = '' }) {
+ return (
+ onChange && onChange(type === 'number' ? (parseFloat(e.target.value) ?? 0) : e.target.value)}
+ placeholder={placeholder}
+ min={min}
+ max={max}
+ step={step}
+ readOnly={readOnly}
+ className={`input ${className}`}
+ style={{
+ padding: 'var(--space-2) var(--space-2)',
+ fontSize: 'var(--font-size-sm)',
+ MozAppearance: 'textfield',
+ ...style,
+ }}
+ />
+ )
+}
+
+function CellSelect({ value, onChange, options, style }) {
+ return (
+ onChange(e.target.value)}
+ className="input"
+ style={{ padding: 'var(--space-2)', fontSize: 'var(--font-size-sm)', textAlign: 'center', ...style }}
+ >
+ {options.map(o => {o.label} )}
+
+ )
+}
+
+// ─── Main component ────────────────────────────────────────────────────────────
+
+export default function QuotationForm() {
+ const { id } = useParams()
+ const [searchParams] = useSearchParams()
+ const navigate = useNavigate()
+ const isEdit = Boolean(id)
+ const copyFromId = !isEdit ? (searchParams.get('copy_from') || null) : null
+
+ const [customer, setCustomer] = useState(null)
+ const [quotationNumber, setQuotationNumber] = useState('')
+ const [saving, setSaving] = useState(false)
+ const [loadingPage, setLoadingPage] = useState(isEdit || Boolean(copyFromId))
+ const [error, setError] = useState(null)
+ const [showProductModal, setShowProductModal] = useState(false)
+ const [showShipping, setShowShipping] = useState(false)
+ const [showInstall, setShowInstall] = useState(false)
+ const [clientPopulated, setClientPopulated] = useState(false)
+
+ const [form, setForm] = useState({
+ language: 'en',
+ title: '',
+ subtitle: '',
+ customer_id: searchParams.get('customer_id') || '',
+ order_type: '',
+ shipping_method: '',
+ estimated_shipping_date: '',
+ global_discount_label: '',
+ global_discount_percent: 0,
+ global_vat_percent: 24,
+ shipping_cost: 0,
+ shipping_cost_discount: 0,
+ install_cost: 0,
+ install_cost_discount: 0,
+ extras_label: '',
+ extras_cost: 0,
+ comments: [],
+ items: [],
+ status: 'draft',
+ client_org: '',
+ client_name: '',
+ client_location: '',
+ client_phone: '',
+ client_email: '',
+ })
+
+ const [quickNotes, setQuickNotes] = useState({
+ payment_advance: { enabled: false, percent: '30' },
+ lead_time: { enabled: false, days: '7' },
+ backup_relays: { enabled: false, count: '2' },
+ })
+
+ // ─── Load existing quotation ────────────────────────────────────────────
+ useEffect(() => {
+ if (isEdit) {
+ setLoadingPage(true)
+ api.get(`/crm/quotations/${id}`)
+ .then(q => {
+ setForm({
+ language: q.language || 'en',
+ title: q.title || '',
+ subtitle: q.subtitle || '',
+ customer_id: q.customer_id,
+ order_type: q.order_type || '',
+ shipping_method: q.shipping_method || '',
+ estimated_shipping_date: q.estimated_shipping_date || '',
+ global_discount_label: q.global_discount_label || '',
+ global_discount_percent: q.global_discount_percent || 0,
+ global_vat_percent: q.global_vat_percent ?? 24,
+ shipping_cost: q.shipping_cost || 0,
+ shipping_cost_discount: q.shipping_cost_discount || 0,
+ install_cost: q.install_cost || 0,
+ install_cost_discount: q.install_cost_discount || 0,
+ extras_label: q.extras_label || '',
+ extras_cost: q.extras_cost || 0,
+ comments: q.comments || [],
+ items: (q.items || []).map(it => ({
+ product_id: it.product_id || null,
+ description: it.description || '',
+ description_en: it.description_en || '',
+ description_gr: it.description_gr || '',
+ unit_type: it.unit_type || 'pcs',
+ unit_cost: it.unit_cost || 0,
+ discount_percent: it.discount_percent || 0,
+ quantity: it.quantity || 1,
+ sort_order: it.sort_order || 0,
+ })),
+ status: q.status || 'draft',
+ client_org: q.client_org || '',
+ client_name: q.client_name || '',
+ client_location: q.client_location || '',
+ client_phone: q.client_phone || '',
+ client_email: q.client_email || '',
+ })
+ if (q.quick_notes && typeof q.quick_notes === 'object') {
+ setQuickNotes(prev => ({
+ payment_advance: { ...prev.payment_advance, ...q.quick_notes.payment_advance },
+ lead_time: { ...prev.lead_time, ...q.quick_notes.lead_time },
+ backup_relays: { ...prev.backup_relays, ...q.quick_notes.backup_relays },
+ }))
+ }
+ setQuotationNumber(q.quotation_number)
+ if (q.shipping_cost > 0) setShowShipping(true)
+ if (q.install_cost > 0) setShowInstall(true)
+ setClientPopulated(true)
+ })
+ .catch(() => setError('Failed to load quotation.'))
+ .finally(() => setLoadingPage(false))
+ } else if (copyFromId) {
+ // Copy mode — load source quotation as a template, then get a fresh number
+ setLoadingPage(true)
+ Promise.all([
+ api.get(`/crm/quotations/${copyFromId}`),
+ api.get('/crm/quotations/next-number'),
+ ])
+ .then(([q, r]) => {
+ setForm({
+ language: q.language || 'en',
+ title: q.title || '',
+ subtitle: q.subtitle || '',
+ customer_id: searchParams.get('customer_id') || q.customer_id,
+ order_type: q.order_type || '',
+ shipping_method: q.shipping_method || '',
+ estimated_shipping_date: q.estimated_shipping_date || '',
+ global_discount_label: q.global_discount_label || '',
+ global_discount_percent: q.global_discount_percent || 0,
+ global_vat_percent: q.global_vat_percent ?? 24,
+ shipping_cost: q.shipping_cost || 0,
+ shipping_cost_discount: q.shipping_cost_discount || 0,
+ install_cost: q.install_cost || 0,
+ install_cost_discount: q.install_cost_discount || 0,
+ extras_label: q.extras_label || '',
+ extras_cost: q.extras_cost || 0,
+ comments: q.comments || [],
+ items: (q.items || []).map(it => ({
+ product_id: it.product_id || null,
+ description: it.description || '',
+ description_en: it.description_en || '',
+ description_gr: it.description_gr || '',
+ unit_type: it.unit_type || 'pcs',
+ unit_cost: it.unit_cost || 0,
+ discount_percent: it.discount_percent || 0,
+ quantity: it.quantity || 1,
+ sort_order: it.sort_order || 0,
+ })),
+ status: 'draft',
+ client_org: q.client_org || '',
+ client_name: q.client_name || '',
+ client_location: q.client_location || '',
+ client_phone: q.client_phone || '',
+ client_email: q.client_email || '',
+ })
+ if (q.quick_notes && typeof q.quick_notes === 'object') {
+ setQuickNotes(prev => ({
+ payment_advance: { ...prev.payment_advance, ...q.quick_notes.payment_advance },
+ lead_time: { ...prev.lead_time, ...q.quick_notes.lead_time },
+ backup_relays: { ...prev.backup_relays, ...q.quick_notes.backup_relays },
+ }))
+ }
+ setQuotationNumber(r.next_number)
+ if (q.shipping_cost > 0) setShowShipping(true)
+ if (q.install_cost > 0) setShowInstall(true)
+ setClientPopulated(true)
+ })
+ .catch(() => setError('Failed to load source quotation.'))
+ .finally(() => setLoadingPage(false))
+ } else {
+ api.get('/crm/quotations/next-number').then(r => setQuotationNumber(r.next_number)).catch(() => {})
+ }
+ }, [id, isEdit, copyFromId])
+
+ // ─── Load customer + auto-fill client fields ─────────────────────────────
+ useEffect(() => {
+ const cid = form.customer_id
+ if (!cid) return
+ api.get(`/crm/customers/${cid}`)
+ .then(c => {
+ setCustomer(c)
+ if (!clientPopulated) {
+ const name = [c.title, c.name, c.surname].filter(Boolean).join(' ')
+ const location = [c.location?.address, c.location?.city, c.location?.postal_code, c.location?.region, c.location?.country].filter(Boolean).join(', ')
+ const phone = (c.contacts || []).find(ct => ct.type === 'phone')?.value || ''
+ const email = (c.contacts || []).find(ct => ct.type === 'email')?.value || ''
+ setForm(f => ({ ...f, client_org: c.organization || '', client_name: name, client_location: location, client_phone: phone, client_email: email }))
+ setClientPopulated(true)
+ }
+ })
+ .catch(() => setCustomer(null))
+ }, [form.customer_id, clientPopulated])
+
+ // ─── State helpers ───────────────────────────────────────────────────────
+ const setField = useCallback((key, value) => setForm(f => ({ ...f, [key]: value })), [])
+
+ const addBlankItem = useCallback(() => {
+ setForm(f => ({ ...f, items: [...f.items, emptyItem(f.items.length)] }))
+ }, [])
+
+ const removeItem = useCallback(idx => {
+ setForm(f => ({ ...f, items: f.items.filter((_, i) => i !== idx) }))
+ }, [])
+
+ const setItemField = useCallback((idx, key, value) => {
+ setForm(f => ({ ...f, items: f.items.map((it, i) => i === idx ? { ...it, [key]: value } : it) }))
+ }, [])
+
+ const addProductFromCatalogue = useCallback(product => {
+ setForm(f => {
+ const lang = f.language || 'en'
+ const nameEn = product.name_en || product.name || ''
+ const nameGr = product.name_gr || product.name || ''
+ return {
+ ...f,
+ items: [...f.items, {
+ product_id: product.id,
+ description: lang === 'gr' ? nameGr : nameEn,
+ description_en: nameEn,
+ description_gr: nameGr,
+ unit_type: 'pcs',
+ unit_cost: product.price || 0,
+ discount_percent: 0,
+ quantity: 1,
+ sort_order: f.items.length,
+ }],
+ }
+ })
+ setShowProductModal(false)
+ }, [])
+
+ const addComment = useCallback(() => setForm(f => ({ ...f, comments: [...f.comments, ''] })), [])
+ const setComment = useCallback((idx, v) => setForm(f => ({ ...f, comments: f.comments.map((c, i) => i === idx ? v : c) })), [])
+ const removeComment = useCallback(idx => setForm(f => ({ ...f, comments: f.comments.filter((_, i) => i !== idx) })), [])
+ const setQuickNote = useCallback((key, field, value) => setQuickNotes(prev => ({ ...prev, [key]: { ...prev[key], [field]: value } })), [])
+
+ const totals = useMemo(() => calcTotals(form), [form])
+
+ // ─── Save ────────────────────────────────────────────────────────────────
+ async function handleSave(generatePdf = false) {
+ setSaving(true)
+ setError(null)
+ try {
+ const payload = {
+ customer_id: form.customer_id,
+ title: form.title || null,
+ subtitle: form.subtitle || null,
+ language: form.language,
+ order_type: form.order_type || null,
+ shipping_method: form.shipping_method || null,
+ estimated_shipping_date: form.estimated_shipping_date || null,
+ global_discount_label: form.global_discount_label || null,
+ global_discount_percent: parseFloat(form.global_discount_percent) || 0,
+ global_vat_percent: parseFloat(form.global_vat_percent) ?? 24,
+ shipping_cost: showShipping ? (parseFloat(form.shipping_cost) || 0) : 0,
+ shipping_cost_discount: showShipping ? (parseFloat(form.shipping_cost_discount) || 0) : 0,
+ install_cost: showInstall ? (parseFloat(form.install_cost) || 0) : 0,
+ install_cost_discount: showInstall ? (parseFloat(form.install_cost_discount) || 0) : 0,
+ extras_label: form.extras_label || null,
+ extras_cost: parseFloat(form.extras_cost) || 0,
+ comments: form.comments.filter(c => c.trim()),
+ quick_notes: quickNotes,
+ items: form.items.map((it, i) => ({
+ product_id: it.product_id || null,
+ description: it.description || null,
+ description_en: it.description_en || null,
+ description_gr: it.description_gr || null,
+ unit_type: it.unit_type || 'pcs',
+ unit_cost: parseFloat(it.unit_cost) || 0,
+ discount_percent: parseFloat(it.discount_percent) || 0,
+ quantity: parseFloat(it.quantity) || 1,
+ sort_order: i,
+ })),
+ client_org: form.client_org || null,
+ client_name: form.client_name || null,
+ client_location: form.client_location || null,
+ client_phone: form.client_phone || null,
+ client_email: form.client_email || null,
+ }
+
+ const pdfParam = generatePdf ? '?generate_pdf=true' : ''
+ // Status rules: "Save Draft" always sets draft; "Generate & Save PDF" sets built (unless staff manually set accepted/declined)
+ const saveStatus = generatePdf
+ ? (form.status === 'accepted' || form.status === 'declined' ? form.status : 'built')
+ : 'draft'
+ let result
+ if (isEdit) {
+ result = await api.put(`/crm/quotations/${id}${pdfParam}`, { ...payload, status: saveStatus })
+ } else {
+ result = await api.post(`/crm/quotations${pdfParam}`, { ...payload, status: saveStatus })
+ }
+
+ navigate(`/crm/customers/${result.customer_id}`, { state: { tab: 'Quotations' } })
+ } catch (e) {
+ setError(e.message || 'Save failed')
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ // ─── Loading screen ──────────────────────────────────────────────────────
+ if (loadingPage) {
+ return (
+
+
+
+ )
+ }
+
+ if (error && !form.customer_id) {
+ return (
+
+ )
+ }
+
+ const customerLabel = customer
+ ? (customer.organization || [customer.title, customer.name, customer.surname].filter(Boolean).join(' '))
+ : null
+
+ // ─── Render ──────────────────────────────────────────────────────────────
+ return (
+
+
+
+ {isEdit && (
+
+
+ {quotationNumber}
+
+
+ {form.status}
+
+
+ )}
+ navigate(-1)} disabled={saving}>
+ Cancel
+
+ handleSave(false)}>
+ Save Draft
+
+ }
+ onClick={() => handleSave(true)}
+ >
+ Generate & Save PDF
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+ {/* ── Two-column layout ───────────────────────────────────────────── */}
+
+
+ {/* LEFT COLUMN — 38% */}
+
+
+ {/* ① Header card */}
+
+
+ {/* Language toggle */}
+
+
Language
+
+ {['en', 'gr'].map(lang => (
+ setField('language', lang)}
+ style={{
+ padding: 'var(--space-3) var(--space-4)',
+ fontSize: 'var(--font-size-base)',
+ fontWeight: 'var(--font-weight-semibold)',
+ lineHeight: 'var(--line-height-base)',
+ borderRadius: 'var(--radius-md)',
+ border: '1px solid var(--color-border)',
+ cursor: 'pointer',
+ backgroundColor: form.language === lang ? 'var(--color-primary-subtle)' : 'transparent',
+ color: form.language === lang ? 'var(--color-primary)' : 'var(--color-text-secondary)',
+ borderColor: form.language === lang ? 'var(--color-primary)' : 'var(--color-border)',
+ transition: 'all 0.15s',
+ }}
+ >
+ {lang === 'en' ? 'EN' : 'GR'}
+
+ ))}
+
+
+
+ {/* Quotation # */}
+
+ {}}
+ inputProps={{ readOnly: true, style: { fontFamily: 'var(--font-family-mono)', fontWeight: 600 } }}
+ />
+
+
+
+
+ setField('title', e.target.value)}
+ placeholder="Quotation title…"
+ />
+ setField('subtitle', e.target.value)}
+ placeholder="Optional subtitle…"
+ />
+ setField('status', e.target.value)}
+ >
+ {STATUS_OPTIONS.map(s => (
+ {s.charAt(0).toUpperCase() + s.slice(1)}
+ ))}
+
+
+
+
+ {/* ② Client Info */}
+
+
+
+ setField('client_org', e.target.value)} placeholder="Company…" />
+ setField('client_phone', e.target.value)} placeholder="+30…" />
+
+
+ setField('client_name', e.target.value)} placeholder="Full name…" />
+ setField('client_email', e.target.value)} placeholder="email@…" />
+
+
setField('client_location', e.target.value)} placeholder="City, region, country…" />
+
+
+
+ {/* ③ Order Details */}
+
+
+
+ setField('order_type', e.target.value)}>
+ {ORDER_TYPE_OPTIONS.map(o => {o.label} )}
+
+ setField('shipping_method', e.target.value)}>
+ {SHIPPING_METHOD_OPTIONS.map(o => {o.label} )}
+
+
+
setField('estimated_shipping_date', e.target.value)}
+ inputProps={{ type: 'date' }}
+ />
+
+
+
+
{/* end LEFT */}
+
+ {/* RIGHT COLUMN */}
+
+
+ {/* ④ Items */}
+
+ {/* Items table */}
+ {(form.items.length > 0 || showShipping || showInstall) && (
+
+ {['Description', 'Unit Cost €', 'Disc %', 'Qty', 'Unit', 'Line Total', ''].map((h, i) => (
+
= 1 && i <= 4 ? 'center' : 'left',
+ }}>
+ {h}
+
+ ))}
+
+ )}
+
+ {/* Item rows */}
+
+ {form.items.map((item, idx) => (
+
+
setItemField(idx, 'description', v)}
+ placeholder="Description…"
+ />
+ setItemField(idx, 'unit_cost', v)}
+ min={0} step="0.01"
+ style={{ textAlign: 'center' }}
+ />
+ setItemField(idx, 'discount_percent', v)}
+ min={0} max={100} step="0.5"
+ style={{ textAlign: 'center' }}
+ />
+ setItemField(idx, 'quantity', v)}
+ min={0} step="1"
+ style={{ textAlign: 'center' }}
+ />
+ setItemField(idx, 'unit_type', v)}
+ options={UNIT_OPTIONS}
+ style={{ textAlign: 'center' }}
+ />
+
+ {fmt(calcLineTotal(item))}
+
+ removeItem(idx)}
+ style={{
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
+ width: 28, height: 28,
+ borderRadius: 'var(--radius-md)',
+ border: '1px solid var(--color-border)',
+ backgroundColor: 'transparent',
+ cursor: 'pointer',
+ color: 'var(--color-text-muted)',
+ transition: 'color 0.15s, border-color 0.15s, background-color 0.15s',
+ }}
+ onMouseEnter={e => {
+ e.currentTarget.style.color = 'var(--color-danger)'
+ e.currentTarget.style.borderColor = 'rgba(255,92,92,0.4)'
+ e.currentTarget.style.backgroundColor = 'var(--color-danger-bg)'
+ }}
+ onMouseLeave={e => {
+ e.currentTarget.style.color = 'var(--color-text-muted)'
+ e.currentTarget.style.borderColor = 'var(--color-border)'
+ e.currentTarget.style.backgroundColor = 'transparent'
+ }}
+ >
+
+
+
+ ))}
+
+
+ {/* Empty state */}
+ {form.items.length === 0 && !showShipping && !showInstall && (
+
+ No items yet — add from catalogue or add a blank row
+
+ )}
+
+ {/* Shipping row */}
+ {showShipping && (
+
+
+ Shipping
+
+
setField('shipping_cost', v)}
+ min={0} step="0.01"
+ style={{ textAlign: 'right' }}
+ />
+
+ {fmt(parseFloat(form.shipping_cost) || 0)}
+
+ { setShowShipping(false); setField('shipping_cost', 0); setField('shipping_cost_discount', 0) }} style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: 28, height: 28, borderRadius: 'var(--radius-md)', border: '1px solid var(--color-border)', backgroundColor: 'transparent', cursor: 'pointer', color: 'var(--color-text-muted)' }}>
+
+
+
+ )}
+
+ {/* Install row */}
+ {showInstall && (
+
+
+ Installation
+
+
setField('install_cost', v)}
+ min={0} step="0.01"
+ style={{ textAlign: 'right' }}
+ />
+
+ {fmt(parseFloat(form.install_cost) || 0)}
+
+ { setShowInstall(false); setField('install_cost', 0); setField('install_cost_discount', 0) }} style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: 28, height: 28, borderRadius: 'var(--radius-md)', border: '1px solid var(--color-border)', backgroundColor: 'transparent', cursor: 'pointer', color: 'var(--color-text-muted)' }}>
+
+
+
+ )}
+
+ {/* Add rows / shipping / install — shipping/install left, catalogue/blank right */}
+
+
+ {!showShipping && (
+ setShowShipping(true)}>
+ + Shipping Cost
+
+ )}
+ {!showInstall && (
+ setShowInstall(true)}>
+ + Installation Cost
+
+ )}
+
+
+ setShowProductModal(true)}>
+ + From Catalogue
+
+
+ + Blank Row
+
+
+
+
+
+ {/* ⑤ Totals */}
+
+
+ {/* Discount + VAT rows side by side */}
+
+ {/* Discount row */}
+
+
Discount
+
setField('global_discount_label', v)}
+ placeholder="Discount label…"
+ style={{ fontSize: 'var(--font-size-xs)' }}
+ />
+ setField('global_discount_percent', v)}
+ min={0} max={100} step="0.5"
+ style={{ textAlign: 'right', fontSize: 'var(--font-size-xs)' }}
+ />
+ %
+
+
+ {/* VAT row */}
+
+
VAT
+
Items only — excl. shipping & install
+
setField('global_vat_percent', v)}
+ min={0} max={100} step="1"
+ style={{ textAlign: 'right', fontSize: 'var(--font-size-xs)' }}
+ />
+ %
+
+
+
+ {/* Extras row */}
+
+
Other
+
setField('extras_label', v)}
+ placeholder="Other costs…"
+ style={{ fontSize: 'var(--font-size-xs)' }}
+ />
+ setField('extras_cost', v)}
+ min={0} step="0.01"
+ style={{ textAlign: 'right', fontSize: 'var(--font-size-xs)' }}
+ />
+ €
+
+
+ {/* Summary boxes */}
+
+ {[
+ { label: 'Subtotal excl. VAT', value: fmt(totals.subtotal_before_discount), accent: false, due: false },
+ { label: 'After Discount', value: fmt(totals.new_subtotal), accent: false, due: false },
+ { label: `VAT ${parseFloat(form.global_vat_percent) || 0}%`, value: fmt(totals.vat_amount), accent: false, due: false },
+ { label: 'Total Due', value: fmt(totals.final_total), accent: true, due: true },
+ ].map(({ label, value, accent, due }) => (
+
+
+ {label}
+
+
+ {value}
+
+
+ ))}
+
+
+
+ {/* ⑥ Notes & Quick Notes */}
+
+
+ {/* Quick notes */}
+
+
+ Quick Notes
+
+
+ {[
+ { key: 'payment_advance', label: 'Payment Advance', fieldKey: 'percent', suffix: '%' },
+ { key: 'lead_time', label: 'Lead Time', fieldKey: 'days', suffix: 'days' },
+ { key: 'backup_relays', label: 'Backup Relays', fieldKey: 'count', suffix: `relay${parseInt(quickNotes.backup_relays.count) === 1 ? '' : 's'}` },
+ ].map(({ key, label, fieldKey, suffix }) => (
+
+ setQuickNote(key, 'enabled', e.target.checked)}
+ style={{ cursor: 'pointer', accentColor: 'var(--color-primary)' }}
+ />
+
+ {label}
+
+ setQuickNote(key, fieldKey, v)}
+ min={1} step={1}
+ style={{ textAlign: 'right', opacity: quickNotes[key].enabled ? 1 : 0.4 }}
+ />
+ {suffix}
+
+ ))}
+
+
+
+ {/* Dynamic comments */}
+
+ {form.comments.map((comment, idx) => (
+
+
+ ))}
+
+
+ {/* Empty state + Add Note — on same line */}
+
+ {form.comments.length === 0 && (
+
+ No notes. Click "+ Add Note" to add one.
+
+ )}
+
+ + Add Note
+
+
+
+
+
{/* end RIGHT */}
+
+
+ {/* ── Modals ──────────────────────────────────────────────────────── */}
+ {showProductModal && (
+
setShowProductModal(false)}
+ />
+ )}
+
+ )
+}
diff --git a/frontend/src/pages/crm/quotations/QuotationList.jsx b/frontend/src/pages/crm/quotations/QuotationList.jsx
new file mode 100644
index 0000000..b4ef151
--- /dev/null
+++ b/frontend/src/pages/crm/quotations/QuotationList.jsx
@@ -0,0 +1,756 @@
+// frontend/src/pages/crm/quotations/QuotationList.jsx
+// All-quotations list page — browse, filter, and navigate to individual quotations
+
+import { useState, useEffect, useCallback, useRef } from 'react'
+import { useNavigate } from 'react-router-dom'
+import api from '@/lib/api'
+import PageHeader from '@/components/ui/PageHeader'
+import Button from '@/components/ui/Button'
+import StatusBadge from '@/components/ui/StatusBadge'
+import SearchBar from '@/components/ui/SearchBar'
+import Select from '@/components/ui/Select'
+import Spinner from '@/components/ui/Spinner'
+import Icon from '@/components/ui/Icon'
+import ConfirmDialog from '@/components/ui/ConfirmDialog'
+import PdfViewModal from '@/modals/crm/PdfViewModal'
+import ComposeEmailModal from '@/modals/crm/ComposeEmailModal'
+import SelectCustomerModal from '@/modals/crm/SelectCustomerModal'
+import { useToast } from '@/components/ui/Toast'
+import { fmtDate as fmtDateCentral, fmtEuro } from '@/lib/formatters'
+
+// ─── Helpers ──────────────────────────────────────────────────────────────────
+
+function fmt(n) { return fmtEuro(n) }
+function fmtDate(iso) { return fmtDateCentral(iso) }
+
+const STATUS_BADGE_MAP = {
+ draft: 'neutral',
+ built: 'warning',
+ sent: 'info',
+ accepted: 'success',
+ declined: 'danger',
+}
+
+const STATUS_OPTIONS = ['draft', 'built', 'sent', 'accepted', 'declined']
+
+// ─── PDF thumbnail via PDF.js ──────────────────────────────────────────────────
+
+const THUMB_W = 69
+const THUMB_H = 90
+
+function loadPdfJs() {
+ return new Promise((res, rej) => {
+ if (window.pdfjsLib) { res(); return }
+ if (document.getElementById('__pdfjs__')) {
+ const check = setInterval(() => {
+ if (window.pdfjsLib) { clearInterval(check); res() }
+ }, 50)
+ return
+ }
+ const s = document.createElement('script')
+ s.id = '__pdfjs__'
+ 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 loadingTask = window.pdfjsLib.getDocument({
+ url: `/api/crm/quotations/${quotationId}/pdf`,
+ 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(THUMB_W / viewport.width, THUMB_H / 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])
+
+ return (
+ {
+ e.currentTarget.style.borderColor = 'var(--color-primary)'
+ e.currentTarget.style.boxShadow = 'var(--shadow-focus)'
+ }}
+ onMouseLeave={e => {
+ e.currentTarget.style.borderColor = 'var(--color-border)'
+ e.currentTarget.style.boxShadow = 'none'
+ }}
+ >
+ {failed ? (
+
+
+
+
+
+ ) : (
+
+ )}
+
+ )
+}
+
+function DraftThumbnail() {
+ return (
+
+ )
+}
+
+// ─── Actions dropdown ─────────────────────────────────────────────────────────
+
+function ActionsMenu({ quotation, onSend, onEdit, onCopy, onDelete, onStatusChange, deleting }) {
+ const [open, setOpen] = useState(false)
+ const [statusOpen, setStatusOpen] = useState(false)
+ const [menuPos, setMenuPos] = useState({ top: 0, right: 0 })
+ const ref = useRef(null)
+ const btnRef = useRef(null)
+
+ useEffect(() => {
+ if (!open) return
+ const h = e => { if (ref.current && !ref.current.contains(e.target)) { setOpen(false); setStatusOpen(false) } }
+ document.addEventListener('mousedown', h)
+ return () => document.removeEventListener('mousedown', h)
+ }, [open])
+
+ const openMenu = e => {
+ e.stopPropagation()
+ if (!open && btnRef.current) {
+ const r = btnRef.current.getBoundingClientRect()
+ setMenuPos({ top: r.bottom + 4, right: window.innerWidth - r.right })
+ }
+ setOpen(o => !o)
+ setStatusOpen(false)
+ }
+
+ const item = (label, iconEl, onClick, danger = false) => (
+ { e.stopPropagation(); setOpen(false); setStatusOpen(false); onClick() }}
+ style={{
+ width: '100%', display: 'flex', alignItems: 'center', gap: 'var(--space-2)',
+ padding: 'var(--space-2) var(--space-3)', backgroundColor: 'transparent', border: 'none',
+ cursor: 'pointer', fontSize: 'var(--font-size-sm)', textAlign: 'left',
+ color: danger ? 'var(--color-danger)' : 'var(--color-text-secondary)',
+ transition: 'background-color 0.1s',
+ }}
+ onMouseEnter={e => e.currentTarget.style.backgroundColor = danger ? 'var(--color-danger-bg)' : 'var(--color-bg-elevated)'}
+ onMouseLeave={e => e.currentTarget.style.backgroundColor = 'transparent'}
+ >
+ {iconEl}
+ {label}
+
+ )
+
+ return (
+
+
{ e.currentTarget.style.borderColor = 'var(--color-border-strong)'; e.currentTarget.style.backgroundColor = 'var(--color-bg-elevated)' }}
+ onMouseLeave={e => { if (!open) { e.currentTarget.style.borderColor = 'var(--color-border)'; e.currentTarget.style.backgroundColor = 'transparent' } }}
+ >
+ Actions
+
+
+
+
+
+ {open && (
+
+ {!quotation.is_legacy && item('Send',
, onSend)}
+ {!quotation.is_legacy && item('Edit',
+
,
+ onEdit
+ )}
+ {item('Copy',
, onCopy)}
+
+ {/* Change Status sub-menu */}
+
{ e.stopPropagation(); setStatusOpen(o => !o) }}
+ style={{
+ width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
+ padding: 'var(--space-2) var(--space-3)', backgroundColor: statusOpen ? 'var(--color-bg-elevated)' : 'transparent',
+ border: 'none', cursor: 'pointer', fontSize: 'var(--font-size-sm)', color: 'var(--color-text-secondary)',
+ transition: 'background-color 0.1s',
+ }}
+ onMouseEnter={e => e.currentTarget.style.backgroundColor = 'var(--color-bg-elevated)'}
+ onMouseLeave={e => { if (!statusOpen) e.currentTarget.style.backgroundColor = 'transparent' }}
+ >
+
+
+ Change Status
+
+
+
+
+
+
+ {statusOpen && (
+
+ {STATUS_OPTIONS.map(s => (
+ { e.stopPropagation(); setOpen(false); setStatusOpen(false); onStatusChange(s) }}
+ disabled={quotation.status === s}
+ style={{
+ width: '100%', display: 'flex', alignItems: 'center', gap: 'var(--space-2)',
+ padding: 'var(--space-2) var(--space-4)', backgroundColor: 'transparent', border: 'none',
+ cursor: quotation.status === s ? 'default' : 'pointer', fontSize: 'var(--font-size-sm)',
+ color: quotation.status === s ? 'var(--color-text-muted)' : 'var(--color-text-secondary)',
+ opacity: quotation.status === s ? 0.5 : 1, textAlign: 'left', transition: 'background-color 0.1s',
+ }}
+ onMouseEnter={e => { if (quotation.status !== s) e.currentTarget.style.backgroundColor = 'var(--color-bg-elevated)' }}
+ onMouseLeave={e => e.currentTarget.style.backgroundColor = 'transparent'}
+ >
+ {s.charAt(0).toUpperCase() + s.slice(1)}
+
+ ))}
+
+ )}
+
+
+
{ e.stopPropagation(); setOpen(false); setStatusOpen(false); onDelete() }}
+ disabled={deleting}
+ style={{
+ width: '100%', display: 'flex', alignItems: 'center', gap: 'var(--space-2)',
+ padding: 'var(--space-2) var(--space-3)', backgroundColor: 'transparent', border: 'none',
+ cursor: deleting ? 'not-allowed' : 'pointer', fontSize: 'var(--font-size-sm)',
+ color: 'var(--color-danger)', textAlign: 'left', opacity: deleting ? 0.5 : 1,
+ transition: 'background-color 0.1s',
+ }}
+ onMouseEnter={e => e.currentTarget.style.backgroundColor = 'var(--color-danger-bg)'}
+ onMouseLeave={e => e.currentTarget.style.backgroundColor = 'transparent'}
+ >
+
+ Delete
+
+
+ )}
+
+ )
+}
+
+// ─── Main component ────────────────────────────────────────────────────────────
+
+export default function QuotationList() {
+ const navigate = useNavigate()
+ const { toast } = useToast()
+
+ const [quotations, setQuotations] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState(null)
+ const [search, setSearch] = useState('')
+ const [filterStatus, setFilterStatus] = useState('')
+ const [filterType, setFilterType] = useState('')
+ const [pdfPreview, setPdfPreview] = useState(null)
+ const [deleting, setDeleting] = useState(null)
+ const [deleteTarget, setDeleteTarget] = useState(null)
+ const [showCustomerPicker, setShowCustomerPicker] = useState(false)
+ const [composeTarget, setComposeTarget] = useState(null)
+
+ const load = useCallback(async () => {
+ setLoading(true)
+ setError(null)
+ try {
+ const res = await api.get('/crm/quotations/all')
+ setQuotations(Array.isArray(res) ? res : [])
+ } catch {
+ setError('Failed to load quotations.')
+ setQuotations([])
+ } finally {
+ setLoading(false)
+ }
+ }, [])
+
+ useEffect(() => { load() }, [load])
+
+ const handleDelete = async () => {
+ if (!deleteTarget) return
+ setDeleting(deleteTarget.id)
+ try {
+ await api.delete(`/crm/quotations/${deleteTarget.id}`)
+ setQuotations(prev => prev.filter(x => x.id !== deleteTarget.id))
+ toast.success('Deleted', `Quotation ${deleteTarget.quotation_number} removed.`)
+ setDeleteTarget(null)
+ } catch {
+ toast.danger('Error', 'Failed to delete quotation.')
+ } finally {
+ setDeleting(null)
+ }
+ }
+
+ const handleSend = async (q) => {
+ const hasPdf = q.is_legacy ? !!q.legacy_pdf_path : !!q.nextcloud_pdf_url
+ let attachments = []
+ if (hasPdf) {
+ try {
+ const token = localStorage.getItem('access_token')
+ const resp = await fetch(`/api/crm/quotations/${q.id}/pdf`, {
+ headers: { Authorization: `Bearer ${token}` },
+ })
+ if (resp.ok) {
+ const blob = await resp.blob()
+ attachments = [new File([blob], `${q.quotation_number}.pdf`, { type: 'application/pdf' })]
+ }
+ } catch { /* open without attachment */ }
+ }
+ setComposeTarget({ quotation: q, defaultTo: q.customer_email || '', attachments })
+ }
+
+ const handleStatusChange = async (q, newStatus) => {
+ try {
+ await api.put(`/crm/quotations/${q.id}`, { status: newStatus })
+ setQuotations(prev => prev.map(x => x.id === q.id ? { ...x, status: newStatus } : x))
+ toast.success('Status updated', `Quotation ${q.quotation_number} marked as ${newStatus}.`)
+ } catch {
+ toast.danger('Error', 'Failed to update status.')
+ }
+ }
+
+ // ─── Filtering ────────────────────────────────────────────────────────────
+ const filtered = quotations.filter(q => {
+ if (filterStatus && q.status !== filterStatus) return false
+ if (filterType === 'regular' && q.is_legacy) return false
+ if (filterType === 'legacy' && !q.is_legacy) return false
+
+ if (search.trim()) {
+ const s = search.toLowerCase()
+ const customerName = (q.customer_name || q.customer_org || '').toLowerCase()
+ const title = (q.title || '').toLowerCase()
+ const number = (q.quotation_number || '').toLowerCase()
+ if (!customerName.includes(s) && !title.includes(s) && !number.includes(s)) return false
+ }
+
+ return true
+ })
+
+ return (
+
+
+ }
+ onClick={() => setShowCustomerPicker(true)}
+ >
+ New Quotation
+
+
+
+ {/* ── Filter bar ───────────────────────────────────────────────────── */}
+
+
+
+
+
+ All statuses
+ {STATUS_OPTIONS.map(s => (
+ {s.charAt(0).toUpperCase() + s.slice(1)}
+ ))}
+
+
+ All types
+ Regular
+ Legacy
+
+ {(search || filterStatus || filterType) && (
+
{ setSearch(''); setFilterStatus(''); setFilterType('') }}
+ >
+ Clear
+
+ )}
+
+
+ {/* ── Table ────────────────────────────────────────────────────────── */}
+
+
+ {/* Table header */}
+
+ {['', 'Number', 'Customer', 'Title', 'Date', 'Status', 'Total', ''].map((h, i) => (
+
+ {h}
+
+ ))}
+
+
+ {/* Loading */}
+ {loading && (
+
+
+
+ )}
+
+ {/* Error */}
+ {!loading && error && (
+
+ {error}
+
+ )}
+
+ {/* Empty */}
+ {!loading && !error && filtered.length === 0 && (
+
+
+
+
+
+
+
+
+ {quotations.length === 0 ? 'No quotations yet' : 'No results match your filters'}
+
+
+ {quotations.length === 0
+ ? 'Create your first quotation to get started.'
+ : 'Try adjusting your search or filters.'}
+
+ {quotations.length === 0 && (
+
setShowCustomerPicker(true)}
+ style={{ marginTop: 'var(--space-2)' }}
+ >
+ New Quotation
+
+ )}
+
+ )}
+
+ {/* Rows */}
+ {!loading && !error && filtered.map((q, i) => {
+ const hasPdf = q.is_legacy ? !!q.legacy_pdf_path : !!q.nextcloud_pdf_url
+ const displayDate = q.is_legacy
+ ? (q.legacy_date ? fmtDate(q.legacy_date) : fmtDate(q.created_at))
+ : fmtDate(q.created_at)
+
+ return (
+
navigate(`/crm/customers/${q.customer_id}?tab=Quotations`)}
+ style={{
+ display: 'grid',
+ gridTemplateColumns: '76px 160px 210px minmax(0,1fr) 110px 120px 150px 130px',
+ gap: 'var(--space-3)',
+ padding: 'var(--space-3) var(--space-5)',
+ borderBottom: i < filtered.length - 1 ? '1px solid var(--color-border)' : 'none',
+ alignItems: 'center',
+ cursor: 'pointer',
+ transition: 'background-color 0.12s',
+ minHeight: 96,
+ position: 'relative',
+ }}
+ onMouseEnter={e => e.currentTarget.style.backgroundColor = 'var(--color-bg-elevated)'}
+ onMouseLeave={e => e.currentTarget.style.backgroundColor = ''}
+ >
+ {/* Thumbnail */}
+
e.stopPropagation()}>
+ {hasPdf
+ ?
setPdfPreview({ id: q.id, number: q.quotation_number })} />
+ : }
+
+
+ {/* Quotation number — single line, badge inline */}
+
+
+ {q.quotation_number}
+
+ {!!q.is_legacy && (
+
+ Legacy
+
+ )}
+
+
+ {/* Customer — Name Surname on top, Organization below */}
+
+
+ {q.customer_name || '—'}
+
+ {q.customer_org ? (
+
+ {q.customer_org}
+
+ ) : null}
+
+
+ {/* Title */}
+
+ {q.title || Untitled }
+
+
+ {/* Date */}
+
+ {displayDate}
+
+
+ {/* Status */}
+
+
+ {q.status}
+
+
+
+ {/* Total */}
+
+ {fmt(q.final_total)}
+
+
+ {/* Actions dropdown */}
+
e.stopPropagation()}
+ >
+
handleSend(q)}
+ onEdit={() => navigate(`/crm/quotations/${q.id}`)}
+ onCopy={() => navigate(`/crm/quotations/new?copy_from=${q.id}&customer_id=${q.customer_id}`)}
+ onDelete={() => setDeleteTarget(q)}
+ onStatusChange={s => handleStatusChange(q, s)}
+ />
+
+
+ )
+ })}
+
+
+ {/* Footer count */}
+ {!loading && filtered.length > 0 && (
+
+ Showing {filtered.length} of {quotations.length} quotation{quotations.length !== 1 ? 's' : ''}
+
+ )}
+
+ {/* PDF preview modal */}
+ {pdfPreview && (
+
setPdfPreview(null)}
+ />
+ )}
+
+ {/* Compose email modal */}
+ setComposeTarget(null)}
+ defaultTo={composeTarget?.defaultTo || ''}
+ defaultSubject={composeTarget ? `Quotation ${composeTarget.quotation.quotation_number}` : ''}
+ defaultAttachments={composeTarget?.attachments || []}
+ customerId={composeTarget?.quotation?.customer_id}
+ />
+
+ {/* Delete confirm */}
+ setDeleteTarget(null)}
+ />
+
+ {/* Customer picker — required before creating a new quotation */}
+ {showCustomerPicker && (
+ {
+ setShowCustomerPicker(false)
+ navigate(`/crm/quotations/new?customer_id=${customer.id}`)
+ }}
+ onClose={() => setShowCustomerPicker(false)}
+ />
+ )}
+
+ )
+}
diff --git a/frontend/src/pages/dashboard/DashboardPage.jsx b/frontend/src/pages/dashboard/DashboardPage.jsx
new file mode 100644
index 0000000..91a27cd
--- /dev/null
+++ b/frontend/src/pages/dashboard/DashboardPage.jsx
@@ -0,0 +1,37 @@
+// frontend/src/pages/dashboard/DashboardPage.jsx
+
+import { useState, useEffect } from 'react'
+import PageHeader from '@/components/ui/PageHeader'
+import { useAuth } from '@/hooks/useAuth'
+import { fmtDateFull, fmtTime24 } from '@/lib/formatters'
+
+export default function DashboardPage() {
+ const { user } = useAuth()
+ const [now, setNow] = useState(new Date())
+
+ useEffect(() => {
+ const timer = setInterval(() => setNow(new Date()), 1000)
+ return () => clearInterval(timer)
+ }, [])
+
+ const dateStr = fmtDateFull(now)
+ const timeStr = fmtTime24(now)
+
+ return (
+
+
+
+
+
+ {user?.name ? `Welcome, ${user.name}.` : 'Welcome.'}
+
+
+ {dateStr} — {timeStr}
+
+
+
+ )
+}
diff --git a/frontend/src/pages/dev/CardFontSample.jsx b/frontend/src/pages/dev/CardFontSample.jsx
new file mode 100644
index 0000000..27e4fa6
--- /dev/null
+++ b/frontend/src/pages/dev/CardFontSample.jsx
@@ -0,0 +1,434 @@
+// frontend/src/pages/dev/CardFontSample.jsx
+// Standalone card header style sampler — no MainLayout, no auth required.
+// View at: /dev/card-fonts
+//
+// Shows the stitch-reference style: Inter, uppercase, tracking-widest, icon-left.
+// Each card uses a different icon to demonstrate how sections would look in practice.
+
+import { useEffect } from 'react'
+import Icon from '@/components/ui/Icon'
+
+// ─── Shared mock body content ─────────────────────────────────────────────────
+
+const services = [
+ { name: 'API Gateway', status: 'success' },
+ { name: 'Database', status: 'success' },
+ { name: 'Worker Queue', status: 'warning' },
+ { name: 'CDN Edge', status: 'success' },
+]
+
+const dotColor = {
+ success: 'var(--color-success)',
+ warning: 'var(--color-warning)',
+}
+
+function ServiceRow({ name, status }) {
+ return (
+
+
+ {name}
+ {status === 'success' ? 'OK' : 'WARN'}
+
+ )
+}
+
+const CardBody = () => (
+
+ {services.map(s => )}
+
+)
+
+const CardFooter = () => (
+
+ Last checked 2 minutes ago
+ Refresh
+
+)
+
+// ─── Card header style variants ───────────────────────────────────────────────
+// All use Inter. They vary only in the title treatment (size, weight, case, spacing, color).
+
+const specimens = [
+ {
+ id: 1,
+ icon: 'settings',
+ iconColor: 'var(--color-primary)',
+ label: '1 · Inter 600 · uppercase · tracking-widest · text-muted',
+ description: 'Closest to the stitch reference. Clean, restrained, professional.',
+ titleStyle: {
+ fontFamily: 'Inter, sans-serif',
+ fontSize: '0.6875rem', /* 11px */
+ fontWeight: 600,
+ textTransform: 'uppercase',
+ letterSpacing: '0.12em',
+ color: 'var(--color-text-muted)',
+ },
+ },
+ {
+ id: 2,
+ icon: 'devices',
+ iconColor: 'var(--color-primary)',
+ label: '2 · Inter 700 · uppercase · tracking-widest · on-surface-variant',
+ description: 'Heavier weight — bolder impression while keeping the muted tone.',
+ titleStyle: {
+ fontFamily: 'Inter, sans-serif',
+ fontSize: '0.6875rem',
+ fontWeight: 700,
+ textTransform: 'uppercase',
+ letterSpacing: '0.12em',
+ color: 'var(--color-text-secondary)',
+ },
+ },
+ {
+ id: 3,
+ icon: 'dashboard',
+ iconColor: 'var(--color-primary)',
+ label: '3 · Inter 600 · uppercase · tracking-wide · primary color',
+ description: 'Icon and title in the same accent color — more expressive.',
+ titleStyle: {
+ fontFamily: 'Inter, sans-serif',
+ fontSize: '0.6875rem',
+ fontWeight: 600,
+ textTransform: 'uppercase',
+ letterSpacing: '0.10em',
+ color: 'var(--color-primary)',
+ },
+ },
+ {
+ id: 4,
+ icon: 'customers',
+ iconColor: 'var(--color-text-muted)',
+ label: '4 · Inter 500 · uppercase · tracking-widest · muted icon + muted title',
+ description: 'Both icon and title muted — very quiet, recedes into the card.',
+ titleStyle: {
+ fontFamily: 'Inter, sans-serif',
+ fontSize: '0.6875rem',
+ fontWeight: 500,
+ textTransform: 'uppercase',
+ letterSpacing: '0.14em',
+ color: 'var(--color-text-muted)',
+ },
+ },
+ {
+ id: 5,
+ icon: 'firmware',
+ iconColor: 'var(--color-primary)',
+ label: '5 · Inter 600 · uppercase · tracking-widest · 12px (slightly larger)',
+ description: 'Same treatment at 12px — easier to read at a glance.',
+ titleStyle: {
+ fontFamily: 'Inter, sans-serif',
+ fontSize: 'var(--font-size-sm)', /* 12px */
+ fontWeight: 600,
+ textTransform: 'uppercase',
+ letterSpacing: '0.10em',
+ color: 'var(--color-text-muted)',
+ },
+ },
+ {
+ id: 6,
+ icon: 'mail',
+ iconColor: 'var(--color-primary)',
+ label: '6 · Inter 600 · title case · 14px · no tracking',
+ description: 'Title case at body size — feels more conversational.',
+ titleStyle: {
+ fontFamily: 'Inter, sans-serif',
+ fontSize: 'var(--font-size-base)', /* 14px */
+ fontWeight: 600,
+ textTransform: 'none',
+ letterSpacing: '-0.01em',
+ color: 'var(--color-text-primary)',
+ },
+ },
+ {
+ id: 7,
+ icon: 'manufacturing',
+ iconColor: 'var(--color-primary)',
+ label: '7 · Inter 500 · title case · 14px · relaxed tracking',
+ description: 'Medium weight at 14px — softest, most editorial option.',
+ titleStyle: {
+ fontFamily: 'Inter, sans-serif',
+ fontSize: 'var(--font-size-base)',
+ fontWeight: 500,
+ textTransform: 'none',
+ letterSpacing: '0.01em',
+ color: 'var(--color-text-secondary)',
+ },
+ },
+ {
+ id: 8,
+ icon: 'mqtt',
+ iconColor: 'var(--color-tertiary)',
+ label: '8 · Inter 700 · uppercase · tracking-widest · tertiary/aqua icon',
+ description: 'Accent icon in aqua — useful to visually distinguish section types.',
+ titleStyle: {
+ fontFamily: 'Inter, sans-serif',
+ fontSize: '0.6875rem',
+ fontWeight: 700,
+ textTransform: 'uppercase',
+ letterSpacing: '0.12em',
+ color: 'var(--color-text-muted)',
+ },
+ },
+ {
+ id: 9,
+ icon: 'melodies',
+ iconColor: 'var(--color-primary)',
+ label: '9 · Inter 600 · uppercase · 10px · extreme tracking',
+ description: 'Pushed to the limit — very spacious, laser-etched instrument-panel feel.',
+ titleStyle: {
+ fontFamily: 'Inter, sans-serif',
+ fontSize: '0.625rem', /* 10px */
+ fontWeight: 600,
+ textTransform: 'uppercase',
+ letterSpacing: '0.20em',
+ color: 'var(--color-text-muted)',
+ },
+ },
+ {
+ id: 10,
+ icon: 'staff',
+ iconColor: 'var(--color-primary)',
+ label: '10 · Inter 600 · uppercase · 11px · no icon (icon=false)',
+ description: 'No icon — title only. To compare how it looks without the icon.',
+ noIcon: true,
+ titleStyle: {
+ fontFamily: 'Inter, sans-serif',
+ fontSize: '0.6875rem',
+ fontWeight: 600,
+ textTransform: 'uppercase',
+ letterSpacing: '0.12em',
+ color: 'var(--color-text-muted)',
+ },
+ },
+]
+
+// ─── Single specimen card ──────────────────────────────────────────────────────
+
+function SpecimenCard({ s }) {
+ return (
+
+ {/* The card */}
+
+ {/* Header */}
+
+
+ {!s.noIcon && (
+
+ )}
+
+ System Status
+
+
+
+ All services operational
+
+
+
+ {/* Body */}
+
+
+
+
+ {/* Footer */}
+
+
+
+
+
+ {/* Label beneath card */}
+
+ {s.label}
+ {s.description}
+
+
+ )
+}
+
+// ─── Page ─────────────────────────────────────────────────────────────────────
+
+export default function CardFontSample() {
+ // Inter is already loaded via tokens.css (system fallback) but ensure the
+ // Google Fonts version is present for the exact weights we need.
+ useEffect(() => {
+ const existing = document.querySelector('link[data-card-font-sampler]')
+ if (existing) return
+ const link = document.createElement('link')
+ link.rel = 'stylesheet'
+ link.setAttribute('data-card-font-sampler', '1')
+ link.href = 'https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap'
+ document.head.appendChild(link)
+ return () => {
+ const el = document.querySelector('link[data-card-font-sampler]')
+ if (el) document.head.removeChild(el)
+ }
+ }, [])
+
+ return (
+
+ {/* Page header */}
+
+
+
+ Dev Tooling · Card Header Sampler
+
+
+
+ Card Header Style Sampler
+
+
+
+ All 10 cards use Inter — the same font as the stitch reference.
+ Each varies title size, weight, case, letter-spacing, and icon treatment.
+ Pick a number and we'll apply it globally.
+
+
+
+
+
+ {/* Card grid */}
+
+ {specimens.map(s => )}
+
+
+ {/* Footer */}
+
+ /dev/card-fonts
+ BellSystems CP v2 · Internal only
+
+
+ )
+}
diff --git a/frontend/src/pages/dev/StyleGuide.jsx b/frontend/src/pages/dev/StyleGuide.jsx
new file mode 100644
index 0000000..6575dc5
--- /dev/null
+++ b/frontend/src/pages/dev/StyleGuide.jsx
@@ -0,0 +1,2030 @@
+// frontend/src/pages/dev/StyleGuide.jsx
+// Development reference only — showcases every UI component with all variants and states.
+// Not linked from the sidebar. Route: /dev/styleguide (no auth required).
+
+import { useState } from 'react'
+import Button from '@/components/ui/Button'
+import RowActions from '@/components/ui/RowActions'
+import StatusBadge from '@/components/ui/StatusBadge'
+import FormField from '@/components/ui/FormField'
+import Modal from '@/components/ui/Modal'
+import DataTable from '@/components/ui/DataTable'
+import Pagination from '@/components/ui/Pagination'
+import Spinner from '@/components/ui/Spinner'
+import PageHeader from '@/components/ui/PageHeader'
+import { ProfilePageHeader, ProfilePageHeaderActions } from '@/components/ui/ProfilePageHeader'
+import Card from '@/components/ui/Card'
+import Tabs from '@/components/ui/Tabs'
+import { ToastProvider, useToast } from '@/components/ui/Toast'
+import SearchBar from '@/components/ui/SearchBar'
+import Select from '@/components/ui/Select'
+import HeaderSearch from '@/components/ui/HeaderSearch'
+import Breadcrumbs from '@/components/ui/Breadcrumbs'
+import ConfirmDialog from '@/components/ui/ConfirmDialog'
+import Icon, { ICON_NAMES } from '@/components/ui/Icon'
+import PillButton from '@/components/ui/PillButton'
+import SegmentedControl from '@/components/ui/SegmentedControl'
+import IconButtonGroup from '@/components/ui/IconButtonGroup'
+import DateTimePicker from '@/components/ui/DateTimePicker'
+
+// Asset SVG URLs — grouped by folder (loaded at module init, no runtime fetch)
+const _globalIconUrls = import.meta.glob('../../assets/global-icons/*.svg', { query: '?url', import: 'default', eager: true })
+const _sideMenuIconUrls = import.meta.glob('../../assets/side-menu-icons/*.svg', { query: '?url', import: 'default', eager: true })
+const _commsIconUrls = import.meta.glob('../../assets/comms/*.svg', { query: '?url', import: 'default', eager: true })
+const _otherIconUrls = import.meta.glob('../../assets/other-icons/*.svg', { query: '?url', import: 'default', eager: true })
+const _customerStatusUrls = import.meta.glob('../../assets/customer-status/*.svg', { query: '?url', import: 'default', eager: true })
+
+// Helper: { '/path/to/foo-bar.svg': url } → [{ name: 'foo-bar', url }]
+function urlMapToList(map) {
+ return Object.entries(map).map(([path, url]) => {
+ const name = path.split('/').pop().replace(/\.svg$/, '')
+ return { name, url }
+ }).sort((a, b) => a.name.localeCompare(b.name))
+}
+
+const ASSET_ICON_GROUPS = [
+ { title: 'Global Icons', items: urlMapToList(_globalIconUrls) },
+ { title: 'Side Menu Icons', items: urlMapToList(_sideMenuIconUrls) },
+ { title: 'Comms', items: urlMapToList(_commsIconUrls) },
+ { title: 'Other Icons', items: urlMapToList(_otherIconUrls) },
+ { title: 'Customer Status', items: urlMapToList(_customerStatusUrls) },
+]
+
+// ---------------------------------------------------------------------------
+// Layout helpers — local to this page only
+// ---------------------------------------------------------------------------
+
+function Section({ title, id, children }) {
+ return (
+
+
+ {title}
+
+ {children}
+
+ )
+}
+
+function Subsection({ title, children }) {
+ return (
+
+
+ {title}
+
+ {children}
+
+ )
+}
+
+function DemoRow({ children, wrap = false }) {
+ return (
+
+ {children}
+
+ )
+}
+
+function DemoGrid({ cols = 3, children }) {
+ return (
+
+ {children}
+
+ )
+}
+
+function DemoCell({ label, children, centered = false }) {
+ return (
+
+ {children}
+
+ {label}
+
+
+ )
+}
+
+function Swatch({ token, value }) {
+ return (
+
+ )
+}
+
+// ---------------------------------------------------------------------------
+// Demo sub-components (need hooks, so they're standalone components)
+// ---------------------------------------------------------------------------
+
+function DateTimePickerDemo() {
+ const [val, setVal] = useState('')
+ const [val2, setVal2] = useState('2026-03-05T14:30:00.000Z')
+ return (
+
+
+
+
+ {val && (
+
+ ISO: {val}
+
+ )}
+
+
+
+
+
+ {}}
+ error="This field is required"
+ />
+
+
+ {}}
+ disabled
+ />
+
+
+
+ )
+}
+
+function TabsLineDemo() {
+ const [active, setActive] = useState('overview')
+ const tabs = [
+ { key: 'overview', label: 'Overview' },
+ { key: 'financials', label: 'Financials' },
+ { key: 'orders', label: 'Orders' },
+ { key: 'support', label: 'Support' },
+ ]
+ return (
+
+
+
+ Active tab: {active}
+
+
+ )
+}
+
+function TabsPillDemo() {
+ const [active, setActive] = useState('devices')
+ const tabs = [
+ { key: 'devices', label: 'Devices' },
+ { key: 'firmware', label: 'Firmware' },
+ { key: 'mqtt', label: 'MQTT' },
+ ]
+ return
+}
+
+function TabsIconDemo() {
+ const [active, setActive] = useState('customers')
+ const tabs = [
+ {
+ key: 'customers', label: 'Customers', count: 142,
+ icon: ,
+ },
+ {
+ key: 'orders', label: 'Orders', count: 28,
+ icon: ,
+ },
+ {
+ key: 'settings', label: 'Settings',
+ icon: ,
+ },
+ ]
+ return
+}
+
+function ToastDemo() {
+ const { toast } = useToast()
+ return (
+
+ toast.success('Saved!', 'Your changes have been saved successfully.')}>
+ success
+
+ toast.info('Info', 'A new firmware version is available.')}>
+ info
+
+ toast.warning('Warning', 'Disk space is running low on this device.')}>
+ warning
+
+ toast.danger('Error', 'Failed to connect to the device. Check your network.')}>
+ danger
+
+ toast.success('Title only')}>
+ title only
+
+
+ )
+}
+
+function HeaderSearchDemo() {
+ const [value, setValue] = useState('')
+ return
+}
+
+function SearchBarControlledDemo() {
+ const [value, setValue] = useState('Bell Unit')
+ return (
+
+
+
+ Current value: "{value}"
+
+
+ )
+}
+
+function ConfirmDialogDemo() {
+ const [dangerOpen, setDangerOpen] = useState(false)
+ const [primaryOpen, setPrimaryOpen] = useState(false)
+ const [loading, setLoading] = useState(false)
+
+ function handleConfirm(setter) {
+ setLoading(true)
+ setTimeout(() => { setLoading(false); setter(false) }, 1800)
+ }
+
+ return (
+
+
+ setDangerOpen(true)}>
+ Danger variant
+
+ variant="danger"
+
+
+ setPrimaryOpen(true)}>
+ Primary variant
+
+ variant="primary"
+
+
+ setDangerOpen(false)}
+ onConfirm={() => handleConfirm(setDangerOpen)}
+ title="Delete Device"
+ message="This will permanently delete Bell Unit Alpha and all associated data. This action cannot be undone."
+ confirmLabel="Delete"
+ variant="danger"
+ loading={loading}
+ />
+ setPrimaryOpen(false)}
+ onConfirm={() => handleConfirm(setPrimaryOpen)}
+ title="Publish Firmware"
+ message="This will push firmware v2.3.1 to all connected devices in this group. Confirm to proceed."
+ confirmLabel="Publish"
+ variant="primary"
+ loading={loading}
+ />
+
+ )
+}
+
+// ---------------------------------------------------------------------------
+// Sample data for DataTable demos
+// ---------------------------------------------------------------------------
+
+// All columns definition for the interactive column-management demo
+const TABLE_ALL_COLUMNS = [
+ { key: 'name', label: 'Name', sortable: true, alwaysOn: true },
+ { key: 'serial', label: 'Serial', width: '140px' },
+ { key: 'location', label: 'Location', sortable: true },
+ { key: 'status', label: 'Status', width: '120px', render: (row) => {row.status} },
+ { key: 'actions', label: '', width: '110px', align: 'right', render: () => (
+
+ , color: 'var(--color-info)', onClick: () => {} },
+ { label: 'Delete', icon: , color: 'var(--color-danger)', onClick: () => {}, divider: true },
+ ]} />
+
+ ) },
+]
+
+const TABLE_DATA = [
+ { id: 1, name: 'Bell Unit Alpha', serial: 'SN-00001', location: 'London', status: 'Online', statusVariant: 'success' },
+ { id: 2, name: 'Bell Unit Beta', serial: 'SN-00002', location: 'Berlin', status: 'Pending', statusVariant: 'warning' },
+ { id: 3, name: 'Bell Unit Gamma', serial: 'SN-00003', location: 'Athens', status: 'Offline', statusVariant: 'danger' },
+ { id: 4, name: 'Bell Unit Delta', serial: 'SN-00004', location: 'New York', status: 'Info', statusVariant: 'info' },
+]
+
+// Static column list for non-interactive demos
+const TABLE_COLUMNS = TABLE_ALL_COLUMNS
+
+// ---------------------------------------------------------------------------
+// Main style guide
+// ---------------------------------------------------------------------------
+
+export default function StyleGuide() {
+ const [modalOpen, setModalOpen] = useState(false)
+ const [modalSize, setModalSize] = useState('md')
+ const [paginationPage, setPaginationPage] = useState(3)
+ const [paginationSize, setPaginationSize] = useState(20)
+ const [formValues, setFormValues] = useState({ text: '', email: '', select: '', textarea: '' })
+
+ // DataTable interactive demo state
+ const [tableSortKey, setTableSortKey] = useState('')
+ const [tableSortDir, setTableSortDir] = useState('asc')
+ const [tableVisibleKeys, setTableVisibleKeys] = useState(
+ TABLE_ALL_COLUMNS.map((c) => c.key)
+ )
+
+ // Filter bar + table demo state
+ const [filterSearch, setFilterSearch] = useState('')
+ const [filterStatus, setFilterStatus] = useState('')
+ const [filterLocation, setFilterLocation] = useState('')
+ const filteredTableData = TABLE_DATA.filter((row) => {
+ const matchSearch = !filterSearch || row.name.toLowerCase().includes(filterSearch.toLowerCase()) || row.serial.toLowerCase().includes(filterSearch.toLowerCase())
+ const matchStatus = !filterStatus || row.status.toLowerCase() === filterStatus.toLowerCase()
+ const matchLocation = !filterLocation || row.location.toLowerCase() === filterLocation.toLowerCase()
+ return matchSearch && matchStatus && matchLocation
+ })
+ const sortedTableData = tableSortKey
+ ? [...TABLE_DATA].sort((a, b) => {
+ const va = (a[tableSortKey] || '').toString().toLowerCase()
+ const vb = (b[tableSortKey] || '').toString().toLowerCase()
+ if (va < vb) return tableSortDir === 'asc' ? -1 : 1
+ if (va > vb) return tableSortDir === 'asc' ? 1 : -1
+ return 0
+ })
+ : TABLE_DATA
+ const visibleTableColumns = TABLE_ALL_COLUMNS.filter((c) => tableVisibleKeys.includes(c.key))
+
+ const handleFormChange = (name) => (e) => setFormValues((v) => ({ ...v, [name]: e.target.value }))
+
+ return (
+
+ {/* ── Dev banner ───────────────────────────────────────────────────── */}
+
+ ⚠ Development Style Guide — Do not ship in production navigation
+
+
+
+
+ {/* ── Page title ─────────────────────────────────────────────────── */}
+
+
+ BellSystems v2 — Component Style Guide
+
+
+ Every UI component with every variant and state. For development reference only.
+
+
+
+ {/* ── TOC ────────────────────────────────────────────────────────── */}
+
+ {[
+ ['#colors', 'Color Tokens'],
+ ['#typography', 'Typography'],
+ ['#spacing', 'Spacing'],
+ ['#buttons', 'Button'],
+ ['#badges', 'StatusBadge'],
+ ['#formfields', 'FormField'],
+ ['#spinner', 'Spinner'],
+ ['#datatable', 'DataTable'],
+ ['#rowactions', 'RowActions'],
+ ['#pagination', 'Pagination'],
+ ['#pageheader', 'PageHeader'],
+ ['#profileheader', 'ProfilePageHeader'],
+ ['#modal', 'Modal'],
+ ['#card', 'Card'],
+ ['#tabs', 'Tabs'],
+ ['#toast', 'Toast'],
+ ['#searchbar', 'SearchBar'],
+ ['#headersearch', 'HeaderSearch'],
+ ['#breadcrumbs', 'Breadcrumbs'],
+ ['#confirmdialog','ConfirmDialog'],
+ ['#icons', 'Icon'],
+ ].map(([href, label]) => (
+
+ {label}
+
+ ))}
+
+
+ {/* ================================================================
+ 1. COLOR TOKENS
+ ================================================================ */}
+
+
+
+
+ {[
+ '--color-bg-abyss', '--color-bg-base', '--color-bg-void',
+ '--color-bg-surface', '--color-bg-elevated', '--color-bg-island', '--color-bg-float',
+ ].map((t) => )}
+
+
+
+
+
+ {[
+ '--color-primary', '--color-primary-hover', '--color-primary-container',
+ '--color-primary-subtle', '--color-primary-deep', '--color-primary-inverse',
+ ].map((t) => )}
+
+
+
+
+
+ {[
+ '--color-success', '--color-success-bg',
+ '--color-warning', '--color-warning-bg',
+ '--color-danger', '--color-danger-bg',
+ '--color-info', '--color-info-bg',
+ ].map((t) => )}
+
+
+
+
+
+ {['--color-text-primary', '--color-text-secondary', '--color-text-muted', '--color-text-accent'].map(
+ (t) => (
+
+ )
+ )}
+
+
+
+
+
+ {['--color-border', '--color-border-strong', '--color-border-focus'].map((t) => (
+
+ ))}
+
+
+
+
+
+ {/* ================================================================
+ 2. TYPOGRAPHY
+ ================================================================ */}
+
+
+
+ {[
+ { token: '--font-size-2xl', label: '2xl — 56px — Hero KPIs', sample: '1,284' },
+ { token: '--font-size-xl', label: 'xl — 24px — Page Titles', sample: 'Device Inventory' },
+ { token: '--font-size-lg', label: 'lg — 18px — Section Headings', sample: 'Recent Activity' },
+ { token: '--font-size-md', label: 'md — 16px — Card Titles', sample: 'Connection Status' },
+ { token: '--font-size-base',label: 'base — 14px — Body / Table rows', sample: 'Bell Unit Alpha SN-00001' },
+ { token: '--font-size-sm', label: 'sm — 12px — Captions / Helper text', sample: 'Last seen 5 minutes ago' },
+ { token: '--font-size-xs', label: 'xs — 11px — Labels / Chips', sample: 'DEVICE STATUS' },
+ { token: '--font-size-2xs', label: '2xs — 10px — Sidebar section headers / Micro labels', sample: 'BELL CLOUD' },
+ ].map(({ token, label, sample }) => (
+
+
+ {label}
+
+
+ {sample}
+
+
+ ))}
+
+
+
+
+ {[
+ { token: '--font-weight-normal', label: 'normal / 400' },
+ { token: '--font-weight-medium', label: 'medium / 500' },
+ { token: '--font-weight-semibold', label: 'semibold / 600' },
+ { token: '--font-weight-bold', label: 'bold / 700' },
+ ].map(({ token, label }) => (
+
+
+ Hamburgefons
+
+
+ {label}
+
+
+ ))}
+
+
+
+
+
+ {[
+ { token: '--tracking-display', label: '-0.02em — Hero KPI numbers' },
+ { token: '--tracking-tight', label: '-0.01em — Barlow Condensed headings' },
+ { token: '--tracking-normal', label: '0em — Default body text' },
+ { token: '--tracking-snug', label: '0.04em — Sidebar section labels (uppercase)' },
+ { token: '--tracking-wide', label: '0.08em — Chip labels, all-caps badges' },
+ ].map(({ token, label }) => (
+
+
+ Bell Cloud
+
+
+ {token}
+
+
+ {label}
+
+
+ ))}
+
+
+
+
+
+
+
+ --font-family-display — Barlow Condensed
+
+
+ Device Inventory
+
+
+ BellSystems Control Panel v2
+
+
+
+
+ --font-family-base — Onest
+
+
+ Bell Unit Alpha · SN-00042 · Firmware v2.3.1 · Last seen 5 minutes ago via MQTT
+
+
+ 0123456789 · ABCDEFGHIJKLMNOPQRSTUVWXYZ · abcdefghijklmnopqrstuvwxyz
+
+
+
+
+ --font-family-sidebar — Inter · Used for sidebar navigation (section labels, nav items)
+
+
+ Bell Cloud
+
+
+ Devices · Melodies · App Users
+
+
+ Dashboard — active/selected state (white)
+
+
+
+
+
+
+
+ SN-00042 | MAC: AA:BB:CC:DD:EE:FF | fw-v2.3.1-stable
+
+
+
+
+
+ {/* ================================================================
+ 3. SPACING
+ ================================================================ */}
+
+
+ {[1, 2, 3, 4, 5, 6, 8, 10, 12, 16].map((n) => (
+
+
+ --space-{n}
+
+
+
+ {n * 4}px
+
+
+ ))}
+
+
+
+ {/* ================================================================
+ 4. BUTTON
+ ================================================================ */}
+
+
+ {/* ================================================================
+ 5. STATUS BADGE
+ ================================================================ */}
+
+
+
+
+ {['success', 'warning', 'danger', 'info', 'primary', 'neutral'].map((v) => (
+
+ {v}
+ variant="{v}"
+
+ ))}
+
+
+
+
+
+ {['success', 'warning', 'danger', 'info', 'primary', 'neutral'].map((v) => (
+
+ {v}
+ size="sm"
+
+ ))}
+
+
+
+
+
+ {['success', 'warning', 'danger', 'info', 'primary', 'neutral'].map((v) => (
+ {v}
+ ))}
+
+
+
+
+
+ {[
+ ['online', 'success'], ['active', 'success'], ['connected', 'success'],
+ ['sold', 'success'], ['claimed', 'success'], ['provisioned','success'],
+ ['flashed', 'info'], ['manufactured','info'], ['pending', 'warning'],
+ ['offline', 'danger'], ['error', 'danger'], ['inactive', 'danger'],
+ ['open', 'primary'], ['draft', 'neutral'], ['sent', 'info'],
+ ['accepted', 'success'], ['rejected', 'danger'], ['paid', 'success'],
+ ['unpaid', 'warning'], ['overdue', 'danger'], ['cancelled', 'neutral'],
+ ['completed', 'success'], ['processing', 'info'], ['shipped', 'primary'],
+ ['delivered', 'success'], ['returned', 'warning'],
+ ].map(([status, v]) => (
+ {status}
+ ))}
+
+
+
+
+
+ {/* ================================================================
+ 5b. PILL BUTTON
+ ================================================================ */}
+
+
+ {/* ================================================================
+ 6a. SEGMENTED CONTROL
+ ================================================================ */}
+
+
+
+
+ {['secondary', 'primary', 'ghost'].map((v) => (
+
+ {}}
+ options={[
+ { value: 'inbound', label: 'Inbound' },
+ { value: 'outbound', label: 'Outbound' },
+ ]}
+ />
+ variant="{v}"
+
+ ))}
+
+
+
+
+
+ {['sm', 'md', 'lg'].map((s) => (
+
+ {}}
+ options={[
+ { value: 'all', label: 'All' },
+ { value: 'unread', label: 'Unread' },
+ { value: 'read', label: 'Read' },
+ ]}
+ />
+ size="{s}"
+
+ ))}
+
+
+
+
+
+ {}}
+ options={[
+ { value: 'sales', label: 'Sales' },
+ { value: 'support', label: 'Support' },
+ { value: 'both', label: 'All' },
+ ]}
+ />
+ {}}
+ options={[
+ { value: 'all', label: 'All' },
+ { value: 'unread', label: 'Unread' },
+ { value: 'read', label: 'Read' },
+ { value: 'important', label: 'Bookmarked' },
+ ]}
+ />
+
+
+
+
+ {}}
+ options={[
+ { value: 'inbound', label: 'Inbound' },
+ { value: 'outbound', label: 'Outbound' },
+ ]}
+ />
+
+
+
+
+ {/* ================================================================
+ 6b. ICON BUTTON GROUP
+ ================================================================ */}
+
+
+ {/* ================================================================
+ 6. FORM FIELD
+ ================================================================ */}
+
+
+ {/* ================================================================
+ 7. SPINNER
+ ================================================================ */}
+
+
+
+ {['sm', 'md', 'lg'].map((s) => (
+
+
+ size="{s}"
+
+ ))}
+
+
+
+
+ {[
+ ['var(--color-primary)', 'primary'],
+ ['var(--color-success)', 'success'],
+ ['var(--color-warning)', 'warning'],
+ ['var(--color-danger)', 'danger'],
+ ['var(--color-text-muted)', 'muted'],
+ ].map(([color, label]) => (
+
+
+ {label}
+
+ ))}
+
+
+
+
+ {/* ================================================================
+ 8. DATA TABLE
+ ================================================================ */}
+
+
+
+
+ Click a column header to sort. Drag column headers left/right to reorder. Use the grid icon (far right of header) to show/hide columns.
+
+ { setTableSortKey(key); setTableSortDir(dir) }}
+ allColumns={TABLE_ALL_COLUMNS}
+ visibleKeys={tableVisibleKeys}
+ onColumnChange={setTableVisibleKeys}
+ onRowClick={() => {}}
+ />
+
+
+
+
+
+
+
+
+
+
+
+ {}}
+ selectedIds={new Set([2])}
+ />
+
+
+
+
+
+
+
+
+ SearchBar expands to fill remaining space. Filter selects and page-size select are fixed-width with flexShrink: 0. Use flexWrap: 'nowrap' on the row so everything stays on one line.
+
+
+
+
+
+
setFilterStatus(e.target.value)}
+ placeholder="All Statuses"
+ style={{ width: '130px', flexShrink: 0 }}
+ >
+ All Statuses
+ Online
+ Offline
+ Warning
+
+
setFilterLocation(e.target.value)}
+ placeholder="All Locations"
+ style={{ width: '130px', flexShrink: 0 }}
+ >
+ All Locations
+ London
+ Paris
+ Berlin
+ Athens
+
+ {(filterSearch || filterStatus || filterLocation) && (
+
{ setFilterSearch(''); setFilterStatus(''); setFilterLocation('') }}
+ >
+ Clear
+
+ )}
+
+ {}}
+ />
+
+
+
+
+ {/* ================================================================
+ 9. ROW ACTIONS
+ ================================================================ */}
+
+
+
+
+ The locked last column of every table. Click to open the action menu. Closes on any outside click.
+
+
+ , onClick: () => {} },
+ { label: 'Edit', icon: , color: 'var(--color-info)', onClick: () => {} },
+ { label: 'Delete', icon: , color: 'var(--color-danger)', onClick: () => {}, divider: true },
+ ]} />
+ , color: 'var(--color-info)', onClick: () => {} },
+ { label: 'Delete', icon: , color: 'var(--color-danger)', onClick: () => {}, divider: true },
+ ]} />
+ , onClick: () => {} }]} />
+
+
+
+
+
+
+ {/* ================================================================
+ 10. PAGINATION
+ ================================================================ */}
+
+
+ {/* ================================================================
+ 10. PAGE HEADER
+ ================================================================ */}
+
+
+ {/* ================================================================
+ 10b. PROFILE PAGE HEADER
+ ================================================================ */}
+
+
+ {/* ================================================================
+ 11. MODAL
+ ================================================================ */}
+
+
+
+
+ {['sm', 'md', 'lg', 'xl', 'xxl', 'full'].map((size) => (
+ { setModalSize(size); setModalOpen(true) }}
+ >
+ size="{size}"
+
+ ))}
+
+
+ All modals support: Escape to close, backdrop click to close, focus trap, and scroll lock.
+
+
+
+ setModalOpen(false)}
+ title={`Modal — size="${modalSize}"`}
+ size={modalSize}
+ footer={
+
+ setModalOpen(false)}>Cancel
+ setModalOpen(false)}>Confirm
+
+ }
+ >
+
+ This is the modal body. It can contain any content — forms, tables, confirmations.
+
+ {}}
+ hint="Focus is trapped inside the modal while open."
+ />
+
+
+
+
+
+ Confirm dialogs, simple alerts
+
+
+ Standard forms, single-step flows
+
+
+ Complex forms, data selection
+
+
+ Large editors, rich content views
+
+
+ Dashboards, preview panes
+
+
+ Wizards, multi-step flows
+
+
+
+
+
+
+ {/* ================================================================
+ 12. CARD
+ ================================================================ */}
+
+
+
+
+ Body content goes here.
+
+
+ Body content goes here.
+
+
+ Body content goes here.
+
+
+
+
+
+
+
+ View
+ Configure
+ >
+ }
+ >
+ Online
+
+
+
+
+
+
+
+ {r.status} },
+ ]}
+ data={[
+ { id: '#ORD-001', status: 'Paid', sv: 'success' },
+ { id: '#ORD-002', status: 'Pending', sv: 'warning' },
+ { id: '#ORD-003', status: 'Overdue', sv: 'danger' },
+ ]}
+ />
+
+
+
+
+
+ {/* ================================================================
+ 13. TABS
+ ================================================================ */}
+
+
+ {/* ================================================================
+ 14. TOAST
+ ================================================================ */}
+
+
+ {/* ================================================================
+ 15. SEARCH BAR
+ ================================================================ */}
+
+
+
+ void v} />
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* ================================================================
+ 16. HEADER SEARCH
+ ================================================================ */}
+
+
+ {/* ================================================================
+ 17. BREADCRUMBS
+ ================================================================ */}
+
+
+ {/* ================================================================
+ 17. CONFIRM DIALOG
+ ================================================================ */}
+
+
+ {/* ================================================================
+ 18. ICON
+ ================================================================ */}
+
+
+
+ {ICON_NAMES.map((name) => (
+
+
+
+
+
+
+
+
+ ))}
+
+
+
+
+ {[
+ ['var(--color-primary)', 'primary'],
+ ['var(--color-success)', 'success'],
+ ['var(--color-warning)', 'warning'],
+ ['var(--color-danger)', 'danger'],
+ ['var(--color-info)', 'info'],
+ ['var(--color-text-muted)', 'muted'],
+ ].map(([color, label]) => (
+
+
+ {label}
+
+ ))}
+
+
+
+ {/* Asset SVGs from /assets/ — displayed with brightness filter for dark bg */}
+ {ASSET_ICON_GROUPS.map(group => (
+
+
+ {group.items.map(({ name, url }) => (
+
+
+
+ ))}
+
+
+ ))}
+
+
+ {/* ── Masonry Grid ────────────────────────────────────────────────── */}
+
+
+
+ The default layout for all content pages with multiple variable-height sections.
+ Sections fill left-to-right across the top, then each new section drops into the shortest column automatically.
+ Use masonry-grid masonry-grid--N (N = 2, 3, or 4) as a wrapper inside .page-wrapper.
+
+
+
+
+
+
+
+ {['Field one', 'Field two', 'Field three'].map(f => (
+
+ {f}
+
+ ))}
+
+
+
+
+ Field one
+
+
+
+
+ {['Field one', 'Field two'].map(f => (
+
+ {f}
+
+ ))}
+
+
+
+
+
+
+
+ {[
+ { label: 'Section A', rows: 3 },
+ { label: 'Section B', rows: 1 },
+ { label: 'Section C', rows: 4 },
+ { label: 'Section D → col 2 (shortest)', rows: 2, accent: true },
+ { label: 'Section E → col 1 (next shortest)', rows: 1, accent: true },
+ ].map(({ label, rows, accent }) => (
+
+
+ {Array.from({ length: rows }).map((_, i) => (
+
+ ))}
+
+
+ ))}
+
+
+
+
+ {`// Inside .page-wrapper — sections auto-flow into shortest column
+
+ …
+ …
+ … {/* drops into shortest column */}
+
+
+// Available: masonry-grid--2 masonry-grid--3 masonry-grid--4
+// Responsive: collapses to fewer columns at 1024px, single column at 768px`}
+
+
+
+ {/* ── DateTimePicker ─────────────────────────────────────────────── */}
+
+
+
+
+
+ {`import DateTimePicker from '@/components/ui/DateTimePicker'
+
+// value is an ISO string or ''; onChange receives an ISO string
+ setForm(f => ({ ...f, occurred_at: iso }))}
+/>
+
+// With validation error
+ `}
+
+
+
+ {/* ── Footer ─────────────────────────────────────────────────────── */}
+
+
+ BellSystems Control Panel v2 — Style Guide
+
+
+ /dev/styleguide
+
+
+
+
+
+ )
+}
diff --git a/frontend/src/pages/engineering/developer/ApiReferencePage.jsx b/frontend/src/pages/engineering/developer/ApiReferencePage.jsx
new file mode 100644
index 0000000..eccd797
--- /dev/null
+++ b/frontend/src/pages/engineering/developer/ApiReferencePage.jsx
@@ -0,0 +1,733 @@
+// frontend/src/pages/developer/ApiReferencePage.jsx
+// Vesper Firmware API Reference v2 — Command explorer
+
+import { useState, useMemo } from 'react'
+import PageHeader from '@/components/ui/PageHeader'
+import Card from '@/components/ui/Card'
+import SearchBar from '@/components/ui/SearchBar'
+import Tabs from '@/components/ui/Tabs'
+
+// ---------------------------------------------------------------------------
+// Data
+// ---------------------------------------------------------------------------
+
+const TRANSPORTS = [
+ { transport: 'MQTT', address: 'vesper/{device_id}/control', direction: 'Inbound', notes: 'Commands in. Responses go to vesper/{device_id}/data' },
+ { transport: 'MQTT', address: 'vesper/{device_id}/data', direction: 'Outbound', notes: 'Responses + events' },
+ { transport: 'MQTT', address: 'vesper/{device_id}/status/heartbeat', direction: 'Outbound, retained', notes: 'Heartbeat every 30 s' },
+ { transport: 'MQTT', address: 'vesper/{device_id}/status/alerts', direction: 'Outbound, QoS 1', notes: 'Subsystem state changes (WARNING / CRITICAL / FAILED / CLEARED). Published on transition only.' },
+ { transport: 'MQTT', address: 'vesper/{device_id}/status/info', direction: 'Outbound, QoS 0', notes: 'Significant device events (playback start/stop, etc.).' },
+ { transport: 'WebSocket', address: 'ws://{device_ip}/ws', direction: 'Bidirectional', notes: 'Requires identify on connect' },
+ { transport: 'HTTP', address: 'http://{device_ip}/...', direction: 'REST', notes: 'Web console endpoints' },
+ { transport: 'UART', address: 'Hardware serial', direction: 'Bidirectional', notes: 'Restricted — whitelist only' },
+ { transport: 'UDP', address: 'Port 32101', direction: 'Discovery', notes: 'Passive — device announces itself' },
+]
+
+const UART_WHITELIST = ['ping', 'playback', 'clock.pause', 'clock.resume', 'system.get_time']
+
+const NAMESPACES = [
+ {
+ id: 'root',
+ label: 'Root',
+ description: 'Top-level commands — no namespace prefix.',
+ commands: [
+ { cmd: 'ping', handler: 'SystemHandler', transports: ['All'], description: 'Connectivity check. No contents required.', contents: null, returns: '{ "status": "SUCCESS", "type": "pong" }', example: '{ "v": 2, "cmd": "ping" }', warning: null },
+ { cmd: 'identify', handler: 'SystemHandler', transports: ['WebSocket'], description: 'Register client type with the device. Must be sent immediately after connecting on WebSocket or targeted responses will not be received.', contents: [{ field: 'device_type', type: 'string', required: true, notes: '"master" (app/console) or "secondary" (slave board)' }], returns: null, example: '{ "v": 2, "cmd": "identify", "contents": { "device_type": "master" } }', warning: null },
+ { cmd: 'playback', handler: 'PlaybackHandler', transports: ['All'], description: 'Control melody playback. The action field is always required.', contents: [{ field: 'action', type: 'string', required: true, notes: '"play" | "stop" | "pause" | "unpause"' }, { field: 'melody_uid', type: 'string', required: false, notes: 'Required when action is play' }], returns: null, example: '{ "v": 2, "cmd": "playback", "contents": { "action": "play", "melody_uid": "ABC123" } }\n{ "v": 2, "cmd": "playback", "contents": { "action": "stop" } }', warning: null },
+ ],
+ },
+ {
+ id: 'relay',
+ label: 'relay',
+ description: 'Bell relay channel configuration and testing. Handler: RelayHandler.',
+ commands: [
+ { cmd: 'relay.set_durations', handler: 'RelayHandler', transports: ['All'], description: 'Set strike duration (ms) for one or more bell channels. Partial updates accepted — omitted channels are unchanged.', contents: [{ field: 'bells', type: 'object', required: true, notes: 'Map of channel index → duration ms. e.g. { "0": 95, "2": 110 }. At least one entry required.' }], returns: null, example: '{\n "v": 2,\n "cmd": "relay.set_durations",\n "contents": { "bells": { "0": 95, "1": 100, "2": 110 } }\n}', warning: null },
+ { cmd: 'relay.set_outputs', handler: 'RelayHandler', transports: ['All'], description: 'Map bell channels to physical relay outputs. Partial updates accepted.', contents: [{ field: 'bells', type: 'object', required: true, notes: 'Map of channel index → relay output index. e.g. { "0": 2, "1": 3 }' }], returns: null, example: '{\n "v": 2,\n "cmd": "relay.set_outputs",\n "contents": { "bells": { "0": 2, "1": 3, "2": 4 } }\n}', warning: null },
+ { cmd: 'relay.get_config', handler: 'RelayHandler', transports: ['All'], description: 'Read current bell durations and relay output assignments.', contents: null, returns: '{ "durations": { "0": 95, ... }, "outputs": { "0": 2, ... } }', example: '{ "v": 2, "cmd": "relay.get_config" }', warning: null },
+ { cmd: 'relay.test_output', handler: 'RelayHandler', transports: ['All'], description: 'Fire a specific relay for a caller-specified duration. Safety-gated: Player must be stopped.', contents: [{ field: 'output', type: 'int', required: true, notes: 'Physical relay output index' }, { field: 'duration_ms', type: 'int', required: true, notes: 'Duration in ms. No defaults. Typical range: 85–140 ms' }], returns: null, example: '{\n "v": 2,\n "cmd": "relay.test_output",\n "contents": { "output": 2, "duration_ms": 95 }\n}', warning: null },
+ ],
+ },
+ {
+ id: 'clock',
+ label: 'clock',
+ description: 'Clock strike configuration, RTC time, face position, timezone, and silence periods. Handler: ClockHandler.',
+ commands: [
+ { cmd: 'clock.set_outputs', handler: 'ClockHandler', transports: ['All'], description: 'Set which relay outputs the clock strikes use. Partial updates accepted.', contents: [{ field: 'c1', type: 'int', required: false, notes: 'Relay output for the first clock channel' }, { field: 'c2', type: 'int', required: false, notes: 'Relay output for the second clock channel. At least one of c1/c2 required.' }], returns: null, example: '{ "v": 2, "cmd": "clock.set_outputs", "contents": { "c1": 4, "c2": 5 } }', warning: null },
+ { cmd: 'clock.set_timings', handler: 'ClockHandler', transports: ['All'], description: 'Set clock strike timing configuration. Partial updates accepted.', contents: null, returns: null, example: '{ "v": 2, "cmd": "clock.set_timings", "contents": { ... } }', warning: null },
+ { cmd: 'clock.set_alerts', handler: 'ClockHandler', transports: ['All'], description: 'Configure hourly/quarter alert behavior. Partial updates accepted.', contents: null, returns: null, example: '{ "v": 2, "cmd": "clock.set_alerts", "contents": { ... } }', warning: null },
+ { cmd: 'clock.set_backlight', handler: 'ClockHandler', transports: ['All'], description: 'Configure LCD backlight behavior. Partial updates accepted.', contents: null, returns: null, example: '{ "v": 2, "cmd": "clock.set_backlight", "contents": { ... } }', warning: null },
+ { cmd: 'clock.set_silence', handler: 'ClockHandler', transports: ['All'], description: 'Configure silence period (quiet hours). Partial updates accepted.', contents: null, returns: null, example: '{ "v": 2, "cmd": "clock.set_silence", "contents": { ... } }', warning: null },
+ { cmd: 'clock.set_time', handler: 'ClockHandler', transports: ['All'], description: 'Set the RTC time.', contents: [{ field: 'timestamp', type: 'int', required: true, notes: 'Unix epoch timestamp' }, { field: 'timezone_offset', type: 'int', required: false, notes: 'Offset in seconds (e.g. 7200 for UTC+2)' }, { field: 'dst_offset', type: 'int', required: false, notes: 'DST offset in seconds' }], returns: null, example: '{\n "v": 2,\n "cmd": "clock.set_time",\n "contents": { "timestamp": 1740000000, "timezone_offset": 7200 }\n}', warning: null },
+ { cmd: 'clock.set_face', handler: 'ClockHandler', transports: ['All'], description: 'Set the physical analog clock face position.', contents: [{ field: 'hour', type: 'int', required: true, notes: 'Hour hand position (0–11)' }, { field: 'minute', type: 'int', required: true, notes: 'Minute hand position (0–59)' }], returns: null, example: '{ "v": 2, "cmd": "clock.set_face", "contents": { "hour": 10, "minute": 10 } }', warning: null },
+ { cmd: 'clock.set_timezone', handler: 'ClockHandler', transports: ['All'], description: 'Set timezone independently of RTC time.', contents: [{ field: 'gmt_offset_sec', type: 'int', required: true, notes: 'GMT offset in seconds (e.g. 7200 for UTC+2)' }, { field: 'dst_offset_sec', type: 'int', required: false, notes: 'DST offset in seconds' }, { field: 'timezone_name', type: 'string', required: false, notes: 'IANA timezone name, e.g. "Europe/Athens"' }], returns: null, example: '{\n "v": 2,\n "cmd": "clock.set_timezone",\n "contents": { "gmt_offset_sec": 7200, "dst_offset_sec": 3600, "timezone_name": "Europe/Athens" }\n}', warning: null },
+ { cmd: 'clock.get_timezone', handler: 'ClockHandler', transports: ['All'], description: 'Read current timezone settings.', contents: null, returns: '{ "gmt_offset_sec": 7200, "dst_offset_sec": 3600, "timezone_name": "..." }', example: '{ "v": 2, "cmd": "clock.get_timezone" }', warning: null },
+ { cmd: 'clock.sync_ntp', handler: 'ClockHandler', transports: ['All'], description: 'Force an immediate NTP time sync.', contents: null, returns: null, example: '{ "v": 2, "cmd": "clock.sync_ntp" }', warning: null },
+ { cmd: 'clock.enable', handler: 'ClockHandler', transports: ['All'], description: 'Enable clock strike output.', contents: null, returns: null, example: '{ "v": 2, "cmd": "clock.enable" }', warning: null },
+ { cmd: 'clock.disable', handler: 'ClockHandler', transports: ['All'], description: 'Disable clock strike output.', contents: null, returns: null, example: '{ "v": 2, "cmd": "clock.disable" }', warning: null },
+ { cmd: 'clock.pause', handler: 'ClockHandler', transports: ['All'], description: 'Pause clock updates.', contents: null, returns: null, example: '{ "v": 2, "cmd": "clock.pause" }', warning: null },
+ { cmd: 'clock.resume', handler: 'ClockHandler', transports: ['All'], description: 'Resume clock updates.', contents: null, returns: null, example: '{ "v": 2, "cmd": "clock.resume" }', warning: null },
+ { cmd: 'clock.get_face', handler: 'ClockHandler', transports: ['All'], description: 'Read current analog clock face position.', contents: null, returns: '{ "clock_hour", "clock_minute", "last_sync_time", "next_output_is_c1" }', example: '{ "v": 2, "cmd": "clock.get_face" }', warning: null },
+ { cmd: 'clock.get_config', handler: 'ClockHandler', transports: ['All'], description: 'Read full clock configuration — outputs, timings, alerts, backlight, silence, enabled state.', contents: null, returns: null, example: '{ "v": 2, "cmd": "clock.get_config" }', warning: null },
+ ],
+ },
+ {
+ id: 'system',
+ label: 'system',
+ description: 'Device status, settings, health, and control. Handler: SystemHandler.',
+ commands: [
+ { cmd: 'system.status', handler: 'SystemHandler', transports: ['All'], description: 'Get current device status: player state, strike counters, projected run time.', contents: null, returns: null, example: '{ "v": 2, "cmd": "system.status" }', warning: null },
+ { cmd: 'system.get_time', handler: 'SystemHandler', transports: ['All'], description: 'Get current RTC time.', contents: null, returns: '{ "local_timestamp", "utc_timestamp", "year", "month", "day", "hour", "minute", "second", "rtc_available" }', example: '{ "v": 2, "cmd": "system.get_time" }', warning: null },
+ { cmd: 'system.get_settings', handler: 'SystemHandler', transports: ['All'], description: 'Get full ConfigManager JSON dump (all saved settings).', contents: null, returns: null, example: '{ "v": 2, "cmd": "system.get_settings" }', warning: null },
+ { cmd: 'system.get_device_info', handler: 'SystemHandler', transports: ['All'], description: 'Get device identity and runtime stats.', contents: null, returns: '{ "uid", "hw_type", "hw_version", "fw_version", "uptime_ms", "free_heap", "min_free_heap" }', example: '{ "v": 2, "cmd": "system.get_device_info" }', warning: null },
+ { cmd: 'system.health', handler: 'SystemHandler', transports: ['All'], description: 'Get full HealthMonitor report — all subsystem states, warnings, and critical failures.', contents: null, returns: null, example: '{ "v": 2, "cmd": "system.health" }', warning: null },
+ { cmd: 'system.factory_reset', handler: 'SystemHandler', transports: ['All'], description: 'Reset all settings to factory defaults.', contents: null, returns: null, example: '{ "v": 2, "cmd": "system.factory_reset" }', warning: 'Non-reversible. All saved configuration is permanently wiped.' },
+ { cmd: 'system.restart', handler: 'SystemHandler', transports: ['All'], description: 'Reboot the device. Response is sent before reboot (2 s delay).', contents: null, returns: null, example: '{ "v": 2, "cmd": "system.restart" }', warning: null },
+ ],
+ },
+ {
+ id: 'firmware',
+ label: 'firmware',
+ description: 'OTA slot management — validate, commit, and roll back firmware. Handler: FirmwareHandler.',
+ commands: [
+ { cmd: 'firmware.status', handler: 'FirmwareHandler', transports: ['All'], description: 'Get firmware validation state.', contents: null, returns: '{ "validation_state", "version", "boot_count", "can_commit", "can_rollback" }', example: '{ "v": 2, "cmd": "firmware.status" }', warning: null },
+ { cmd: 'firmware.commit', handler: 'FirmwareHandler', transports: ['All'], description: 'Commit the current firmware as permanent (marks OTA slot as valid).', contents: null, returns: null, example: '{ "v": 2, "cmd": "firmware.commit" }', warning: null },
+ { cmd: 'firmware.rollback', handler: 'FirmwareHandler', transports: ['All'], description: 'Roll back to the previous firmware. Device reboots.', contents: null, returns: null, example: '{ "v": 2, "cmd": "firmware.rollback" }', warning: 'Device will reboot immediately after this command.' },
+ ],
+ },
+ {
+ id: 'ota',
+ label: 'ota',
+ description: 'Over-the-air update management. Handler: FirmwareHandler.',
+ commands: [
+ { cmd: 'ota.status', handler: 'FirmwareHandler', transports: ['All'], description: 'Get OTA update status.', contents: null, returns: '{ "current_version", "available_version", "channel", "update_available", "last_check", "progress" }', example: '{ "v": 2, "cmd": "ota.status" }', warning: null },
+ { cmd: 'ota.check', handler: 'FirmwareHandler', transports: ['All'], description: 'Check for available updates.', contents: [{ field: 'channel', type: 'string', required: false, notes: 'Update channel. Default: "stable"' }], returns: null, example: '{ "v": 2, "cmd": "ota.check", "contents": { "channel": "beta" } }', warning: null },
+ { cmd: 'ota.update', handler: 'FirmwareHandler', transports: ['All'], description: 'Trigger OTA update from VPS. Device may reboot after flashing.', contents: [{ field: 'channel', type: 'string', required: false, notes: 'Update channel. Default: "stable"' }], returns: null, example: '{ "v": 2, "cmd": "ota.update", "contents": { "channel": "stable" } }', warning: 'Device may reboot after the update is applied.' },
+ { cmd: 'ota.custom', handler: 'FirmwareHandler', transports: ['All'], description: 'Flash firmware from a custom URL.', contents: [{ field: 'firmware_url', type: 'string', required: true, notes: 'Direct download URL for the firmware binary' }, { field: 'checksum', type: 'string', required: false, notes: 'Optional integrity checksum' }, { field: 'file_size', type: 'int', required: false, notes: 'Optional file size in bytes' }, { field: 'version', type: 'string', required: false, notes: 'Version label for display purposes' }], returns: null, example: '{\n "v": 2,\n "cmd": "ota.custom",\n "contents": {\n "firmware_url": "http://example.com/firmware.bin",\n "checksum": "abc123",\n "version": "142"\n }\n}', warning: 'Device may reboot after the update is applied.' },
+ ],
+ },
+ {
+ id: 'network',
+ label: 'network',
+ description: 'Network configuration and connection status. Handler: NetworkHandler.',
+ commands: [
+ { cmd: 'network.info', handler: 'NetworkHandler', transports: ['All'], description: 'Get IP address, gateway, and DNS.', contents: null, returns: '{ "ip", "gateway", "dns" }', example: '{ "v": 2, "cmd": "network.info" }', warning: null },
+ { cmd: 'network.status', handler: 'NetworkHandler', transports: ['All'], description: 'Get full connection state.', contents: null, returns: '{ "connected", "state", "type", "ip", "rssi", "ap_mode" }', example: '{ "v": 2, "cmd": "network.status" }', warning: null },
+ { cmd: 'network.set_config', handler: 'NetworkHandler', transports: ['All'], description: 'Update network configuration. Partial updates accepted. Returns restart_required flag.', contents: [{ field: 'hostname', type: 'string', required: false, notes: 'Device hostname on the network' }, { field: 'useStaticIP', type: 'bool', required: false, notes: 'Enable static IP mode' }, { field: 'ip', type: 'string', required: false, notes: 'Static IP address. Required together with gateway + subnet when using static IP.' }, { field: 'gateway', type: 'string', required: false, notes: 'Gateway address' }, { field: 'subnet', type: 'string', required: false, notes: 'Subnet mask' }, { field: 'dns1', type: 'string', required: false, notes: 'Primary DNS' }, { field: 'dns2', type: 'string', required: false, notes: 'Secondary DNS' }], returns: '{ "restart_required": true/false }', example: '{\n "v": 2,\n "cmd": "network.set_config",\n "contents": { "hostname": "vesper-lab", "useStaticIP": false }\n}', warning: null },
+ ],
+ },
+ {
+ id: 'files',
+ label: 'files',
+ description: 'Melody file management on the SD card and built-in firmware storage. Handler: FileHandler.',
+ commands: [
+ { cmd: 'files.list', handler: 'FileHandler', transports: ['All'], description: 'List melody files on the SD card.', contents: null, returns: 'Array of SD card melody file objects', example: '{ "v": 2, "cmd": "files.list" }', warning: null },
+ { cmd: 'files.list_builtin', handler: 'FileHandler', transports: ['All'], description: 'List all built-in melodies compiled into the firmware.', contents: null, returns: '[{ "name": "...", "uid": "..." }, ...]', example: '{ "v": 2, "cmd": "files.list_builtin" }', warning: null },
+ { cmd: 'files.download', handler: 'FileHandler', transports: ['All'], description: 'Download a melody from the server to the SD card.', contents: [{ field: 'melodys_uid', type: 'string', required: true, notes: 'UID of the melody to download' }], returns: null, example: '{ "v": 2, "cmd": "files.download", "contents": { "melodys_uid": "ABC123" } }', warning: null },
+ { cmd: 'files.delete', handler: 'FileHandler', transports: ['All'], description: 'Delete a melody file from the SD card.', contents: [{ field: 'name', type: 'string', required: true, notes: 'Filename including extension, e.g. "westminster.mel"' }], returns: null, example: '{ "v": 2, "cmd": "files.delete", "contents": { "name": "westminster.mel" } }', warning: null },
+ ],
+ },
+ {
+ id: 'log',
+ label: 'log',
+ description: 'Log verbosity control per output channel. Level 0 = off, 5 = verbose. Handler: LoggingHandler.',
+ commands: [
+ { cmd: 'log.set_serial', handler: 'LoggingHandler', transports: ['All'], description: 'Set log verbosity for the UART serial output.', contents: [{ field: 'level', type: 'int', required: true, notes: '0 = None, 1 = Error, 2 = Warning, 3 = Info, 4 = Debug, 5 = Verbose' }], returns: null, example: '{ "v": 2, "cmd": "log.set_serial", "contents": { "level": 3 } }', warning: null },
+ { cmd: 'log.set_sd', handler: 'LoggingHandler', transports: ['All'], description: 'Set log verbosity for SD card logging.', contents: [{ field: 'level', type: 'int', required: true, notes: '0 = None, 1 = Error, 2 = Warning, 3 = Info, 4 = Debug, 5 = Verbose' }], returns: null, example: '{ "v": 2, "cmd": "log.set_sd", "contents": { "level": 2 } }', warning: null },
+ { cmd: 'log.set_mqtt', handler: 'LoggingHandler', transports: ['All'], description: 'Set log verbosity for MQTT log publishing.', contents: [{ field: 'level', type: 'int', required: true, notes: '0 = None, 1 = Error, 2 = Warning, 3 = Info, 4 = Debug, 5 = Verbose' }], returns: null, example: '{ "v": 2, "cmd": "log.set_mqtt", "contents": { "level": 1 } }', warning: null },
+ ],
+ },
+ {
+ id: 'mqtt',
+ label: 'mqtt',
+ description: 'MQTT connectivity control. Handler: MQTTHandler.',
+ commands: [
+ { cmd: 'mqtt.enable', handler: 'MQTTHandler', transports: ['All'], description: 'Enable MQTT connectivity.', contents: null, returns: null, example: '{ "v": 2, "cmd": "mqtt.enable" }', warning: null },
+ { cmd: 'mqtt.disable', handler: 'MQTTHandler', transports: ['All'], description: 'Disable MQTT connectivity.', contents: null, returns: null, example: '{ "v": 2, "cmd": "mqtt.disable" }', warning: null },
+ ],
+ },
+ {
+ id: 'telemetry',
+ label: 'telemetry',
+ description: 'Bell strike counters and thermal load monitoring. Handler: TelemetryHandler.',
+ commands: [
+ { cmd: 'telemetry.get_strikes', handler: 'TelemetryHandler', transports: ['All'], description: 'Get strike counts per bell channel.', contents: null, returns: '{ "strikes": { "0": 1234, "1": 567, ... } }', example: '{ "v": 2, "cmd": "telemetry.get_strikes" }', warning: null },
+ { cmd: 'telemetry.reset_strikes', handler: 'TelemetryHandler', transports: ['All'], description: 'Reset strike counters. Omit bell to reset all channels; include it to reset a single channel.', contents: [{ field: 'bell', type: 'int', required: false, notes: 'Channel index. If omitted, all channels are reset.' }], returns: null, example: '{ "v": 2, "cmd": "telemetry.reset_strikes" }\n{ "v": 2, "cmd": "telemetry.reset_strikes", "contents": { "bell": 2 } }', warning: null },
+ { cmd: 'telemetry.get_loads', handler: 'TelemetryHandler', transports: ['All'], description: 'Get heat load per bell channel, cooling status, and overload flags.', contents: null, returns: '{ "loads": { "0": 42.5, ... }, "cooling_active": false, "overloaded": [2, 5] }', example: '{ "v": 2, "cmd": "telemetry.get_loads" }', warning: null },
+ ],
+ },
+]
+
+const LEGACY_MAP = [
+ { v1_cmd: 'ping', v1_action: '—', v2: 'ping' },
+ { v1_cmd: 'identify', v1_action: '—', v2: 'identify' },
+ { v1_cmd: 'playback', v1_action: '—', v2: 'playback' },
+ { v1_cmd: 'file_manager', v1_action: 'list_melodies', v2: 'files.list' },
+ { v1_cmd: 'file_manager', v1_action: 'download_melody', v2: 'files.download' },
+ { v1_cmd: 'file_manager', v1_action: 'delete_melody', v2: 'files.delete' },
+ { v1_cmd: 'relay_setup', v1_action: 'set_timings', v2: 'relay.set_durations' },
+ { v1_cmd: 'relay_setup', v1_action: 'set_outputs', v2: 'relay.set_outputs' },
+ { v1_cmd: 'clock_setup', v1_action: 'set_outputs', v2: 'clock.set_outputs' },
+ { v1_cmd: 'clock_setup', v1_action: 'set_timings', v2: 'clock.set_timings' },
+ { v1_cmd: 'clock_setup', v1_action: 'set_alerts', v2: 'clock.set_alerts' },
+ { v1_cmd: 'clock_setup', v1_action: 'set_backlight', v2: 'clock.set_backlight' },
+ { v1_cmd: 'clock_setup', v1_action: 'set_silence', v2: 'clock.set_silence' },
+ { v1_cmd: 'clock_setup', v1_action: 'set_rtc_time', v2: 'clock.set_time' },
+ { v1_cmd: 'clock_setup', v1_action: 'set_physical_clock_time', v2: 'clock.set_face' },
+ { v1_cmd: 'clock_setup', v1_action: 'pause_clock_updates', v2: 'clock.pause' },
+ { v1_cmd: 'clock_setup', v1_action: 'resume_clock_updates', v2: 'clock.resume' },
+ { v1_cmd: 'clock_setup', v1_action: 'set_enabled (enabled=true)', v2: 'clock.enable' },
+ { v1_cmd: 'clock_setup', v1_action: 'set_enabled (enabled=false)', v2: 'clock.disable' },
+ { v1_cmd: 'system_info', v1_action: 'report_status', v2: 'system.status' },
+ { v1_cmd: 'system_info', v1_action: 'get_device_time', v2: 'system.get_time' },
+ { v1_cmd: 'system_info', v1_action: 'get_clock_time', v2: 'clock.get_face' },
+ { v1_cmd: 'system_info', v1_action: 'get_firmware_status', v2: 'firmware.status' },
+ { v1_cmd: 'system_info', v1_action: 'network_info', v2: 'network.info' },
+ { v1_cmd: 'system_info', v1_action: 'get_full_settings', v2: 'system.get_settings' },
+ { v1_cmd: 'system', v1_action: 'status', v2: 'system.status' },
+ { v1_cmd: 'system', v1_action: 'reset_defaults', v2: 'system.factory_reset' },
+ { v1_cmd: 'system', v1_action: 'commit_firmware', v2: 'firmware.commit' },
+ { v1_cmd: 'system', v1_action: 'rollback_firmware', v2: 'firmware.rollback' },
+ { v1_cmd: 'system', v1_action: 'get_firmware_status', v2: 'firmware.status' },
+ { v1_cmd: 'system', v1_action: 'set_network_config', v2: 'network.set_config' },
+ { v1_cmd: 'system', v1_action: 'set_serial_log_level', v2: 'log.set_serial' },
+ { v1_cmd: 'system', v1_action: 'set_sd_log_level', v2: 'log.set_sd' },
+ { v1_cmd: 'system', v1_action: 'set_mqtt_log_level', v2: 'log.set_mqtt' },
+ { v1_cmd: 'system', v1_action: 'set_mqtt_enabled (enabled=true)', v2: 'mqtt.enable' },
+ { v1_cmd: 'system', v1_action: 'set_mqtt_enabled (enabled=false)', v2: 'mqtt.disable' },
+ { v1_cmd: 'system', v1_action: 'restart', v2: 'system.restart' },
+ { v1_cmd: 'system', v1_action: 'reboot', v2: 'system.restart' },
+ { v1_cmd: 'system', v1_action: 'force_update', v2: 'ota.update' },
+ { v1_cmd: 'system', v1_action: 'custom_update', v2: 'ota.custom' },
+]
+
+// Transport badge styles
+const TRANSPORT_META = {
+ MQTT: { color: 'var(--color-info)', bg: 'var(--color-info-bg)' },
+ WebSocket: { color: 'var(--color-primary)', bg: 'var(--color-primary-subtle)' },
+ HTTP: { color: 'var(--color-success)', bg: 'var(--color-success-bg)' },
+ UART: { color: 'var(--color-warning)', bg: 'var(--color-warning-bg)' },
+ UDP: { color: 'var(--color-info)', bg: 'var(--color-info-bg)' },
+ All: { color: 'var(--color-text-muted)', bg: 'var(--color-bg-island)' },
+}
+
+// ---------------------------------------------------------------------------
+// Sub-components
+// ---------------------------------------------------------------------------
+
+function TransportPill({ name }) {
+ const meta = TRANSPORT_META[name] || TRANSPORT_META.All
+ return (
+
+ {name}
+
+ )
+}
+
+function CodeBlock({ code }) {
+ const [copied, setCopied] = useState(false)
+ const handleCopy = () => {
+ navigator.clipboard.writeText(code).then(() => {
+ setCopied(true)
+ setTimeout(() => setCopied(false), 1800)
+ })
+ }
+ return (
+
+
+ {code}
+
+
+ {copied ? 'Copied' : 'Copy'}
+
+
+ )
+}
+
+function ReturnBlock({ code }) {
+ return (
+
+ {code}
+
+ )
+}
+
+function CommandCard({ command }) {
+ const [open, setOpen] = useState(false)
+ const isUartAllowed = UART_WHITELIST.includes(command.cmd)
+
+ return (
+
+
setOpen(v => !v)}
+ style={{
+ width: '100%', display: 'flex', alignItems: 'center',
+ justifyContent: 'space-between',
+ padding: 'var(--space-3) var(--space-4)',
+ background: 'none', border: 'none', cursor: 'pointer', textAlign: 'left',
+ gap: 'var(--space-3)',
+ }}
+ aria-expanded={open}
+ >
+ {/* Left */}
+
+
+ {command.cmd}
+
+ {command.warning && (
+
+
+ caution
+
+ )}
+
+ {command.description}
+
+
+ {/* Right */}
+
+ {command.transports.map(t =>
)}
+
+
+
+
+
+
+ {open && (
+
+
+ {command.description}
+
+
+ {command.warning && (
+
+ )}
+
+
+ {/* Left: meta + contents + returns */}
+
+ {/* Meta */}
+
+
+
Handler
+
{command.handler}
+
+ {isUartAllowed && (
+
+
UART
+
+
+ whitelisted
+
+
+ )}
+
+
+ {/* Contents */}
+ {command.contents ? (
+
+
+ Contents
+
+
+
+
+
+ {['Field', 'Type', 'Req', 'Notes'].map(h => (
+ {h}
+ ))}
+
+
+
+ {command.contents.map((field, i) => (
+ 0 ? '1px solid var(--color-border)' : 'none', backgroundColor: i % 2 === 1 ? 'rgba(192,193,255,0.015)' : 'transparent' }}>
+ {field.field}
+ {field.type}
+
+ {field.required ? yes : no }
+
+ {field.notes}
+
+ ))}
+
+
+
+
+ ) : (
+
No contents required.
+ )}
+
+ {/* Returns */}
+ {command.returns && (
+
+ )}
+
+
+ {/* Right: example */}
+
+
+
+ )}
+
+ )
+}
+
+function NamespaceSection({ ns, searchQuery }) {
+ const filteredCommands = useMemo(() => {
+ if (!searchQuery) return ns.commands
+ const q = searchQuery.toLowerCase()
+ return ns.commands.filter(c =>
+ c.cmd.toLowerCase().includes(q) ||
+ c.description.toLowerCase().includes(q) ||
+ c.handler.toLowerCase().includes(q)
+ )
+ }, [ns.commands, searchQuery])
+
+ if (searchQuery && filteredCommands.length === 0) return null
+
+ return (
+
+
+
+ {ns.label === 'Root' ? 'root' : ns.label}
+
+
+ {filteredCommands.length}
+
+ {ns.description}
+
+
+ {filteredCommands.map(cmd => )}
+
+
+ )
+}
+
+// ---------------------------------------------------------------------------
+// Tab: Transports
+// ---------------------------------------------------------------------------
+function TransportsTab() {
+ return (
+
+
+
+
+
+
+ {['Transport', 'Address / Topic', 'Direction', 'Notes'].map(h => (
+ {h}
+ ))}
+
+
+
+ {TRANSPORTS.map((row, i) => (
+ 0 ? '1px solid var(--color-border)' : 'none', backgroundColor: i % 2 === 1 ? 'rgba(192,193,255,0.015)' : 'transparent' }}>
+
+ {row.address}
+ {row.direction}
+ {row.notes}
+
+ ))}
+
+
+
+
+
+
+
+ {UART_WHITELIST.map(cmd => (
+
+ {cmd}
+
+ ))}
+
+
+
+
+
+
.",\n "contents": { ... } // optional\n}'} />
+
+ {[
+ { field: 'v', type: 'int', notes: 'Protocol version. Always 2 for v2 firmware.' },
+ { field: 'cmd', type: 'string', notes: 'Command name, e.g. "relay.set_durations"' },
+ { field: 'contents', type: 'object?', notes: 'Optional payload. Omit entirely if not needed.' },
+ ].map(row => (
+
+
+ {row.field}
+ {row.type}
+
+
{row.notes}
+
+ ))}
+
+
+
+
+ )
+}
+
+// ---------------------------------------------------------------------------
+// Tab: Legacy migration
+// ---------------------------------------------------------------------------
+function LegacyTab() {
+ return (
+
+
+
+
+
+
+ {['v1 cmd', 'v1 action', 'v2 command'].map(h => (
+ {h}
+ ))}
+
+
+
+ {LEGACY_MAP.map((row, i) => (
+ 0 ? '1px solid var(--color-border)' : 'none', backgroundColor: i % 2 === 1 ? 'rgba(192,193,255,0.015)' : 'transparent' }}>
+ {row.v1_cmd}
+ {row.v1_action}
+ {row.v2}
+
+ ))}
+
+
+
+
+
+ )
+}
+
+// ---------------------------------------------------------------------------
+// Tab: Commands
+// ---------------------------------------------------------------------------
+function CommandsTab({ activeNamespace, setActiveNamespace, searchQuery, setSearchQuery }) {
+ const totalCmds = NAMESPACES.reduce((acc, ns) => acc + ns.commands.length, 0)
+ const nsTabs = [{ key: 'all', label: 'All', count: totalCmds }, ...NAMESPACES.map(ns => ({ key: ns.id, label: ns.label, count: ns.commands.length }))]
+
+ const visibleNamespaces = useMemo(() => {
+ if (activeNamespace === 'all') return NAMESPACES
+ return NAMESPACES.filter(ns => ns.id === activeNamespace)
+ }, [activeNamespace])
+
+ const noResults = searchQuery && visibleNamespaces.every(ns => {
+ const q = searchQuery.toLowerCase()
+ return ns.commands.filter(c => c.cmd.toLowerCase().includes(q) || c.description.toLowerCase().includes(q) || c.handler.toLowerCase().includes(q)).length === 0
+ })
+
+ return (
+
+ {/* Filters row */}
+
+
+
+
+
+ {nsTabs.map(tab => (
+ setActiveNamespace(tab.key)}
+ style={{
+ display: 'inline-flex', alignItems: 'center', gap: 'var(--space-1)',
+ padding: 'var(--space-1) var(--space-3)',
+ borderRadius: 'var(--radius-md)',
+ fontSize: 'var(--font-size-sm)',
+ fontFamily: 'var(--font-family-mono)',
+ fontWeight: activeNamespace === tab.key ? 'var(--font-weight-semibold)' : 'var(--font-weight-normal)',
+ cursor: 'pointer',
+ border: '1px solid',
+ borderColor: activeNamespace === tab.key ? 'var(--color-primary)' : 'var(--color-border-strong)',
+ backgroundColor: activeNamespace === tab.key ? 'var(--color-primary-subtle)' : 'transparent',
+ color: activeNamespace === tab.key ? 'var(--color-primary)' : 'var(--color-text-muted)',
+ transition: 'all 0.15s ease',
+ }}
+ >
+ {tab.label}
+
+ {tab.count}
+
+
+ ))}
+
+
+
+ {/* Command list */}
+
+ {visibleNamespaces.map(ns =>
)}
+ {noResults && (
+
+ No commands match "{searchQuery}"
+
+ )}
+
+
+ )
+}
+
+// ---------------------------------------------------------------------------
+// Page
+// ---------------------------------------------------------------------------
+export default function ApiReferencePage() {
+ const [activeTab, setActiveTab] = useState('commands')
+ const [activeNamespace, setActiveNamespace] = useState('all')
+ const [searchQuery, setSearchQuery] = useState('')
+
+ const TABS = [
+ { key: 'commands', label: 'Commands' },
+ { key: 'transports', label: 'Transports' },
+ { key: 'legacy', label: 'v1 → v2 Migration' },
+ ]
+
+ const totalCmds = NAMESPACES.reduce((a, n) => a + n.commands.length, 0)
+
+ return (
+ <>
+
+
+
+
+
+ {/* Stats strip */}
+
+ {[
+ { label: 'Namespaces', value: NAMESPACES.length },
+ { label: 'Commands', value: totalCmds },
+ { label: 'Transports', value: 5 },
+ { label: 'Protocol', value: 'v2' },
+ ].map(stat => (
+
+
+ {stat.value}
+
+
+ {stat.label}
+
+
+ ))}
+
+
+
+
+ {activeTab === 'commands' && (
+
+ )}
+ {activeTab === 'transports' &&
}
+ {activeTab === 'legacy' &&
}
+
+ >
+ )
+}
diff --git a/frontend/src/pages/engineering/firmware/FirmwareApiPage.jsx b/frontend/src/pages/engineering/firmware/FirmwareApiPage.jsx
new file mode 100644
index 0000000..cac8a35
--- /dev/null
+++ b/frontend/src/pages/engineering/firmware/FirmwareApiPage.jsx
@@ -0,0 +1,368 @@
+// frontend/src/pages/manufacturing/firmware/FirmwareApiPage.jsx
+// Firmware OTA API reference — rendered as a tab panel inside FirmwareManagerPage
+
+import { useState } from 'react'
+import Tabs from '@/components/ui/Tabs'
+import Card from '@/components/ui/Card'
+
+// ─── Param colour system ──────────────────────────────────────────────────────
+
+const PARAM = {
+ hwType: { color: 'var(--color-danger)', bg: 'var(--color-danger-bg)', label: '{hw_type}', eg: 'vesper_plus · chronos · agnus' },
+ channel: { color: 'var(--color-info)', bg: 'var(--color-info-bg)', label: '{channel}', eg: 'stable · beta · alpha · testing' },
+ version: { color: 'var(--color-success)', bg: 'var(--color-success-bg)', label: '{version}', eg: '1.0 · 2.5.1 · 1.2.3' },
+ identity: { color: 'var(--color-warning)', bg: 'var(--color-warning-bg)', label: 'device_uid / sha256', eg: 'BSVSPR-26C13X-… · a3f1c8…' },
+}
+
+// An inline coloured segment inside a URL path
+function PathParam({ kind }) {
+ const p = PARAM[kind]
+ return (
+
+ {p.label}
+
+ )
+}
+
+// A standalone pill tag (query param or body field)
+function ParamPill({ kind, name }) {
+ const p = PARAM[kind]
+ return (
+
+ {name}
+
+ )
+}
+
+// ─── Endpoint data ────────────────────────────────────────────────────────────
+
+// pathParts: array of { text, plain } | { kind }
+// query / body: array of { name, kind }
+
+const GET_ENDPOINTS = [
+ {
+ label: 'Check for latest version',
+ pathParts: [
+ { text: '/api/firmware/', plain: true },
+ { kind: 'hwType' },
+ { text: '/', plain: true },
+ { kind: 'channel' },
+ { text: '/latest', plain: true },
+ ],
+ query: [
+ { name: '?hw_version', kind: 'version' },
+ { name: '?current_version', kind: 'version' },
+ ],
+ desc: 'Returns metadata for the correct next firmware hop. Pass hw_version and current_version so the server resolves upgrade chains correctly.',
+ },
+ {
+ label: 'Get specific version info',
+ pathParts: [
+ { text: '/api/firmware/', plain: true },
+ { kind: 'hwType' },
+ { text: '/', plain: true },
+ { kind: 'channel' },
+ { text: '/', plain: true },
+ { kind: 'version' },
+ { text: '/info', plain: true },
+ ],
+ desc: 'Returns metadata for a specific version. Used when resolving upgrade chains with min_fw_version constraints.',
+ },
+ {
+ label: 'Get latest changelog',
+ pathParts: [
+ { text: '/api/firmware/', plain: true },
+ { kind: 'hwType' },
+ { text: '/', plain: true },
+ { kind: 'channel' },
+ { text: '/latest/changelog', plain: true },
+ ],
+ desc: 'Returns the full changelog for the latest firmware as plain text. Not served in metadata — for admin/display use only.',
+ },
+ {
+ label: 'Get specific version changelog',
+ pathParts: [
+ { text: '/api/firmware/', plain: true },
+ { kind: 'hwType' },
+ { text: '/', plain: true },
+ { kind: 'channel' },
+ { text: '/', plain: true },
+ { kind: 'version' },
+ { text: '/info/changelog', plain: true },
+ ],
+ desc: 'Returns the full changelog for a specific firmware version as plain text.',
+ },
+ {
+ label: 'Download firmware binary',
+ pathParts: [
+ { text: '/api/firmware/', plain: true },
+ { kind: 'hwType' },
+ { text: '/', plain: true },
+ { kind: 'channel' },
+ { text: '/', plain: true },
+ { kind: 'version' },
+ { text: '/firmware.bin', plain: true },
+ ],
+ desc: 'Streams the raw .bin file. Devices fetch this after confirming the version via /latest or /info.',
+ },
+]
+
+const POST_ENDPOINTS = [
+ {
+ label: 'OTA download event',
+ pathParts: [{ text: '/api/ota/events/download', plain: true }],
+ body: [
+ { name: 'device_uid', kind: 'identity' },
+ { name: 'hw_type', kind: 'hwType' },
+ { name: 'hw_version', kind: 'version' },
+ { name: 'from_version', kind: 'version' },
+ { name: 'to_version', kind: 'version' },
+ { name: 'channel', kind: 'channel' },
+ ],
+ desc: 'Posted when the binary is fully written to the staged partition (before Update.end()). Best-effort — no retry on failure.',
+ },
+ {
+ label: 'OTA flash confirmed',
+ pathParts: [{ text: '/api/ota/events/flash', plain: true }],
+ body: [
+ { name: 'device_uid', kind: 'identity' },
+ { name: 'hw_type', kind: 'hwType' },
+ { name: 'hw_version', kind: 'version' },
+ { name: 'from_version', kind: 'version' },
+ { name: 'to_version', kind: 'version' },
+ { name: 'channel', kind: 'channel' },
+ { name: 'sha256', kind: 'identity' },
+ ],
+ desc: 'Posted after Update.end() succeeds — partition committed, device about to reboot. This is ground truth for fleet version tracking.',
+ },
+]
+
+// ─── Endpoint card ────────────────────────────────────────────────────────────
+
+function EndpointCard({ method, label, pathParts, query, body, desc }) {
+ const isGet = method === 'GET'
+ const methodColor = isGet ? 'var(--color-info)' : 'var(--color-success)'
+ const methodBg = isGet ? 'var(--color-info-bg)' : 'var(--color-success-bg)'
+
+ return (
+
+
+
+ {/* Method badge + label */}
+
+
+ {method}
+
+
+ {label}
+
+
+
+ {/* URL path */}
+
+
+ console.bellsystems.net
+
+ {pathParts.map((p, i) =>
+ p.plain
+ ?
{p.text}
+ :
+ )}
+
+
+ {/* Query params */}
+ {query && query.length > 0 && (
+
+
+ QUERY
+
+ {query.map((q) =>
)}
+
+ )}
+
+ {/* Body fields */}
+ {body && body.length > 0 && (
+
+
+ BODY
+
+ {body.map((f) =>
)}
+
+ )}
+
+ {/* Description */}
+
+ {desc}
+
+
+
+ )
+}
+
+// ─── Legend panel ─────────────────────────────────────────────────────────────
+
+function LegendPanel() {
+ return (
+
+ {Object.entries(PARAM).map(([key, p]) => (
+
+
+
+ {p.label}
+
+
+ {p.eg}
+
+
+
+ ))}
+
+
+ Colour coding is consistent across path segments, query params, and body fields.
+
+
+ )
+}
+
+// ─── Main page ────────────────────────────────────────────────────────────────
+
+const METHOD_TABS = [
+ { key: 'get', label: 'GET' },
+ { key: 'post', label: 'POST' },
+]
+
+export default function FirmwareApiPage() {
+ const [activeMethod, setActiveMethod] = useState('get')
+
+ return (
+
+
+ {/* Notice banner */}
+
+ {/* Lock-open icon */}
+
+
+
+
+
+ All endpoints are unauthenticated — devices call them directly without any API key or session token.
+
+
+
+ {/* Two-column layout — grid spans both the header row and the content row */}
+
+
+ {/* Row 1 left: GET / POST tabs */}
+
+
+ {/* Row 1 right: "Parameter Legend" label — visually sits in the tab bar row */}
+
+
+ Parameter Legend
+
+
+
+ {/* Row 2 left: endpoint cards */}
+
+ {activeMethod === 'get' && GET_ENDPOINTS.map((ep) => )}
+ {activeMethod === 'post' && POST_ENDPOINTS.map((ep) => )}
+
+
+ {/* Row 2 right: legend items (sticky) */}
+
+
+
+
+
+ )
+}
diff --git a/frontend/src/pages/engineering/firmware/FirmwareManager.jsx b/frontend/src/pages/engineering/firmware/FirmwareManager.jsx
new file mode 100644
index 0000000..683aa69
--- /dev/null
+++ b/frontend/src/pages/engineering/firmware/FirmwareManager.jsx
@@ -0,0 +1,495 @@
+// frontend/src/pages/manufacturing/firmware/FirmwareManager.jsx
+// Firmware Files tab — uses DataTable with column reorder, RowActions, Select
+
+import { useState, useEffect, useRef } from 'react'
+import { createPortal } from 'react-dom'
+import api from '@/lib/api'
+import { useAuth } from '@/hooks/useAuth'
+import Button from '@/components/ui/Button'
+import StatusBadge from '@/components/ui/StatusBadge'
+import Spinner from '@/components/ui/Spinner'
+import Modal from '@/components/ui/Modal'
+import FormField from '@/components/ui/FormField'
+import Select from '@/components/ui/Select'
+import ConfirmDialog from '@/components/ui/ConfirmDialog'
+import SearchBar from '@/components/ui/SearchBar'
+import { fmtDateTimeMedium } from '@/lib/formatters'
+import DataTable from '@/components/ui/DataTable'
+import RowActions from '@/components/ui/RowActions'
+
+// ─── Constants ────────────────────────────────────────────────────────────────
+
+const BOARD_TYPES = [
+ { 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' },
+ { value: 'bespoke', label: 'Bespoke' },
+]
+
+const BOARD_TYPE_LABELS = Object.fromEntries(BOARD_TYPES.map((b) => [b.value, b.label]))
+const CHANNELS = ['stable', 'beta', 'alpha', 'testing']
+
+const CHANNEL_VARIANT = { stable: 'success', beta: 'info', alpha: 'warning', testing: 'neutral' }
+const UPDATE_TYPE_VARIANT = { mandatory: 'info', emergency: 'danger', optional: 'neutral' }
+
+// ─── All columns definition ───────────────────────────────────────────────────
+
+const ALL_COLUMNS = [
+ { key: 'hw_type', label: 'Type', sortable: true, alwaysOn: true },
+ { key: 'channel', label: 'Channel', sortable: true },
+ { key: 'version', label: 'Version', sortable: true },
+ { key: 'update_type', label: 'Update Type', sortable: true },
+ { key: 'min_fw', label: 'Min FW' },
+ { key: 'size', label: 'Size' },
+ { key: 'sha256', label: 'SHA-256' },
+ { key: 'notes', label: 'Release Note' },
+ { key: 'uploaded_at', label: 'Uploaded', sortable: true },
+ { key: 'is_latest', label: 'Latest' },
+ { key: '__actions', label: '', align: 'right' },
+]
+
+const DEFAULT_VISIBLE = ['hw_type', 'channel', 'version', 'update_type', 'min_fw', 'size', 'notes', 'uploaded_at', 'is_latest', '__actions']
+const COLS_KEY = 'fw_manager_visible_v2'
+const ORDER_KEY = 'fw_manager_order_v2'
+
+function loadColPrefs() {
+ try {
+ const vis = JSON.parse(localStorage.getItem(COLS_KEY))
+ const order = JSON.parse(localStorage.getItem(ORDER_KEY))
+ return {
+ visible: Array.isArray(vis) ? vis : DEFAULT_VISIBLE,
+ order: Array.isArray(order) ? order : DEFAULT_VISIBLE,
+ }
+ } catch {
+ return { visible: DEFAULT_VISIBLE, order: DEFAULT_VISIBLE }
+ }
+}
+
+// ─── Helpers ──────────────────────────────────────────────────────────────────
+
+function formatBytes(bytes) {
+ if (!bytes) return '—'
+ if (bytes < 1024) return `${bytes} B`
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
+ return `${(bytes / (1024 * 1024)).toFixed(2)} MB`
+}
+
+function formatDate(iso) { return fmtDateTimeMedium(iso) }
+
+// ─── Changelog modal ──────────────────────────────────────────────────────────
+
+function ChangelogModal({ fw, onClose }) {
+ return (
+
+ {fw?.changelog ? (
+
+ {fw.changelog}
+
+ ) : (
+
+ No changelog for this version.
+
+ )}
+
+ )
+}
+
+// ─── Upload / Edit modal ──────────────────────────────────────────────────────
+
+function FirmwareFormModal({ initial, onClose, onSaved }) {
+ const isEdit = !!initial
+
+ const [hwType, setHwType] = useState(initial?.hw_type ?? 'vesper')
+ const [bespokeUid, setBespokeUid] = useState(initial?.bespoke_uid ?? '')
+ const [channel, setChannel] = useState(initial?.channel ?? 'stable')
+ const [version, setVersion] = useState(initial?.version ?? '')
+ const [updateType, setUpdateType] = useState(initial?.update_type ?? 'mandatory')
+ const [minFw, setMinFw] = useState(initial?.min_fw_version ?? '')
+ const [changelog, setChangelog] = useState(initial?.changelog ?? '')
+ const [releaseNote, setReleaseNote] = useState(initial?.release_note ?? '')
+ const [file, setFile] = useState(null)
+ const [uploading, setUploading] = useState(false)
+ const [error, setError] = useState('')
+ const fileInputRef = useRef(null)
+
+ const handleSubmit = async (e) => {
+ e.preventDefault()
+ if (!isEdit && !file) { setError('A .bin file is required.'); return }
+ if (hwType === 'bespoke' && !bespokeUid.trim()) { setError('Bespoke UID is required.'); return }
+ setError(''); setUploading(true)
+ try {
+ const fd = new FormData()
+ if (!isEdit) {
+ fd.append('hw_type', hwType); fd.append('channel', channel)
+ fd.append('version', version); fd.append('update_type', updateType)
+ if (minFw) fd.append('min_fw_version', minFw)
+ fd.append('changelog', changelog); fd.append('release_note', releaseNote)
+ if (hwType === 'bespoke') fd.append('bespoke_uid', bespokeUid.trim())
+ fd.append('file', file)
+ } else {
+ fd.append('channel', channel); fd.append('version', version)
+ fd.append('update_type', updateType); fd.append('min_fw_version', minFw)
+ fd.append('changelog', changelog); fd.append('release_note', releaseNote)
+ if (hwType === 'bespoke') fd.append('bespoke_uid', bespokeUid.trim())
+ if (file) fd.append('file', file)
+ }
+ const token = localStorage.getItem('access_token')
+ const url = isEdit ? `/api/firmware/${initial.id}` : '/api/firmware/upload'
+ const method = isEdit ? 'PUT' : 'POST'
+ const res = await fetch(url, { method, headers: { Authorization: `Bearer ${token}` }, body: fd })
+ if (!res.ok) {
+ const err = await res.json().catch(() => ({}))
+ throw new Error(err.detail || (isEdit ? 'Update failed' : 'Upload failed'))
+ }
+ onSaved()
+ } catch (err) {
+ setError(err.message)
+ } finally {
+ setUploading(false)
+ }
+ }
+
+ const UPDATE_TYPES = [
+ { value: 'mandatory', label: 'Mandatory', desc: 'Auto on reboot', colorVar: 'info' },
+ { value: 'emergency', label: 'Emergency', desc: 'Immediate push', colorVar: 'danger' },
+ { value: 'optional', label: 'Optional', desc: 'User-initiated', colorVar: 'success' },
+ ]
+
+ return (
+
+ Cancel
+
+ {isEdit ? 'Save Changes' : 'Upload Firmware'}
+
+
+ }
+ >
+
+
+ )
+}
+
+// ─── Main component ───────────────────────────────────────────────────────────
+
+export default function FirmwareManager() {
+ const { hasPermission } = useAuth()
+ const canAdd = hasPermission('manufacturing', 'add')
+ const canDelete = hasPermission('manufacturing', 'delete')
+
+ const [firmware, setFirmware] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState('')
+ const [hwTypeFilter, setHwTypeFilter] = useState('')
+ const [channelFilter, setChannelFilter] = useState('')
+ const [search, setSearch] = useState('')
+ const [sortKey, setSortKey] = useState('uploaded_at')
+ const [sortDir, setSortDir] = useState('desc')
+
+ const [colPrefs, setColPrefs] = useState(loadColPrefs)
+
+ const [showUpload, setShowUpload] = useState(false)
+ const [editTarget, setEditTarget] = useState(null)
+ const [deleteTarget, setDeleteTarget] = useState(null)
+ const [deleting, setDeleting] = useState(false)
+ const [changelogFw, setChangelogFw] = useState(null)
+
+ const handleColChange = (nextVisible) => {
+ // nextVisible is the new ordered+visible array from DataTable drag reorder
+ const nextPrefs = { visible: nextVisible, order: nextVisible }
+ setColPrefs(nextPrefs)
+ localStorage.setItem(COLS_KEY, JSON.stringify(nextVisible))
+ localStorage.setItem(ORDER_KEY, JSON.stringify(nextVisible))
+ }
+
+ const fetchFirmware = async () => {
+ setLoading(true); setError('')
+ try {
+ const params = new URLSearchParams()
+ if (hwTypeFilter) params.set('hw_type', hwTypeFilter)
+ if (channelFilter) params.set('channel', channelFilter)
+ const qs = params.toString()
+ const data = await api.get(`/firmware${qs ? `?${qs}` : ''}`)
+ setFirmware(data.firmware)
+ } catch (err) {
+ setError(err.message)
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ useEffect(() => { fetchFirmware() }, [hwTypeFilter, channelFilter])
+
+ const handleDelete = async () => {
+ if (!deleteTarget) return
+ setDeleting(true)
+ try {
+ await api.delete(`/firmware/${deleteTarget.id}`)
+ } catch (err) {
+ setError(err.message)
+ } finally {
+ setDeleteTarget(null); setDeleting(false)
+ await fetchFirmware()
+ }
+ }
+
+ // Filter + search + sort
+ const filteredFirmware = firmware
+ .filter((fw) => {
+ if (!search) return true
+ const q = search.toLowerCase()
+ return (
+ (fw.hw_type || '').toLowerCase().includes(q) ||
+ (fw.channel || '').toLowerCase().includes(q) ||
+ (fw.version || '').toLowerCase().includes(q) ||
+ (fw.update_type || '').toLowerCase().includes(q) ||
+ (fw.changelog || '').toLowerCase().includes(q) ||
+ (fw.release_note || '').toLowerCase().includes(q)
+ )
+ })
+ .sort((a, b) => {
+ const dir = sortDir === 'asc' ? 1 : -1
+ if (sortKey === 'hw_type') return dir * (a.hw_type || '').localeCompare(b.hw_type || '')
+ if (sortKey === 'channel') return dir * (a.channel || '').localeCompare(b.channel || '')
+ if (sortKey === 'version') return dir * (a.version || '').localeCompare(b.version || '')
+ if (sortKey === 'update_type') return dir * (a.update_type || '').localeCompare(b.update_type || '')
+ if (sortKey === 'uploaded_at') return dir * ((a.uploaded_at || '') < (b.uploaded_at || '') ? -1 : 1)
+ return 0
+ })
+
+ // Build columns for DataTable based on current visible order
+ const { visible: visibleKeys } = colPrefs
+ const columns = visibleKeys
+ .map((k) => ALL_COLUMNS.find((c) => c.key === k))
+ .filter(Boolean)
+ .map((col) => ({ ...col, render: (fw) => renderCell(col, fw) }))
+
+ function renderCell(col, fw) {
+ switch (col.key) {
+ case 'hw_type': return (
+
+
+ {BOARD_TYPE_LABELS[fw.hw_type] || fw.hw_type}
+
+ {fw.bespoke_uid && (
+
+ {fw.bespoke_uid}
+
+ )}
+
+ )
+ case 'channel': return {fw.channel}
+ case 'version': return {fw.version}
+ case 'update_type': return {fw.update_type}
+ case 'min_fw': return {fw.min_fw_version || '—'}
+ case 'size': return {formatBytes(fw.size_bytes)}
+ case 'sha256': return (
+
+ {fw.sha256 ? fw.sha256.slice(0, 12) + '…' : '—'}
+
+ )
+ case 'notes': return (
+
+ {fw.release_note || — }
+
+ )
+ case 'uploaded_at': return {formatDate(fw.uploaded_at)}
+ case 'is_latest': return fw.is_latest ? latest : null
+ case '__actions': {
+ const actions = [
+ { label: 'View Changelog', onClick: () => setChangelogFw(fw) },
+ { label: 'Copy .bin URL', onClick: () => {
+ const url = `${window.location.origin}/api/firmware/${fw.hw_type}/${fw.channel}/${fw.version}/firmware.bin`
+ navigator.clipboard.writeText(url).catch(() => {})
+ }},
+ ...(canAdd ? [{ label: 'Edit Release', onClick: () => setEditTarget(fw), divider: true }] : []),
+ ...(canDelete ? [{ label: 'Delete Release', onClick: () => setDeleteTarget(fw), color: 'var(--color-danger)' }] : []),
+ ]
+ return
+ }
+ default: return null
+ }
+ }
+
+ return (
+
+ {/* Toolbar */}
+
+
+ setHwTypeFilter(e.target.value)} placeholder="All Types">
+ All Types
+ {BOARD_TYPES.map((bt) => {bt.label} )}
+
+
+
+ setChannelFilter(e.target.value)} placeholder="All Channels">
+ All Channels
+ {CHANNELS.map((c) => {c} )}
+
+
+
+
+
+
+ {canAdd && (
+
setShowUpload(true)}>
+ + Upload
+
+ )}
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+
{ setSortKey(key); setSortDir(dir) }}
+ allColumns={ALL_COLUMNS.filter((c) => c.key !== '__actions')}
+ visibleKeys={visibleKeys}
+ onColumnChange={handleColChange}
+ />
+
+ {/* Modals */}
+ setChangelogFw(null)} />
+
+ {(showUpload || editTarget) && (
+ { setShowUpload(false); setEditTarget(null) }}
+ onSaved={async () => { setShowUpload(false); setEditTarget(null); await fetchFirmware() }}
+ />
+ )}
+
+ setDeleteTarget(null)}
+ />
+
+ )
+}
diff --git a/frontend/src/pages/engineering/firmware/FirmwareManagerPage.jsx b/frontend/src/pages/engineering/firmware/FirmwareManagerPage.jsx
new file mode 100644
index 0000000..abdd0e6
--- /dev/null
+++ b/frontend/src/pages/engineering/firmware/FirmwareManagerPage.jsx
@@ -0,0 +1,36 @@
+// frontend/src/pages/manufacturing/firmware/FirmwareManagerPage.jsx
+// Firmware Manager — wrapper page with PageHeader and two tabs:
+// "Firmware Files" → FirmwareManager
+// "Flashing Assets" → FlashAssetManager
+
+import { useState } from 'react'
+import PageHeader from '@/components/ui/PageHeader'
+import Tabs from '@/components/ui/Tabs'
+import FirmwareManager from './FirmwareManager'
+import FlashAssetManager from '@/pages/engineering/manufacturing/FlashAssetManager'
+import FirmwareApiPage from './FirmwareApiPage'
+
+const TABS = [
+ { key: 'firmware', label: 'Firmware Files' },
+ { key: 'assets', label: 'Flashing Assets' },
+ { key: 'api', label: 'Firmware API' },
+]
+
+export default function FirmwareManagerPage() {
+ const [activeTab, setActiveTab] = useState('firmware')
+
+ return (
+
+
+
+
+
+ {activeTab === 'firmware' &&
}
+ {activeTab === 'assets' &&
}
+ {activeTab === 'api' &&
}
+
+ )
+}
diff --git a/frontend/src/pages/engineering/manufacturing/DeviceInventory.jsx b/frontend/src/pages/engineering/manufacturing/DeviceInventory.jsx
new file mode 100644
index 0000000..197e965
--- /dev/null
+++ b/frontend/src/pages/engineering/manufacturing/DeviceInventory.jsx
@@ -0,0 +1,465 @@
+// frontend/src/pages/manufacturing/DeviceInventory.jsx
+
+import { useState, useEffect, useCallback, useMemo } from 'react'
+import { useNavigate } from 'react-router-dom'
+import api from '@/lib/api'
+import { useAuth } from '@/hooks/useAuth'
+import PageHeader from '@/components/ui/PageHeader'
+import DataTable from '@/components/ui/DataTable'
+import StatusBadge from '@/components/ui/StatusBadge'
+import Pagination from '@/components/ui/Pagination'
+import SearchBar from '@/components/ui/SearchBar'
+import Select from '@/components/ui/Select'
+import RowActions from '@/components/ui/RowActions'
+import Icon from '@/components/ui/Icon'
+import NewBatchModal from '@/modals/engineering/manufacturing/NewBatchModal'
+import { fmtDateMedium } from '@/lib/formatters'
+import DeleteDeviceModal from '@/modals/engineering/manufacturing/DeleteDeviceModal'
+import DeleteUnprovisionedModal from '@/modals/engineering/manufacturing/DeleteUnprovisionedModal'
+
+// ─── Constants ────────────────────────────────────────────────────────────────
+
+const BOARD_TYPE_LABELS = {
+ vesper: 'Vesper', vesper_plus: 'Vesper+', vesper_pro: 'Vesper Pro',
+ agnus: 'Agnus', agnus_mini: 'Agnus Mini',
+ chronos: 'Chronos', chronos_pro: 'Chronos Pro',
+}
+
+const BOARD_TYPE_OPTIONS = [
+ { value: '', label: 'All Types' },
+ { value: 'vesper_pro', label: 'Vesper Pro' },
+ { value: 'vesper_plus', label: 'Vesper+' },
+ { value: 'vesper', label: 'Vesper' },
+ { value: 'agnus', label: 'Agnus' },
+ { value: 'agnus_mini', label: 'Agnus Mini' },
+ { value: 'chronos_pro', label: 'Chronos Pro' },
+ { value: 'chronos', label: 'Chronos' },
+]
+
+const STATUS_OPTIONS = [
+ { value: '', label: 'All Statuses' },
+ { value: 'manufactured', label: 'Manufactured' },
+ { value: 'flashed', label: 'Flashed' },
+ { value: 'provisioned', label: 'Provisioned' },
+ { value: 'sold', label: 'Sold' },
+ { value: 'claimed', label: 'Claimed' },
+ { value: 'decommissioned', label: 'Decommissioned' },
+]
+
+// Maps mfg_status → StatusBadge variant
+const STATUS_VARIANT = {
+ manufactured: 'neutral',
+ flashed: 'info',
+ provisioned: 'warning',
+ sold: 'success',
+ claimed: 'success',
+ decommissioned: 'danger',
+}
+
+const DEFAULT_PAGE_SIZE = 25
+const PAGE_SIZE_OPTIONS = [25, 50, 100]
+
+const COLUMNS = [
+ { key: 'serial', label: 'Serial Number', defaultOn: true, alwaysOn: true },
+ { key: 'type', label: 'Type', defaultOn: true, sortable: true },
+ { key: 'version', label: 'Revision', defaultOn: true, sortable: true },
+ { key: 'status', label: 'Status', defaultOn: true },
+ { key: 'batch', label: 'Batch', defaultOn: true },
+ { key: 'created', label: 'Created', defaultOn: true, sortable: true },
+ { key: 'customer', label: 'Customer', defaultOn: true, sortable: true },
+ { key: 'name', label: 'Device Name', defaultOn: false, sortable: true },
+]
+
+const COL_PREF_KEY = 'mfg_inventory_cols_v2'
+
+function loadColPrefs() {
+ try {
+ const raw = localStorage.getItem(COL_PREF_KEY)
+ if (raw) return JSON.parse(raw)
+ } catch { /* ignore */ }
+ return COLUMNS.filter((c) => c.defaultOn).map((c) => c.key)
+}
+
+function saveColPrefs(keys) {
+ try { localStorage.setItem(COL_PREF_KEY, JSON.stringify(keys)) } catch { /* ignore */ }
+}
+
+// ─── Helpers ──────────────────────────────────────────────────────────────────
+
+function formatHwVersion(v) {
+ if (!v) return '—'
+ if (/^\d+\.\d+/.test(v)) return `Rev ${v}`
+ const n = parseInt(v, 10)
+ return isNaN(n) ? `Rev ${v}` : `Rev ${n}.0`
+}
+
+function formatDate(iso) { return fmtDateMedium(iso) }
+
+// ─── Stat Card ────────────────────────────────────────────────────────────────
+
+function StatCard({ label, value, accent }) {
+ return (
+
+ )
+}
+
+// ─── Main Component ───────────────────────────────────────────────────────────
+
+export default function DeviceInventory() {
+ const navigate = useNavigate()
+ const { hasPermission } = useAuth()
+ const canDelete = hasPermission('manufacturing', 'delete')
+
+ const [devices, setDevices] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState('')
+ const [search, setSearch] = useState('')
+ const [statusFilter, setStatusFilter] = useState('')
+ const [typeFilter, setTypeFilter] = useState('')
+ const [page, setPage] = useState(1)
+ const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE)
+ const [customerMap, setCustomerMap] = useState({})
+
+ // Sorting
+ const [sortKey, setSortKey] = useState('')
+ const [sortDir, setSortDir] = useState('asc')
+
+ // Column visibility + order
+ const [visibleCols, setVisibleCols] = useState(loadColPrefs)
+
+ // Modals
+ const [showBatch, setShowBatch] = useState(false)
+ const [deleteTarget, setDeleteTarget] = useState(null)
+ const [showDeleteUnprovisioned, setShowDeleteUnprovisioned] = useState(false)
+
+ // ─── Fetch ────────────────────────────────────────────────────────────────
+
+ const fetchDevices = useCallback(async () => {
+ setLoading(true); setError('')
+ try {
+ const params = new URLSearchParams()
+ if (search) params.set('search', search)
+ if (statusFilter) params.set('status', statusFilter)
+ if (typeFilter) params.set('hw_type', typeFilter)
+ params.set('limit', '500')
+ const data = await api.get(`/manufacturing/devices${params.toString() ? `?${params}` : ''}`)
+ const devs = data.devices || []
+ setDevices(devs)
+ setPage(1)
+
+ // Fetch customer names for assigned devices
+ const customerIds = [...new Set(devs.map((d) => d.customer_id).filter(Boolean))]
+ if (customerIds.length) {
+ const entries = await Promise.all(
+ customerIds.map((id) =>
+ api.get(`/manufacturing/customers/${id}`)
+ .then((c) => [id, c])
+ .catch(() => [id, null])
+ )
+ )
+ setCustomerMap(Object.fromEntries(entries))
+ } else {
+ setCustomerMap({})
+ }
+ } catch (err) {
+ setError(err.message)
+ } finally {
+ setLoading(false)
+ }
+ }, [search, statusFilter, typeFilter])
+
+ useEffect(() => { fetchDevices() }, [fetchDevices])
+
+ // ─── Sort ─────────────────────────────────────────────────────────────────
+
+ const handleSort = (key, dir) => {
+ setSortKey(key)
+ setSortDir(dir)
+ setPage(1)
+ }
+
+ // ─── Derived ──────────────────────────────────────────────────────────────
+
+ const sorted = useMemo(() => {
+ if (!sortKey) return devices
+ return [...devices].sort((a, b) => {
+ let av, bv
+ if (sortKey === 'type') { av = BOARD_TYPE_LABELS[a.hw_type] || a.hw_type || ''; bv = BOARD_TYPE_LABELS[b.hw_type] || b.hw_type || '' }
+ else if (sortKey === 'version') { av = a.hw_version || ''; bv = b.hw_version || '' }
+ else if (sortKey === 'created') { av = a.created_at || ''; bv = b.created_at || '' }
+ else if (sortKey === 'customer') {
+ const ca = customerMap[a.customer_id]; const cb = customerMap[b.customer_id]
+ av = ca ? ([ca.name, ca.surname].filter(Boolean).join(' ') || ca.email || '') : ''
+ bv = cb ? ([cb.name, cb.surname].filter(Boolean).join(' ') || cb.email || '') : ''
+ }
+ else if (sortKey === 'name') { av = a.name || ''; bv = b.name || '' }
+ else { av = ''; bv = '' }
+ const cmp = av < bv ? -1 : av > bv ? 1 : 0
+ return sortDir === 'asc' ? cmp : -cmp
+ })
+ }, [devices, sortKey, sortDir, customerMap])
+
+ const paged = sorted.slice((page - 1) * pageSize, page * pageSize)
+
+ // Stats
+ const stats = {
+ total: devices.length,
+ manufactured: devices.filter((d) => d.mfg_status === 'manufactured').length,
+ provisioned: devices.filter((d) => d.mfg_status === 'provisioned').length,
+ sold: devices.filter((d) => ['sold','claimed'].includes(d.mfg_status)).length,
+ }
+
+ // ─── Column management ────────────────────────────────────────────────────
+ // onColumnChange receives either a full ordered array (from drag-reorder)
+ // or a single key string (from checkbox toggle via DataTable's internal toggle).
+ // We normalise both cases here.
+
+ const handleColumnChange = (payload) => {
+ if (Array.isArray(payload)) {
+ // Drag reorder — payload is the new ordered array of visible keys
+ saveColPrefs(payload)
+ setVisibleCols(payload)
+ } else {
+ // Single key toggle
+ const col = COLUMNS.find((c) => c.key === payload)
+ if (col?.alwaysOn) return
+ setVisibleCols((prev) => {
+ const next = prev.includes(payload)
+ ? prev.filter((k) => k !== payload)
+ : [...prev, payload]
+ saveColPrefs(next)
+ return next
+ })
+ }
+ }
+
+ // ─── Table columns definition ─────────────────────────────────────────────
+
+ const allCols = COLUMNS.map((c) => ({ ...c, pickerLabel: c.label }))
+
+ // Define all possible column renderers keyed by column key
+ const COL_DEFS = {
+ serial: {
+ key: 'serial',
+ label: 'Serial Number',
+ render: (d) => (
+
+ {d.serial_number}
+
+ ),
+ },
+ type: {
+ key: 'type',
+ label: 'Type',
+ sortable: true,
+ render: (d) => (
+
+ {BOARD_TYPE_LABELS[d.hw_type] || d.hw_type}
+
+ ),
+ },
+ version: {
+ key: 'version',
+ label: 'Revision',
+ sortable: true,
+ render: (d) => (
+
+ {formatHwVersion(d.hw_version)}
+
+ ),
+ },
+ status: {
+ key: 'status',
+ label: 'Status',
+ render: (d) => (
+
+ {d.mfg_status}
+
+ ),
+ },
+ batch: {
+ key: 'batch',
+ label: 'Batch',
+ render: (d) => (
+
+ {d.batch_id || '—'}
+
+ ),
+ },
+ created: {
+ key: 'created',
+ label: 'Created',
+ sortable: true,
+ render: (d) => (
+
+ {formatDate(d.created_at)}
+
+ ),
+ },
+ customer: {
+ key: 'customer',
+ label: 'Customer',
+ sortable: true,
+ render: (d) => {
+ const c = customerMap[d.customer_id]
+ if (!d.customer_id) return —
+ if (!c) return {d.customer_id}
+ return (
+
+ {[c.name, c.surname].filter(Boolean).join(' ') || c.email || d.customer_id}
+
+ )
+ },
+ },
+ name: {
+ key: 'name',
+ label: 'Device Name',
+ sortable: true,
+ render: (d) => (
+
+ {d.name || '—'}
+
+ ),
+ },
+ }
+
+ const ACTIONS_COL = {
+ key: '_actions',
+ label: '',
+ width: '52px',
+ render: (d) => (
+ , onClick: () => navigate(`/manufacturing/devices/${d.serial_number}`) },
+ ...(canDelete ? [{ label: 'Delete', icon: , color: 'var(--color-danger)', onClick: () => setDeleteTarget(d) }] : []),
+ ]}
+ />
+ ),
+ }
+
+ // Build cols in visibleCols order so drag-reorder is reflected immediately
+ const cols = [
+ ...visibleCols.map((key) => COL_DEFS[key]).filter(Boolean),
+ ACTIONS_COL,
+ ]
+
+ // ─── Render ───────────────────────────────────────────────────────────────
+
+ return (
+
+
+ , onClick: () => setShowBatch(true) },
+ { label: 'Provision New', icon: , divider: true, onClick: () => navigate('/manufacturing/provision') },
+ { label: 'Delete Not Provisioned', icon: , color: 'var(--color-danger)', divider: true, onClick: () => setShowDeleteUnprovisioned(true) },
+ ]}
+ />
+
+
+ {/* Stats strip */}
+
+
+
+
+
+
+
+ {/* Toolbar */}
+
+
+ {/* Error */}
+ {error && (
+
+ {error}
+
+ )}
+
+ {/* Table */}
+
navigate(`/manufacturing/devices/${d.serial_number}`)}
+ emptyMessage="No devices found"
+ emptyDescription={search || statusFilter || typeFilter ? 'Try clearing your filters.' : 'Create your first batch to get started.'}
+ />
+
+ {/* Pagination */}
+ setPage(p)}
+ onSizeChange={(s) => { setPageSize(s); setPage(1) }}
+ pageSizes={PAGE_SIZE_OPTIONS}
+ />
+
+ {/* Modals */}
+ setShowBatch(false)}
+ onCreated={() => fetchDevices()}
+ />
+ setDeleteTarget(null)}
+ onDeleted={() => { setDeleteTarget(null); fetchDevices() }}
+ />
+ setShowDeleteUnprovisioned(false)}
+ onDeleted={() => fetchDevices()}
+ />
+
+ )
+}
diff --git a/frontend/src/pages/engineering/manufacturing/DeviceInventoryDetail.jsx b/frontend/src/pages/engineering/manufacturing/DeviceInventoryDetail.jsx
new file mode 100644
index 0000000..5c3ab14
--- /dev/null
+++ b/frontend/src/pages/engineering/manufacturing/DeviceInventoryDetail.jsx
@@ -0,0 +1,852 @@
+// frontend/src/pages/manufacturing/DeviceInventoryDetail.jsx
+
+import { useState, useEffect, useCallback } from 'react'
+import { useParams, useNavigate } from 'react-router-dom'
+import api from '@/lib/api'
+import { useAuth } from '@/hooks/useAuth'
+import PageHeader from '@/components/ui/PageHeader'
+import Button from '@/components/ui/Button'
+import Card from '@/components/ui/Card'
+import StatusBadge from '@/components/ui/StatusBadge'
+import Spinner from '@/components/ui/Spinner'
+import Icon from '@/components/ui/Icon'
+import Modal from '@/components/ui/Modal'
+import FormField from '@/components/ui/FormField'
+import ConfirmDialog from '@/components/ui/ConfirmDialog'
+import { fmtDateTimeMedium, toDatetimeLocal as toDatetimeLocalFmt } from '@/lib/formatters'
+import DeleteDeviceModal from '@/modals/engineering/manufacturing/DeleteDeviceModal'
+
+// ─── Constants ────────────────────────────────────────────────────────────────
+
+const BOARD_TYPE_LABELS = {
+ vesper: 'Vesper', vesper_plus: 'Vesper+', vesper_pro: 'Vesper Pro',
+ agnus: 'Agnus', agnus_mini: 'Agnus Mini',
+ chronos: 'Chronos', chronos_pro: 'Chronos Pro',
+}
+
+const STATUS_VARIANT = {
+ manufactured: 'neutral', flashed: 'info', provisioned: 'warning',
+ sold: 'success', claimed: 'success', decommissioned: 'danger',
+}
+
+// Each step has CSS var refs for color + per-step hex accents for gradient connectors
+const LIFECYCLE = [
+ { key: 'manufactured', label: 'Manufactured', icon: '🔩', colorVar: 'var(--color-info)', bgVar: 'var(--color-info-bg)', accentHex: '#4da8c8', bgHex: '#1a2e3f' },
+ { key: 'flashed', label: 'Flashed', icon: '⚡', colorVar: 'var(--color-warning)', bgVar: 'var(--color-warning-bg)', accentHex: '#c9a83c', bgHex: '#2e2800' },
+ { key: 'provisioned', label: 'Provisioned', icon: '📡', colorVar: 'var(--color-primary)', bgVar: 'var(--color-primary-subtle)', accentHex: '#c97a28', bgHex: '#2e1a00' },
+ { key: 'sold', label: 'Sold', icon: '📦', colorVar: 'var(--color-success)', bgVar: 'var(--color-success-bg)', accentHex: '#3daa6a', bgHex: '#0e2a1a' },
+ { key: 'claimed', label: 'Claimed', icon: '✅', colorVar: 'var(--color-success)', bgVar: 'var(--color-success-bg)', accentHex: '#22c55e', bgHex: '#0a2416' },
+ { key: 'decommissioned', label: 'Decommissioned', icon: '🗑', colorVar: 'var(--color-danger)', bgVar: 'var(--color-danger-bg)', accentHex: '#ef4444', bgHex: '#2a0a0a' },
+]
+
+const STEP_INDEX = Object.fromEntries(LIFECYCLE.map((s, i) => [s.key, i]))
+
+// ─── Helpers ──────────────────────────────────────────────────────────────────
+
+function formatHwVersion(v) {
+ if (!v) return '—'
+ if (/^\d+\.\d+/.test(v)) return `Rev ${v}`
+ const n = parseInt(v, 10)
+ return isNaN(n) ? `Rev ${v}` : `Rev ${n}.0`
+}
+
+function formatDate(iso) { return fmtDateTimeMedium(iso) }
+function toDatetimeLocal(iso) { return toDatetimeLocalFmt(iso) }
+
+// ─── Field display ────────────────────────────────────────────────────────────
+
+function Field({ label, value, mono = false }) {
+ return (
+
+
+ {label}
+
+
+ {value || '—'}
+
+
+ )
+}
+
+// ─── Customer Search Modal ────────────────────────────────────────────────────
+
+function CustomerSearchModal({ onSelect, onClose }) {
+ const [query, setQuery] = useState('')
+ const [results, setResults] = useState([])
+ const [searching, setSearching] = useState(false)
+
+ const search = useCallback(async (q) => {
+ setSearching(true)
+ try {
+ const data = await api.get(`/manufacturing/customers/search?q=${encodeURIComponent(q)}`)
+ setResults(data.results || [])
+ } catch {
+ setResults([])
+ } finally {
+ setSearching(false)
+ }
+ }, [])
+
+ useEffect(() => {
+ const t = setTimeout(() => search(query), 250)
+ return () => clearTimeout(t)
+ }, [query, search])
+
+ return (
+
+
+
setQuery(e.target.value)}
+ placeholder="Name, email, phone, org…"
+ autoFocus
+ />
+
+ {results.length === 0 ? (
+
+ {searching ? 'Searching…' : query ? 'No customers found.' : 'Type to search customers…'}
+
+ ) : results.map((c) => {
+ const fullName = [c.name, c.surname].filter(Boolean).join(' ') || c.email || c.id
+ return (
+
onSelect(c)}
+ style={{
+ display: 'block', width: '100%', textAlign: 'left',
+ padding: 'var(--space-2) var(--space-3)',
+ backgroundColor: 'var(--color-bg-surface)',
+ borderBottom: '1px solid var(--color-border)',
+ cursor: 'pointer', color: 'var(--color-text-primary)',
+ fontSize: 'var(--font-size-sm)',
+ }}
+ onMouseEnter={(e) => e.currentTarget.style.backgroundColor = 'var(--color-bg-elevated)'}
+ onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'var(--color-bg-surface)'}
+ >
+ {fullName}
+ {c.organization && {c.organization} }
+ {c.city && {c.city} }
+
+ )
+ })}
+
+
+
+ )
+}
+
+// ─── Lifecycle Edit Modal ─────────────────────────────────────────────────────
+
+function LifecycleEditModal({ open, entry, stepMeta, isCurrent, onSave, onDelete, onClose }) {
+ const isNew = !entry
+ const [dateVal, setDateVal] = useState(() => toDatetimeLocal(isNew ? new Date().toISOString() : entry?.date))
+ const [note, setNote] = useState(entry?.note || '')
+ const [saving, setSaving] = useState(false)
+ const [confirmDel, setConfirmDel] = useState(false)
+
+ useEffect(() => {
+ if (open) {
+ setDateVal(toDatetimeLocal(isNew ? new Date().toISOString() : entry?.date))
+ setNote(entry?.note || '')
+ setConfirmDel(false)
+ }
+ }, [open, entry, isNew])
+
+ const handleSave = async () => {
+ setSaving(true)
+ try {
+ const isoDate = dateVal ? new Date(dateVal).toISOString() : new Date().toISOString()
+ await onSave(isoDate, note)
+ onClose()
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ const handleDelete = async () => {
+ setSaving(true)
+ try {
+ await onDelete()
+ onClose()
+ } finally {
+ setSaving(false)
+ setConfirmDel(false)
+ }
+ }
+
+ return (
+
+
+ {!isNew && !isCurrent && (
+ setConfirmDel(true)}>
+ Delete Step
+
+ )}
+
+
+ Cancel
+
+ {isNew ? 'Create' : 'Save'}
+
+
+
+ }
+ >
+
+
setDateVal(e.target.value)}
+ />
+ setNote(e.target.value)}
+ placeholder="Add a note about this step…"
+ maxLength={150}
+ />
+ {note.length > 0 && (
+ 130 ? 'var(--color-warning)' : 'var(--color-text-muted)', marginTop: 'calc(-1 * var(--space-3))' }}>
+ {note.length}/150
+
+ )}
+
+
+ setConfirmDel(false)}
+ />
+
+ )
+}
+
+// ─── Lifecycle Tracker ────────────────────────────────────────────────────────
+
+function LifecycleTracker({ device, canEdit, onStatusChange, onEditEntry, statusError }) {
+ const currentIndex = STEP_INDEX[device.mfg_status] ?? 0
+ const [hoveredIndex, setHoveredIndex] = useState(null)
+
+ const history = device.lifecycle_history || []
+ const historyMap = {}
+ history.forEach((entry) => { historyMap[entry.status_id || entry.status] = entry })
+
+ return (
+
+ {statusError && (
+
+ {statusError}
+
+ )}
+
+
+ {LIFECYCLE.map((step, i) => {
+ const isCurrent = i === currentIndex
+ const isPast = i < currentIndex
+ const isFuture = i > currentIndex
+ const isLast = i === LIFECYCLE.length - 1
+ const entry = historyMap[step.key]
+ const hasData = !!entry
+ const isHovered = hoveredIndex === i
+
+ return (
+
+ {/* Left rail */}
+
+ {/* Step circle */}
+
+ {isPast && (
+
+
+
+
+ )}
+
{step.icon}
+
+
+ {/* Connector line */}
+ {!isLast && (
+
+ )}
+
+
+ {/* Right clickable card */}
+
setHoveredIndex(i)}
+ onMouseLeave={() => setHoveredIndex(null)}
+ onClick={(e) => {
+ if (e.target.closest('[data-edit-btn]')) return
+ if (!canEdit) return
+ onStatusChange(step.key)
+ }}
+ onKeyDown={(e) => {
+ if ((e.key === 'Enter' || e.key === ' ') && canEdit) onStatusChange(step.key)
+ }}
+ style={{
+ flex: 1,
+ marginLeft: 10,
+ marginBottom: isLast ? 0 : 6,
+ padding: '10px 12px',
+ borderRadius: 10,
+ border: `1px solid ${
+ isCurrent
+ ? `${step.accentHex}cc`
+ : isHovered && canEdit
+ ? `${step.accentHex}80`
+ : isPast
+ ? `${step.accentHex}40`
+ : `${step.accentHex}30`
+ }`,
+ backgroundColor: isCurrent
+ ? step.bgVar
+ : isHovered && canEdit
+ ? `${step.bgHex}cc`
+ : isPast
+ ? `${step.bgHex}66`
+ : `${step.bgHex}55`,
+ opacity: isFuture && !isHovered ? 0.5 : isPast && !isHovered ? 0.6 : 1,
+ transition: 'border-color 0.18s, background-color 0.18s, opacity 0.18s',
+ cursor: canEdit ? 'pointer' : 'default',
+ display: 'flex',
+ alignItems: hasData ? 'flex-start' : 'center',
+ justifyContent: 'space-between',
+ gap: 8,
+ minHeight: 44,
+ }}
+ >
+
+ {/* Label row */}
+
+
+ {step.label}
+
+ {isCurrent && (
+ CURRENT
+ )}
+
+
+ {/* Date + set_by */}
+ {hasData && (
+
+ {formatDate(entry.date)}
+ {entry.set_by && · {entry.set_by} }
+
+ )}
+
+ {/* Note */}
+ {entry?.note && (
+
+ "{entry.note}"
+
+ )}
+
+
+ {/* Edit button — visible on hover */}
+ {canEdit && (
+
{
+ e.stopPropagation()
+ onEditEntry(i, entry || null)
+ }}
+ style={{
+ fontSize: '0.58rem',
+ fontWeight: 700,
+ letterSpacing: '0.1em',
+ textTransform: 'uppercase',
+ color: isHovered ? step.colorVar : 'transparent',
+ backgroundColor: 'transparent',
+ border: `1px solid ${isHovered ? `${step.accentHex}55` : 'transparent'}`,
+ borderRadius: 5,
+ padding: '2px 8px',
+ cursor: 'pointer',
+ flexShrink: 0,
+ alignSelf: 'flex-start',
+ transition: 'all 0.15s',
+ pointerEvents: isHovered ? 'auto' : 'none',
+ }}
+ onMouseEnter={(e) => {
+ e.currentTarget.style.backgroundColor = step.bgVar
+ e.currentTarget.style.borderColor = `${step.accentHex}80`
+ }}
+ onMouseLeave={(e) => {
+ e.currentTarget.style.backgroundColor = 'transparent'
+ }}
+ >
+ EDIT
+
+ )}
+
+
+ )
+ })}
+
+
+ )
+}
+
+// ─── Main Page ────────────────────────────────────────────────────────────────
+
+export default function DeviceInventoryDetail() {
+ const { sn } = useParams()
+ const navigate = useNavigate()
+ const { hasPermission } = useAuth()
+ const canEdit = hasPermission('manufacturing', 'edit')
+ const canDelete = hasPermission('manufacturing', 'delete')
+
+ const [device, setDevice] = useState(null)
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState('')
+ const [assignedCustomer, setAssignedCustomer] = useState(null)
+ const [resolvedUsers, setResolvedUsers] = useState([])
+
+ // Assignment
+ const [showCustomerModal, setShowCustomerModal] = useState(false)
+ const [assignSaving, setAssignSaving] = useState(false)
+ const [assignError, setAssignError] = useState('')
+
+ // Status
+ const [statusSaving, setStatusSaving] = useState(false)
+ const [statusError, setStatusError] = useState('')
+
+ // Lifecycle edit modal
+ const [editModalData, setEditModalData] = useState(null) // { stepIndex, entry|null }
+
+ // Delete modal
+ const [showDelete, setShowDelete] = useState(false)
+
+ // ─── Fetch ──────────────────────────────────────────────────────────────
+
+ const loadDevice = useCallback(async () => {
+ setLoading(true); setError('')
+ try {
+ const data = await api.get(`/manufacturing/devices/${sn}`)
+ setDevice(data)
+ if (data.customer_id) {
+ api.get(`/manufacturing/customers/${data.customer_id}`)
+ .then((c) => setAssignedCustomer(c))
+ .catch(() => setAssignedCustomer(null))
+ } else {
+ setAssignedCustomer(null)
+ }
+ if (data.user_list?.length) {
+ Promise.all(
+ data.user_list.map((uid) =>
+ api.get(`/users/${uid}`)
+ .then((u) => ({ uid, display_name: u.display_name || '', email: u.email || '' }))
+ .catch(() => ({ uid, display_name: '', email: '' }))
+ )
+ ).then(setResolvedUsers)
+ } else {
+ setResolvedUsers([])
+ }
+ } catch (err) {
+ setError(err.message)
+ } finally {
+ setLoading(false)
+ }
+ }, [sn])
+
+ useEffect(() => { loadDevice() }, [loadDevice])
+
+ // ─── Status change ───────────────────────────────────────────────────────
+
+ const handleStatusChange = async (newStatus) => {
+ if (newStatus === device?.mfg_status) return
+ setStatusError(''); setStatusSaving(true)
+ try {
+ const updated = await api.request(`/manufacturing/devices/${sn}/status`, {
+ method: 'PATCH',
+ body: JSON.stringify({ status: newStatus }),
+ })
+ setDevice(updated)
+ } catch (err) {
+ setStatusError(err.message)
+ } finally {
+ setStatusSaving(false)
+ }
+ }
+
+ // ─── Customer assignment ─────────────────────────────────────────────────
+
+ const handleSelectCustomer = async (customer) => {
+ setShowCustomerModal(false)
+ setAssignError(''); setAssignSaving(true)
+ try {
+ const updated = await api.request(`/manufacturing/devices/${sn}/assign`, {
+ method: 'POST',
+ body: JSON.stringify({ customer_id: customer.id }),
+ })
+ setDevice(updated)
+ setAssignedCustomer(customer)
+ } catch (err) {
+ setAssignError(err.message)
+ } finally {
+ setAssignSaving(false)
+ }
+ }
+
+ // ─── Lifecycle edit ──────────────────────────────────────────────────────
+
+ const history = device?.lifecycle_history || []
+ const historyIndexMap = {}
+ history.forEach((entry, idx) => { historyIndexMap[entry.status_id || entry.status] = idx })
+
+ const handleSaveLifecycle = async (stepIndex, date, note) => {
+ const stepKey = LIFECYCLE[stepIndex]?.key
+ const arrayIndex = historyIndexMap[stepKey]
+ if (arrayIndex === undefined) {
+ const updated = await api.request(`/manufacturing/devices/${sn}/lifecycle`, {
+ method: 'POST',
+ body: JSON.stringify({ status_id: stepKey, date, note }),
+ })
+ setDevice(updated)
+ } else {
+ const updated = await api.request(`/manufacturing/devices/${sn}/lifecycle`, {
+ method: 'PATCH',
+ body: JSON.stringify({ index: arrayIndex, date, note }),
+ })
+ setDevice(updated)
+ }
+ setEditModalData(null)
+ }
+
+ const handleDeleteLifecycleStep = async (stepIndex) => {
+ const stepKey = LIFECYCLE[stepIndex]?.key
+ const arrayIndex = historyIndexMap[stepKey]
+ if (arrayIndex === undefined) { setEditModalData(null); return }
+ const updated = await api.request(`/manufacturing/devices/${sn}/lifecycle/${arrayIndex}`, {
+ method: 'DELETE',
+ })
+ setDevice(updated)
+ setEditModalData(null)
+ }
+
+ // ─── Render ──────────────────────────────────────────────────────────────
+
+ if (loading) {
+ return (
+
+ )
+ }
+
+ if (error || !device) {
+ return (
+
+
+
{error || 'Device not found.'}
+
navigate('/manufacturing')} style={{ marginTop: 'var(--space-4)' }}>
+ Back to Inventory
+
+
+
+ )
+ }
+
+ const boardLabel = BOARD_TYPE_LABELS[device.hw_type] || device.hw_type
+ const userList = device.user_list || []
+
+ const editStepMeta = editModalData ? LIFECYCLE[editModalData.stepIndex] : null
+ const editIsCurrent = editModalData ? LIFECYCLE[editModalData.stepIndex]?.key === device.mfg_status : false
+
+ return (
+
+
+ {device.serial_number}
+
+ {device.mfg_status}
+
+
+ }
+ subtitle={`${device.name || device.device_name || '—'} · ${boardLabel} · ${formatHwVersion(device.hw_version)}`}
+ >
+ {canEdit && (
+ navigate(`/manufacturing/provision?sn=${sn}`)}>
+ Flash
+
+ )}
+ {canDelete && (
+ setShowDelete(true)}>
+ Delete
+
+ )}
+
+
+
+
+ {/* ── Left column ────────────────────────────────────────────────── */}
+
+
+ {/* Device Information */}
+
+
+
+
+
+
+
+
+
+
+ Document ID
+
+
+ {device.id || '—'}
+
+
+
+
+
+ {/* Assignment */}
+
+ {assignError && (
+
+ {assignError}
+
+ )}
+
+ {/* Customer sub-section */}
+
+
+ Customer
+
+ {device.customer_id ? (
+
{ if (!e.target.closest('[data-reassign-btn]')) navigate(`/crm/customers/${device.customer_id}`) }}
+ onKeyDown={(e) => { if ((e.key === 'Enter' || e.key === ' ') && !e.target.closest('[data-reassign-btn]')) navigate(`/crm/customers/${device.customer_id}`) }}
+ style={{
+ display: 'flex', alignItems: 'center', gap: 'var(--space-3)',
+ padding: 'var(--space-2) var(--space-3)',
+ borderRadius: 'var(--radius-md)',
+ border: '1px solid var(--color-border)',
+ backgroundColor: 'var(--color-bg-surface)',
+ cursor: 'pointer',
+ transition: 'border-color 0.15s',
+ }}
+ onMouseEnter={(e) => e.currentTarget.style.borderColor = 'var(--color-border-strong)'}
+ onMouseLeave={(e) => e.currentTarget.style.borderColor = 'var(--color-border)'}
+ >
+
+ {(assignedCustomer?.name || '?')[0].toUpperCase()}
+
+
+
+ {[assignedCustomer?.name, assignedCustomer?.surname].filter(Boolean).join(' ') || 'Customer'}
+
+ {assignedCustomer?.organization &&
{assignedCustomer.organization}
}
+
+ {canEdit && (
+
{ e.stopPropagation(); setShowCustomerModal(true) }}
+ >
+ Reassign
+
+ )}
+
+ ) : (
+
+
+ No customer assigned.
+
+ {canEdit && (
+
setShowCustomerModal(true)}>
+ + Assign to Customer
+
+ )}
+
+ )}
+
+
+ {/* User Assignment sub-section */}
+
+
+ Assigned Users {userList.length > 0 && `(${userList.length})`}
+
+ {userList.length === 0 ? (
+
+ No users assigned to this device.
+
+ ) : (
+
+ {userList.map((uid) => {
+ const resolved = resolvedUsers.find((u) => u.uid === uid)
+ const displayName = resolved?.display_name || ''
+ const email = resolved?.email || ''
+ const initials = (displayName || email || uid)[0]?.toUpperCase() || 'U'
+ return (
+
navigate(`/users/${uid}`)}
+ onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') navigate(`/users/${uid}`) }}
+ style={{
+ display: 'flex', alignItems: 'center', gap: 'var(--space-3)',
+ padding: 'var(--space-2) var(--space-3)',
+ borderRadius: 'var(--radius-md)',
+ border: '1px solid var(--color-border)',
+ backgroundColor: 'var(--color-bg-surface)',
+ cursor: 'pointer',
+ transition: 'border-color 0.15s',
+ }}
+ onMouseEnter={(e) => e.currentTarget.style.borderColor = 'var(--color-border-strong)'}
+ onMouseLeave={(e) => e.currentTarget.style.borderColor = 'var(--color-border)'}
+ >
+
+ {initials}
+
+
+ {displayName
+ ?
{displayName}
+ :
{uid}
+ }
+ {email &&
{email}
}
+
+
+
+ )
+ })}
+
+ )}
+
+
+
+
+
+ {/* ── Right column: lifecycle ─────────────────────────────────────── */}
+
+ {(statusSaving) && (
+ Saving…
+ )}
+ setEditModalData({ stepIndex, entry })}
+ statusError={statusError}
+ />
+
+
+
+ {/* ── Modals ─────────────────────────────────────────────────────────── */}
+ {showCustomerModal && (
+
setShowCustomerModal(false)}
+ />
+ )}
+
+ {editModalData && editStepMeta && (
+ handleSaveLifecycle(editModalData.stepIndex, date, note)}
+ onDelete={() => handleDeleteLifecycleStep(editModalData.stepIndex)}
+ onClose={() => setEditModalData(null)}
+ />
+ )}
+
+ setShowDelete(false)}
+ onDeleted={() => navigate('/manufacturing')}
+ />
+
+ )
+}
diff --git a/frontend/src/pages/engineering/manufacturing/FlashAssetManager.jsx b/frontend/src/pages/engineering/manufacturing/FlashAssetManager.jsx
new file mode 100644
index 0000000..ae788da
--- /dev/null
+++ b/frontend/src/pages/engineering/manufacturing/FlashAssetManager.jsx
@@ -0,0 +1,458 @@
+// frontend/src/pages/manufacturing/firmware/FlashAssetManager.jsx
+
+import { useState, useEffect, useCallback } from 'react'
+import api from '@/lib/api'
+import { useAuth } from '@/hooks/useAuth'
+import PageHeader from '@/components/ui/PageHeader'
+import Button from '@/components/ui/Button'
+import StatusBadge from '@/components/ui/StatusBadge'
+import Spinner from '@/components/ui/Spinner'
+import Icon from '@/components/ui/Icon'
+import Modal from '@/components/ui/Modal'
+import FormField from '@/components/ui/FormField'
+import ConfirmDialog from '@/components/ui/ConfirmDialog'
+import UploadAssetModal from '@/modals/engineering/manufacturing/UploadAssetModal'
+import { fmtDateTimeMedium } from '@/lib/formatters'
+
+// ─── Constants ────────────────────────────────────────────────────────────────
+
+const KNOWN_BOARD_TYPES = [
+ { value: 'vesper', label: 'Vesper', family: 'vesper' },
+ { value: 'vesper_plus', label: 'Vesper+', family: 'vesper' },
+ { value: 'vesper_pro', label: 'Vesper Pro', family: 'vesper' },
+ { value: 'agnus', label: 'Agnus', family: 'agnus' },
+ { value: 'agnus_mini', label: 'Agnus Mini', family: 'agnus' },
+ { value: 'chronos', label: 'Chronos', family: 'chronos' },
+ { value: 'chronos_pro', label: 'Chronos Pro', family: 'chronos' },
+]
+
+const FAMILY_ACCENT = {
+ vesper: 'var(--color-info)',
+ agnus: 'var(--color-warning)',
+ chronos: 'var(--color-danger)',
+ bespoke: 'var(--color-primary)',
+}
+
+const BOARD_LABELS = Object.fromEntries(KNOWN_BOARD_TYPES.map((b) => [b.value, b.label]))
+
+// ─── Helpers ──────────────────────────────────────────────────────────────────
+
+function formatBytes(bytes) {
+ if (!bytes) return '—'
+ if (bytes < 1024) return `${bytes} B`
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
+ return `${(bytes / (1024 * 1024)).toFixed(2)} MB`
+}
+
+function formatDate(iso) { return fmtDateTimeMedium(iso) }
+
+function boardLabel(hwType, isBespoke) {
+ if (isBespoke) return hwType
+ return BOARD_LABELS[hwType] || hwType
+}
+
+function familyOf(hwType, isBespoke) {
+ if (isBespoke) return 'bespoke'
+ return KNOWN_BOARD_TYPES.find((b) => b.value === hwType)?.family ?? 'vesper'
+}
+
+// ─── Note Modal ───────────────────────────────────────────────────────────────
+
+function NoteModal({ open, hwType, currentNote, onClose, onSaved }) {
+ const [note, setNote] = useState(currentNote || '')
+ const [saving, setSaving] = useState(false)
+ const [error, setError] = useState('')
+
+ const handleSave = async () => {
+ setSaving(true); setError('')
+ try {
+ await api.put(`/manufacturing/flash-assets/${hwType}/note`, { note })
+ onSaved(note)
+ onClose()
+ } catch (err) {
+ setError(err.message || 'Failed to save note')
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ return (
+
+ Cancel
+ Save Note
+
+ }
+ >
+
+
+ Board: {hwType}
+ {' '}— stored alongside binaries as{' '}
+ note.txt
+
+ {error && (
+
+ {error}
+
+ )}
+
setNote(e.target.value)}
+ placeholder="e.g. Built from PlatformIO env:vesper_plus_v2, commit abc1234…"
+ hint="Leave blank to clear the note."
+ />
+
+
+ )
+}
+
+// ─── Asset Pill ───────────────────────────────────────────────────────────────
+
+function AssetPill({ label, info, canDelete, onUpload, onDelete }) {
+ const exists = info?.exists
+ return (
+
+
+
+
+ {label}
+
+ {exists ? (
+
+ {formatBytes(info.size_bytes)}{info.uploaded_at ? ` · ${formatDate(info.uploaded_at)}` : ''}
+
+ ) : (
+
Not uploaded
+ )}
+
+
+
+
+
+ {exists && canDelete && (
+
+
+
+ )}
+
+
+ )
+}
+
+// ─── Board Card ───────────────────────────────────────────────────────────────
+
+function BoardCard({ entry, canDelete, canEdit, onUpload, onDelete, onNote, onDeleteNote }) {
+ const { hw_type, bootloader, partitions, note, is_bespoke } = entry
+ const family = familyOf(hw_type, is_bespoke)
+ const accent = FAMILY_ACCENT[family] || 'var(--color-primary)'
+ const label = boardLabel(hw_type, is_bespoke)
+
+ const hasBootloader = bootloader?.exists
+ const hasPartitions = partitions?.exists
+ const hasAll = hasBootloader && hasPartitions
+ const hasNone = !hasBootloader && !hasPartitions
+ const readyVariant = hasAll ? 'success' : hasNone ? 'danger' : 'warning'
+ const readyLabel = hasAll ? 'Ready' : hasNone ? 'Missing' : 'Partial'
+
+ const [noteHovered, setNoteHovered] = useState(false)
+
+ return (
+
+ {/* Header */}
+
+
+
+ {label}
+
+ {is_bespoke && bespoke }
+
+ {hw_type}
+
+
+
+ {readyLabel}
+ {canEdit && (
+ onNote(entry)}>
+
+ {note ? 'Edit note' : 'Add note'}
+
+ )}
+
+
+
+ {/* Asset pills */}
+
+
onUpload(hw_type, 'bootloader.bin')}
+ onDelete={() => onDelete(hw_type, 'bootloader.bin')} />
+ onUpload(hw_type, 'partitions.bin')}
+ onDelete={() => onDelete(hw_type, 'partitions.bin')} />
+
+
+ {/* Note — shown below the files, hover-X to delete */}
+ {note && (
+
setNoteHovered(true)}
+ onMouseLeave={() => setNoteHovered(false)}
+ style={{
+ padding: 'var(--space-2) var(--space-4)',
+ backgroundColor: 'var(--color-bg-elevated)',
+ borderTop: '1px solid var(--color-border)',
+ display: 'flex', alignItems: 'flex-start', gap: 'var(--space-2)',
+ }}
+ >
+
+ {note}
+
+ {canEdit && noteHovered && (
+
onDeleteNote(entry)}
+ style={{
+ flexShrink: 0, background: 'none', border: 'none', cursor: 'pointer',
+ color: 'var(--color-text-muted)', padding: 'var(--space-1)',
+ borderRadius: 'var(--radius-sm)', lineHeight: 1,
+ display: 'flex', alignItems: 'center',
+ }}
+ onMouseEnter={(e) => { e.currentTarget.style.color = 'var(--color-danger)' }}
+ onMouseLeave={(e) => { e.currentTarget.style.color = 'var(--color-text-muted)' }}
+ >
+
+
+
+
+ )}
+
+ )}
+
+ )
+}
+
+// ─── Main Page ────────────────────────────────────────────────────────────────
+
+export default function FlashAssetManager({ embedded = false }) {
+ const { hasPermission } = useAuth()
+ const canDelete = hasPermission('manufacturing', 'delete')
+ const canEdit = hasPermission('manufacturing', 'edit')
+
+ const [assets, setAssets] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState('')
+ const [filter, setFilter] = useState('all')
+
+ const [uploadTarget, setUploadTarget] = useState(null)
+ const [deleteTarget, setDeleteTarget] = useState(null)
+ const [noteTarget, setNoteTarget] = useState(null)
+ const [noteDelTarget, setNoteDelTarget] = useState(null)
+ const [showConfirmDel, setShowConfirmDel] = useState(false)
+
+ const fetchAssets = useCallback(async () => {
+ setLoading(true); setError('')
+ try {
+ const data = await api.get('/manufacturing/flash-assets')
+ setAssets(data.assets || [])
+ } catch (err) {
+ setError(err.message || 'Failed to load flash assets')
+ } finally {
+ setLoading(false)
+ }
+ }, [])
+
+ useEffect(() => { fetchAssets() }, [fetchAssets])
+
+ const handleDelete = async () => {
+ if (!deleteTarget) return
+ try {
+ const token = localStorage.getItem('access_token')
+ const res = await fetch(`/api/manufacturing/flash-assets/${deleteTarget.hwType}/${deleteTarget.assetName}`, {
+ method: 'DELETE',
+ headers: { Authorization: `Bearer ${token}` },
+ })
+ if (!res.ok) throw new Error('Delete failed')
+ setShowConfirmDel(false); setDeleteTarget(null)
+ fetchAssets()
+ } catch (err) {
+ setError(err.message)
+ }
+ }
+
+ const handleNoteUpdate = (hwType, newNote) => {
+ setAssets((prev) => prev.map((a) => a.hw_type === hwType ? { ...a, note: newNote } : a))
+ }
+
+ const handleDeleteNote = async (entry) => {
+ try {
+ await api.put(`/manufacturing/flash-assets/${entry.hw_type}/note`, { note: '' })
+ handleNoteUpdate(entry.hw_type, '')
+ } catch (err) {
+ setError(err.message || 'Failed to delete note')
+ } finally {
+ setNoteDelTarget(null)
+ }
+ }
+
+ const filteredAssets = assets.filter((e) => {
+ const hasAll = e.bootloader?.exists && e.partitions?.exists
+ const hasNone = !e.bootloader?.exists && !e.partitions?.exists
+ if (filter === 'ready') return hasAll
+ if (filter === 'missing') return hasNone
+ if (filter === 'partial') return !hasAll && !hasNone
+ return true
+ })
+
+ const readyCount = assets.filter((e) => e.bootloader?.exists && e.partitions?.exists).length
+ const missingCount = assets.filter((e) => !e.bootloader?.exists && !e.partitions?.exists).length
+ const partialCount = assets.length - readyCount - missingCount
+
+ const FILTER_OPTIONS = [
+ { value: 'all', label: `All (${assets.length})` },
+ { value: 'ready', label: `Ready (${readyCount})` },
+ { value: 'partial', label: `Partial (${partialCount})` },
+ { value: 'missing', label: `Missing (${missingCount})` },
+ ]
+
+ const content = (
+
+
+
+ {[
+ { label: 'Total', value: assets.length, variant: 'neutral' },
+ { label: 'Ready', value: readyCount, variant: 'success' },
+ { label: 'Partial', value: partialCount, variant: 'warning' },
+ { label: 'Missing', value: missingCount, variant: 'danger' },
+ ].map(({ label, value, variant }) => (
+
+
+ {label}
+
+ {value}
+
+ ))}
+
+
+
+ Refresh
+
+
+
+
+ {FILTER_OPTIONS.map(({ value, label }) => (
+ setFilter(value)}>
+ {label}
+
+ ))}
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+ {loading ? (
+
+
+
+ ) : filteredAssets.length === 0 ? (
+
+
+
+ No assets match the current filter.
+
+
+ ) : (
+
+ {filteredAssets.map((entry) => (
+ setUploadTarget({ hwType, assetName })}
+ onDelete={(hwType, assetName) => { setDeleteTarget({ hwType, assetName }); setShowConfirmDel(true) }}
+ onNote={(e) => setNoteTarget(e)}
+ onDeleteNote={(e) => setNoteDelTarget(e)}
+ />
+ ))}
+
+ )}
+
+
setUploadTarget(null)}
+ onSaved={() => { setUploadTarget(null); fetchAssets() }}
+ />
+ setNoteTarget(null)}
+ onSaved={(newNote) => { if (noteTarget) handleNoteUpdate(noteTarget.hw_type, newNote); setNoteTarget(null) }}
+ />
+ handleDeleteNote(noteDelTarget)}
+ onCancel={() => setNoteDelTarget(null)}
+ />
+ { setShowConfirmDel(false); setDeleteTarget(null) }}
+ />
+
+ )
+
+ if (embedded) return content
+
+ return (
+
+ )
+}
diff --git a/frontend/src/pages/engineering/manufacturing/ProvisioningWizard.jsx b/frontend/src/pages/engineering/manufacturing/ProvisioningWizard.jsx
new file mode 100644
index 0000000..45267b6
--- /dev/null
+++ b/frontend/src/pages/engineering/manufacturing/ProvisioningWizard.jsx
@@ -0,0 +1,1411 @@
+// frontend/src/pages/manufacturing/ProvisioningWizard.jsx
+// Provisions an ESP32 board via WebSerial + esptool-js.
+// Steps: 0=Mode 1=Select/Create device 2=Flash 3=Verify 4=Done
+
+import { useState, useRef, useCallback, useEffect } from 'react'
+import { useNavigate, useSearchParams } from 'react-router-dom'
+import { ESPLoader, Transport } from 'esptool-js'
+import api from '@/lib/api'
+import PageHeader from '@/components/ui/PageHeader'
+import Button from '@/components/ui/Button'
+import Modal from '@/components/ui/Modal'
+import StatusBadge from '@/components/ui/StatusBadge'
+import SearchBar from '@/components/ui/SearchBar'
+import Spinner from '@/components/ui/Spinner'
+import Icon from '@/components/ui/Icon'
+
+// ─── Constants ────────────────────────────────────────────────────────────────
+
+const BOARD_TYPES = [
+ { value: 'vesper_pro', name: 'VESPER PRO', uiName: 'Vesper Pro', codename: 'vesper-pro', desc: 'Full-featured pro controller', family: 'vesper' },
+ { value: 'vesper_plus', name: 'VESPER PLUS', uiName: 'Vesper Plus', codename: 'vesper-plus', desc: 'Extended output controller', family: 'vesper' },
+ { value: 'vesper', name: 'VESPER', uiName: 'Vesper', codename: 'vesper-basic', desc: 'Standard bell controller', family: 'vesper' },
+ { value: 'agnus', name: 'AGNUS', uiName: 'Agnus', codename: 'agnus-basic', desc: 'Standard carillon module', family: 'agnus' },
+ { value: 'agnus_mini', name: 'AGNUS MINI', uiName: 'Agnus Mini', codename: 'agnus-mini', desc: 'Compact carillon module', family: 'agnus' },
+ { value: 'chronos_pro', name: 'CHRONOS PRO', uiName: 'Chronos Pro', codename: 'chronos-pro', desc: 'Pro clock controller', family: 'chronos' },
+ { value: 'chronos', name: 'CHRONOS', uiName: 'Chronos', codename: 'chronos-basic', desc: 'Basic clock controller', family: 'chronos' },
+]
+
+const BOARD_TYPE_MAP = Object.fromEntries(BOARD_TYPES.map((b) => [b.value, b]))
+const BOARD_TYPE_LABELS = Object.fromEntries(BOARD_TYPES.map((b) => [b.value, b.name]))
+
+// Color palette per board family (idle → selected → hover glow)
+const BOARD_FAMILY_COLORS = {
+ vesper: {
+ selectedBg: '#0a1929',
+ selectedBorder: '#3b82f6',
+ selectedText: '#60a5fa',
+ hoverBorder: '#3b82f6',
+ glowColor: 'rgba(59,130,246,0.35)',
+ idleBorder: '#1d3a5c',
+ idleText: '#7ca8d4',
+ },
+ agnus: {
+ selectedBg: '#1a1400',
+ selectedBorder: '#f59e0b',
+ selectedText: '#fbbf24',
+ hoverBorder: '#f59e0b',
+ glowColor: 'rgba(245,158,11,0.35)',
+ idleBorder: '#4a3800',
+ idleText: '#c79d3a',
+ },
+ chronos: {
+ selectedBg: '#1a0808',
+ selectedBorder: '#ef4444',
+ selectedText: '#f87171',
+ hoverBorder: '#ef4444',
+ glowColor: 'rgba(239,68,68,0.35)',
+ idleBorder: '#5c1a1a',
+ idleText: '#d47a7a',
+ },
+}
+
+const STATUS_VARIANT = {
+ manufactured: 'neutral', flashed: 'info', provisioned: 'warning',
+ sold: 'success', claimed: 'success', decommissioned: 'danger',
+}
+
+const FLASHABLE_STATUSES = ['manufactured', 'flashed', 'provisioned']
+
+const FLASH_BAUD = 460800
+const NVS_ADDRESS = 0x9000
+const FW_ADDRESS = 0x10000
+const VERIFY_POLL_MS = 5000
+const VERIFY_TIMEOUT_MS = 120_000
+
+// ─── Helpers ──────────────────────────────────────────────────────────────────
+
+function formatHwVersion(v) {
+ if (!v) return '—'
+ if (/^\d+\.\d+/.test(v)) return `Rev ${v}`
+ const n = parseInt(v, 10)
+ return isNaN(n) ? `Rev ${v}` : `Rev ${n}.0`
+}
+
+// ─── Step Indicator ───────────────────────────────────────────────────────────
+
+const STEP_LABELS = ['Mode', 'Device', 'Flash', 'Verify', 'Done']
+
+function CheckeredFlagIcon() {
+ return (
+
+
+
+ )
+}
+
+function StepIndicator({ current }) {
+ return (
+
+ {STEP_LABELS.map((label, i) => {
+ const idx = i + 1
+ const done = idx < current
+ const active = idx === current
+ const pending = idx > current
+ const isLast = i === STEP_LABELS.length - 1
+
+ const dotBg = done ? '#53b15786' : active ? '#22c55e' : '#251a1a'
+ const dotColor = done ? '#cbedb9' : active ? '#ffffff' : '#555'
+ const labelColor = active ? '#22c55e' : done ? '#53b15786' : '#555'
+ const labelGlow = active ? '0 0 8px rgba(34,197,94,0.45)' : 'none'
+ const lineColor = done ? '#22c55e' : 'var(--color-border)'
+ const dotSize = active ? 38 : 30
+
+ return (
+
+
+
+ {done ? (
+
+
+
+ ) : isLast ?
: idx}
+
+
{label}
+
+ {i < STEP_LABELS.length - 1 && (
+
+ )}
+
+ )
+ })}
+
+ )
+}
+
+// ─── Board Tile ───────────────────────────────────────────────────────────────
+
+function BoardTile({ bt, isSelected, onClick }) {
+ const [hovered, setHovered] = useState(false)
+ const pal = BOARD_FAMILY_COLORS[bt.family]
+
+ const borderColor = isSelected ? pal.selectedBorder : hovered ? pal.hoverBorder : pal.idleBorder
+ const boxShadow = isSelected
+ ? `0 0 0 1px ${pal.selectedBorder}, 0 0 14px 4px ${pal.glowColor}`
+ : hovered
+ ? `0 0 12px 3px ${pal.glowColor}`
+ : 'none'
+
+ return (
+ setHovered(true)}
+ onMouseLeave={() => setHovered(false)}
+ aria-pressed={isSelected}
+ style={{
+ backgroundColor: isSelected ? pal.selectedBg : 'var(--color-bg-elevated)',
+ border: `1px solid ${borderColor}`,
+ borderRadius: 'var(--radius-md)',
+ padding: 'var(--space-3)',
+ textAlign: 'left', cursor: 'pointer',
+ transition: 'border-color 0.15s, box-shadow 0.15s, background-color 0.15s',
+ boxShadow,
+ width: '100%',
+ }}
+ >
+ {bt.name}
+ {bt.codename}
+ {bt.desc}
+
+ )
+}
+
+// ─── Progress Bar ─────────────────────────────────────────────────────────────
+
+function ProgressBar({ label, percent, flex = false }) {
+ return (
+
+
+ {label}
+ {Math.round(percent)}%
+
+
+
0 ? 'var(--shadow-primary-glow)' : 'none',
+ }} />
+
+
+ )
+}
+
+// ─── Info Cell ────────────────────────────────────────────────────────────────
+
+function InfoCell({ label, value, mono = false }) {
+ return (
+
+
{label}
+ {typeof value === 'string'
+ ?
{value || '—'}
+ : value}
+
+ )
+}
+
+// ─── Error Box ────────────────────────────────────────────────────────────────
+
+function ErrorBox({ msg }) {
+ if (!msg) return null
+ return (
+
+ {msg}
+
+ )
+}
+
+// ─── Bespoke Picker Modal ─────────────────────────────────────────────────────
+
+function BespokePickerModal({ open, onConfirm, onClose }) {
+ const [firmwares, setFirmwares] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState('')
+ const [selected, setSelected] = useState(null)
+ const [hwFamily, setHwFamily] = useState('')
+
+ useEffect(() => {
+ if (!open) return
+ setLoading(true)
+ api.get('/firmware?hw_type=bespoke')
+ .then((data) => setFirmwares(data.firmware || []))
+ .catch((err) => setError(err.message))
+ .finally(() => setLoading(false))
+ }, [open])
+
+ const handleConfirm = () => {
+ if (!selected) return
+ onConfirm({ firmware: selected, hwFamily })
+ }
+
+ return (
+
+
+ hw_revision will be set to 1.0 for bespoke devices.
+
+
+ Cancel
+
+ Continue
+
+
+
+ }
+ >
+
+
+ Choose a bespoke firmware and the hardware family to register in NVS.
+
+
+ {loading &&
}
+ {error &&
}
+ {!loading && !error && firmwares.length === 0 && (
+
+ No bespoke firmwares uploaded yet. Upload one from the Firmware Manager.
+
+ )}
+
+ {firmwares.length > 0 && (
+
+
+
+
+ {['UID', 'Version', 'Channel', 'Size'].map((h) => (
+ {h}
+ ))}
+
+
+
+ {firmwares.map((fw) => {
+ const isSel = selected?.id === fw.id
+ return (
+ setSelected(fw)}
+ style={{ borderBottom: '1px solid var(--color-border)', cursor: 'pointer', transition: 'background-color 0.1s', backgroundColor: isSel ? 'var(--color-info-bg)' : '' }}
+ onMouseEnter={(e) => { if (!isSel) e.currentTarget.style.backgroundColor = 'var(--color-bg-elevated)' }}
+ onMouseLeave={(e) => { if (!isSel) e.currentTarget.style.backgroundColor = '' }}
+ >
+ {fw.bespoke_uid || '—'}
+ {fw.version}
+ {fw.channel}
+ {fw.size_bytes ? `${(fw.size_bytes / 1024).toFixed(1)} KB` : '—'}
+
+ )
+ })}
+
+
+
+ )}
+
+
+
+ Hardware Family for NVS
+
+
+ Written to NVS as hw_type .
+
+
setHwFamily(e.target.value)}
+ placeholder="e.g. vesper_plus"
+ style={{ width: '100%', padding: 'var(--space-2) var(--space-3)', borderRadius: 'var(--radius-md)', border: '1px solid var(--color-border-strong)', backgroundColor: 'var(--color-bg-input)', color: 'var(--color-text-primary)', fontSize: 'var(--font-size-sm)' }}
+ />
+
+
+
+ )
+}
+
+// ─── Step 0: Mode Picker ──────────────────────────────────────────────────────
+
+function StepMode({ onPick }) {
+ return (
+
+
+
+ What would you like to do?
+
+
+ Choose how to start the provisioning process.
+
+
+
+
+ {/* Flash Existing */}
+
onPick('existing')}
+ style={{
+ backgroundColor: 'var(--color-bg-elevated)',
+ border: '1px solid var(--color-border)',
+ borderRadius: 'var(--radius-lg)',
+ padding: 'var(--space-5)',
+ textAlign: 'left', cursor: 'pointer',
+ transition: 'border-color 0.15s, box-shadow 0.15s',
+ }}
+ onMouseEnter={(e) => {
+ e.currentTarget.style.borderColor = 'var(--color-primary)'
+ e.currentTarget.style.boxShadow = '0 0 16px rgba(34,197,94,0.18)'
+ }}
+ onMouseLeave={(e) => {
+ e.currentTarget.style.borderColor = 'var(--color-border)'
+ e.currentTarget.style.boxShadow = 'none'
+ }}
+ >
+
+ Flash Existing
+
+ Re-flash a device already in inventory — manufactured, flashed, or provisioned.
+
+
+
+ {/* Deploy New Device */}
+
onPick('new')}
+ style={{
+ backgroundColor: 'var(--color-bg-elevated)',
+ border: '1px solid var(--color-border)',
+ borderRadius: 'var(--radius-lg)',
+ padding: 'var(--space-5)',
+ textAlign: 'left', cursor: 'pointer',
+ transition: 'border-color 0.15s, box-shadow 0.15s',
+ }}
+ onMouseEnter={(e) => {
+ e.currentTarget.style.borderColor = 'var(--color-success)'
+ e.currentTarget.style.boxShadow = '0 0 16px rgba(34,197,94,0.18)'
+ }}
+ onMouseLeave={(e) => {
+ e.currentTarget.style.borderColor = 'var(--color-border)'
+ e.currentTarget.style.boxShadow = 'none'
+ }}
+ >
+
+ Deploy New Device
+
+ Generate a new serial number, select board type and revision, then flash and provision.
+
+
+
+
+ )
+}
+
+// ─── Step 1: Select / Create Device ──────────────────────────────────────────
+
+function StepSelectDevice({ mode, preloadSn, onSelected, onCreatedSn }) {
+ const [search, setSearch] = useState(preloadSn || '')
+ const [results, setResults] = useState([])
+ const [loading, setLoading] = useState(false)
+ const [error, setError] = useState('')
+ const [picked, setPicked] = useState(null)
+
+ // New device form
+ const [boardType, setBoardType] = useState(null)
+ const [boardVersion, setBoardVersion] = useState('1.0')
+ const [creating, setCreating] = useState(false)
+ const [showBespoke, setShowBespoke] = useState(false)
+
+ const doSearch = useCallback(async (q) => {
+ setLoading(true); setError('')
+ try {
+ const params = new URLSearchParams({ limit: '50' })
+ if (q) params.set('search', q)
+ const data = await api.get(`/manufacturing/devices?${params}`)
+ const all = data.devices || []
+ setResults(all.filter((d) => FLASHABLE_STATUSES.includes(d.mfg_status)))
+ } catch (err) {
+ setError(err.message)
+ } finally {
+ setLoading(false)
+ }
+ }, [])
+
+ useEffect(() => { if (mode === 'existing') doSearch(preloadSn || '') }, [mode, doSearch, preloadSn])
+
+ const handleCreate = async () => {
+ if (!boardType) return
+ setCreating(true); setError('')
+ try {
+ const batch = await api.post('/manufacturing/batch', { board_type: boardType, board_version: boardVersion.trim(), quantity: 1 })
+ const sn = batch.serial_numbers[0]
+ onCreatedSn(sn)
+ const device = await api.get(`/manufacturing/devices/${sn}`)
+ onSelected(device)
+ } catch (err) {
+ setError(err.message)
+ } finally {
+ setCreating(false)
+ }
+ }
+
+ const handleBespokeConfirm = async ({ firmware, hwFamily }) => {
+ setShowBespoke(false)
+ setCreating(true); setError('')
+ try {
+ const batch = await api.post('/manufacturing/batch', { board_type: 'vesper', board_version: '1.0', quantity: 1 })
+ const sn = batch.serial_numbers[0]
+ onCreatedSn(sn)
+ const device = await api.get(`/manufacturing/devices/${sn}`)
+ onSelected(device, { firmware, hwFamily })
+ } catch (err) {
+ setError(err.message)
+ } finally {
+ setCreating(false)
+ }
+ }
+
+ // ── Selected confirmation panel ──
+ if (picked) {
+ const boardInfo = BOARD_TYPE_MAP[picked.hw_type]
+ const pal = BOARD_FAMILY_COLORS[boardInfo?.family || 'vesper']
+ return (
+
+
+
Device Selected
+
+
+
+
+ {picked.mfg_status}} />
+
+
+
+ onSelected(picked)}>
+ Continue to Flash
+
+ setPicked(null)}>Change Device
+
+
+ )
+ }
+
+ // ── Deploy New form ──
+ if (mode === 'new') {
+ const vesperBoards = BOARD_TYPES.filter((b) => b.family === 'vesper')
+ const agnusBoards = BOARD_TYPES.filter((b) => b.family === 'agnus')
+ const chronosBoards = BOARD_TYPES.filter((b) => b.family === 'chronos')
+
+ return (
+
+ {/* Board type */}
+
+
Board Type
+
+ {/* Vesper row — 3 equal columns */}
+
+ {vesperBoards.map((bt) => (
+ setBoardType(bt.value)} />
+ ))}
+
+ {/* Agnus row — 2 boards centered inside the same 3-col grid */}
+
+
+ {agnusBoards.map((bt) => (
+ setBoardType(bt.value)} />
+ ))}
+
+
+ {/* Chronos row — 2 boards centered inside the same 3-col grid */}
+
+
+ {chronosBoards.map((bt) => (
+ setBoardType(bt.value)} />
+ ))}
+
+
+
+
+
+ {/* Board revision — narrow */}
+
+
+
+ Board Revision
+
+
+ Rev
+ setBoardVersion(e.target.value)}
+ placeholder="1.0"
+ style={{
+ width: 72, padding: 'var(--space-2) var(--space-2)',
+ borderRadius: 'var(--radius-md)', border: '1px solid var(--color-border-strong)',
+ backgroundColor: 'var(--color-bg-input)', color: 'var(--color-text-primary)',
+ fontSize: 'var(--font-size-sm)', textAlign: 'center',
+ }}
+ />
+
+
e.g. 1.0, 1.20
+
+
+
+
+
+ Generate Serial & Continue
+
+
+
+
+ {/* Bespoke divider */}
+
+
+ {/* Bespoke option */}
+
+
+
Select Bespoke Firmware
+
+ Flash a one-off bespoke firmware with a custom hardware family written to NVS.
+
+
+
setShowBespoke(true)} style={{ flexShrink: 0 }}>
+ Select Bespoke →
+
+
+
+
setShowBespoke(false)} />
+
+ )
+ }
+
+ // ── Existing device search ──
+ return (
+
+
{ setSearch(v); doSearch(v) }}
+ placeholder="Search serial, batch, type…"
+ />
+
+ {error && }
+
+ {loading ? (
+
+ ) : results.length === 0 ? (
+ No flashable devices found.
+ ) : (
+
+
+
+
+ {['Serial', 'Type', 'Revision', 'Status'].map((h) => (
+ {h}
+ ))}
+
+
+
+ {results.map((d, i) => (
+ setPicked(d)}
+ style={{ borderBottom: i < results.length - 1 ? '1px solid var(--color-border)' : 'none', cursor: 'pointer', transition: 'background-color 0.1s' }}
+ onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = 'var(--color-bg-elevated)' }}
+ onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = '' }}
+ >
+ {d.serial_number}
+ {BOARD_TYPE_LABELS[d.hw_type] || d.hw_type}
+ {formatHwVersion(d.hw_version)}
+
+ {d.mfg_status}
+
+
+ ))}
+
+
+
+ )}
+
+ )
+}
+
+// ─── Serial Log Modal ──────────────────────────────────────────────────────────
+
+function SerialLogModal({ open, onClose, logs }) {
+ const [autoScroll, setAutoScroll] = useState(true)
+ const endRef = useRef(null)
+
+ useEffect(() => {
+ if (autoScroll && open) endRef.current?.scrollIntoView({ behavior: 'smooth' })
+ }, [logs, autoScroll, open])
+
+ return (
+
+
+ Auto-scroll
+ setAutoScroll((v) => !v)}
+ style={{
+ position: 'relative', display: 'inline-flex', alignItems: 'center',
+ width: 32, height: 18,
+ backgroundColor: autoScroll ? 'var(--color-primary)' : 'var(--color-bg-elevated)',
+ borderRadius: 9, border: '1px solid var(--color-border)',
+ cursor: 'pointer', transition: 'background-color 0.2s', flexShrink: 0,
+ }}
+ >
+
+
+
+ Close
+
+ }
+ >
+
+ {logs.length === 0 ? (
+
No serial output yet.
+ ) : (
+ logs.map((line, i) =>
{line}
)
+ )}
+
+
+
+ )
+}
+
+// ─── Step 2: Flash ────────────────────────────────────────────────────────────
+
+function StepFlash({ device, bespokeOverride, onFlashed }) {
+ const [portConnected, setPortConnected] = useState(false)
+ const [portName, setPortName] = useState('')
+ const [connecting, setConnecting] = useState(false)
+ const [flashing, setFlashing] = useState(false)
+ const [done, setDone] = useState(false)
+ const [blProgress, setBlProgress] = useState(0)
+ const [partProgress, setPartProgress] = useState(0)
+ const [nvsProgress, setNvsProgress] = useState(0)
+ const [fwProgress, setFwProgress] = useState(0)
+ const [log, setLog] = useState([])
+ const [serial, setSerial] = useState([])
+ const [nvsProfile, setNvsProfile] = useState('current')
+ const [error, setError] = useState('')
+
+ const loaderRef = useRef(null)
+ const portRef = useRef(null)
+ const serialReaderRef = useRef(null)
+ const serialActiveRef = useRef(false)
+ const logEndRef = useRef(null)
+ const serialEndRef = useRef(null)
+
+ const appendLog = (msg) => setLog((prev) => [...prev, String(msg)])
+ const appendSerial = (msg) => setSerial((prev) => [...prev, String(msg)])
+
+ const scrollLog = () => logEndRef.current?.scrollIntoView({ behavior: 'smooth' })
+ const scrollSerial = () => serialEndRef.current?.scrollIntoView({ behavior: 'smooth' })
+
+ const fetchBinary = async (url) => {
+ const token = localStorage.getItem('access_token')
+ const resp = await fetch(url, { headers: { Authorization: `Bearer ${token}` } })
+ if (!resp.ok) {
+ const err = await resp.json().catch(() => ({}))
+ throw new Error(err.detail || `Failed to fetch ${url}: ${resp.status}`)
+ }
+ return resp.arrayBuffer()
+ }
+
+ const arrayBufferToString = (buf) => {
+ const bytes = new Uint8Array(buf)
+ let str = ''
+ for (let i = 0; i < bytes.length; i++) str += String.fromCharCode(bytes[i])
+ return str
+ }
+
+ const startSerialMonitor = async (port) => {
+ serialActiveRef.current = true
+ await new Promise((r) => setTimeout(r, 1000))
+ try { await port.open({ baudRate: 115200 }) } catch (openErr) {
+ appendSerial(`[Error opening port: ${openErr.message}]`); scrollSerial(); return
+ }
+ let reader
+ try { reader = port.readable.getReader() } catch (readerErr) {
+ appendSerial(`[Error getting reader: ${readerErr.message}]`); scrollSerial()
+ try { await port.close() } catch (_) {}
+ return
+ }
+ serialReaderRef.current = reader
+ const dec = new TextDecoder()
+ let buf = ''
+ try {
+ while (serialActiveRef.current) {
+ const { value, done: streamDone } = await reader.read()
+ if (streamDone) break
+ buf += dec.decode(value, { stream: true })
+ const lines = buf.split(/\r?\n/)
+ buf = lines.pop()
+ for (const line of lines) { if (line.trim()) { appendSerial(line); scrollSerial() } }
+ }
+ } catch (_) {}
+ finally { try { reader.releaseLock() } catch (_) {} }
+ }
+
+ const disconnectPort = async () => {
+ serialActiveRef.current = false
+ try { await serialReaderRef.current?.cancel() } catch (_) {}
+ try { serialReaderRef.current?.releaseLock() } catch (_) {}
+ try { await portRef.current?.close() } catch (_) {}
+ portRef.current = null
+ setPortConnected(false)
+ setPortName('')
+ appendSerial('[Port disconnected]')
+ }
+
+ const handleConnectPort = async () => {
+ setError(''); setConnecting(true)
+ try {
+ const port = await navigator.serial.requestPort()
+ portRef.current = port
+ const info = port.getInfo?.() || {}
+ const label = info.usbVendorId
+ ? `USB ${info.usbVendorId.toString(16).toUpperCase()}:${(info.usbProductId || 0).toString(16).toUpperCase()}`
+ : 'Serial Port'
+ setPortName(label)
+ setPortConnected(true)
+ } catch (err) {
+ setError(err.message || 'Port selection cancelled.')
+ } finally {
+ setConnecting(false)
+ }
+ }
+
+ const handleStartFlash = async () => {
+ if (!portRef.current) return
+ setError(''); setLog([]); setSerial([])
+ setBlProgress(0); setPartProgress(0); setNvsProgress(0); setFwProgress(0)
+ setDone(false)
+
+ const port = portRef.current
+ const sn = device.serial_number
+
+ try {
+ const blUrl = bespokeOverride
+ ? `/api/manufacturing/devices/${sn}/bootloader.bin?hw_type_override=${bespokeOverride.hwFamily}`
+ : `/api/manufacturing/devices/${sn}/bootloader.bin`
+ const partUrl = bespokeOverride
+ ? `/api/manufacturing/devices/${sn}/partitions.bin?hw_type_override=${bespokeOverride.hwFamily}`
+ : `/api/manufacturing/devices/${sn}/partitions.bin`
+ const nvsUrl = bespokeOverride
+ ? `/api/manufacturing/devices/${sn}/nvs.bin?hw_type_override=${bespokeOverride.hwFamily}&hw_revision_override=1.0&nvs_profile=${nvsProfile}`
+ : `/api/manufacturing/devices/${sn}/nvs.bin?nvs_profile=${nvsProfile}`
+ const fwUrl = bespokeOverride
+ ? `/api/firmware/bespoke/${bespokeOverride.firmware.channel}/${bespokeOverride.firmware.version}/firmware.bin`
+ : `/api/manufacturing/devices/${sn}/firmware.bin`
+
+ appendLog('Fetching bootloader binary…')
+ const blBuffer = await fetchBinary(blUrl); appendLog(`Bootloader: ${blBuffer.byteLength} bytes`)
+ appendLog('Fetching partition table binary…')
+ const partBuffer = await fetchBinary(partUrl); appendLog(`Partition table: ${partBuffer.byteLength} bytes`)
+ appendLog('Fetching NVS binary…')
+ const nvsBuffer = await fetchBinary(nvsUrl); appendLog(`NVS: ${nvsBuffer.byteLength} bytes`)
+ appendLog('Fetching firmware binary…')
+ const fwBuffer = await fetchBinary(fwUrl); appendLog(`Firmware: ${fwBuffer.byteLength} bytes`)
+
+ setFlashing(true)
+ appendLog('Connecting to ESP32…')
+ const transport = new Transport(port, true)
+ loaderRef.current = new ESPLoader({
+ transport, baudrate: FLASH_BAUD,
+ terminal: {
+ clean() {},
+ writeLine: (line) => { appendLog(line); scrollLog() },
+ write: (msg) => { appendLog(msg); scrollLog() },
+ },
+ })
+ await loaderRef.current.main()
+ appendLog('ESP32 connected.')
+
+ await loaderRef.current.writeFlash({
+ fileArray: [
+ { data: arrayBufferToString(blBuffer), address: 0x1000 },
+ { data: arrayBufferToString(partBuffer), address: 0x8000 },
+ { data: arrayBufferToString(nvsBuffer), address: NVS_ADDRESS },
+ { data: arrayBufferToString(fwBuffer), address: FW_ADDRESS },
+ ],
+ flashSize: 'keep', flashMode: 'keep', flashFreq: 'keep',
+ eraseAll: false, compress: true,
+ reportProgress(fileIndex, written, total) {
+ const pct = (written / total) * 100
+ if (fileIndex === 0) { setBlProgress(pct) }
+ else if (fileIndex === 1) { setBlProgress(100); setPartProgress(pct) }
+ else if (fileIndex === 2) { setPartProgress(100); setNvsProgress(pct) }
+ else { setNvsProgress(100); setFwProgress(pct) }
+ },
+ calculateMD5Hash: () => '',
+ })
+
+ setBlProgress(100); setPartProgress(100); setNvsProgress(100); setFwProgress(100)
+ appendLog('Flash complete. Resetting device…')
+
+ try {
+ const t = loaderRef.current.transport
+ await t.setRTS(true); await new Promise((r) => setTimeout(r, 100))
+ await t.setRTS(false); await new Promise((r) => setTimeout(r, 100))
+ } catch (rstErr) { appendLog(`[Reset warning: ${rstErr.message}]`) }
+
+ appendLog('Hard reset sent. Device is booting…')
+ try { await loaderRef.current.transport.disconnect() } catch (_) {}
+ appendLog('esptool disconnected. Opening serial monitor…')
+
+ await api.request(`/manufacturing/devices/${sn}/status`, {
+ method: 'PATCH',
+ body: JSON.stringify({ status: 'flashed', note: 'Flashed via browser provisioning wizard' }),
+ })
+
+ setFlashing(false)
+ setDone(true)
+ appendSerial('── Serial monitor started (115200 baud) ──')
+ startSerialMonitor(port)
+ } catch (err) {
+ setError(err.message || String(err))
+ setFlashing(false)
+ try { await loaderRef.current?.transport?.disconnect() } catch (_) {}
+ }
+ }
+
+ const webSerialAvailable = typeof navigator !== 'undefined' && 'serial' in navigator
+ const busy = connecting || flashing
+ const boardInfo = BOARD_TYPE_MAP[device.hw_type]
+
+ const familyPal = BOARD_FAMILY_COLORS[boardInfo?.family || 'vesper']
+
+ // ── Left panel: device info + controls ────────────────────────────────────
+ const InfoPanel = (
+
+ {/* Header: title + COM status button */}
+
+
+ Device to Flash
+
+
{ if (portConnected) e.currentTarget.style.opacity = '0.75' }}
+ onMouseLeave={(e) => { e.currentTarget.style.opacity = '1' }}
+ >
+
+ {portConnected ? portName || 'Connected' : 'No Port'}
+ {portConnected && ✕ }
+
+
+
+ {/* Device info */}
+
+ {bespokeOverride ? (
+
+
+
+
+
+
Firmware
+
BESPOKE · {bespokeOverride.firmware.bespoke_uid}
+
v{bespokeOverride.firmware.version} / {bespokeOverride.firmware.channel}
+
+
+ ) : (
+ <>
+ {/* Row 1: serial number + status badge */}
+
+
+ {device.serial_number}
+
+
{device.mfg_status}
+
+ {/* Row 2: board type + codename */}
+
+ Board Type:
+
+
+ {boardInfo?.uiName || boardInfo?.name || device.hw_type}
+
+ {boardInfo?.codename && (
+ | {boardInfo.codename}
+ )}
+
+ {/* Row 3: revision */}
+
+ Revision:
+
+
+ {formatHwVersion(device.hw_version)}
+
+
+ >
+ )}
+
+
+ {/* NVS profile toggle */}
+
+
+ {[['current', 'Current Gen NVS'], ['legacy', 'Legacy NVS']].map(([val, lbl]) => (
+ setNvsProfile(val)}
+ style={{
+ padding: '2px 10px',
+ fontSize: 'var(--font-size-xs)', fontWeight: 'var(--font-weight-medium)',
+ cursor: 'pointer', border: 'none',
+ backgroundColor: nvsProfile === val ? 'var(--color-primary)' : 'var(--color-bg-elevated)',
+ color: nvsProfile === val ? 'var(--color-bg-base)' : 'var(--color-text-muted)',
+ transition: 'background-color 0.15s, color 0.15s',
+ }}
+ >{lbl}
+ ))}
+
+
+
+ {!webSerialAvailable && (
+
+ Web Serial API not available. Use Chrome or Edge on a desktop system.
+
+ )}
+
+ {error &&
}
+
+ {/* Progress bars — shown while flashing */}
+ {(flashing || blProgress > 0) && (
+
+ )}
+
+ {/* Spacer */}
+
+
+ {/* Bottom bar */}
+
+ {/* Left: status hint */}
+
+ {portConnected && !flashing && !done && log.length === 0 && (
+
+
+ Ready to flash.
+
+ )}
+ {flashing && (
+
Flashing — do not disconnect…
+ )}
+
+ NVS 0x9000 · FW 0x10000 · {FLASH_BAUD} baud
+
+
+
+ {/* Right: action buttons */}
+ {!busy && (
+
+ {!portConnected && (
+
+ Select COM Port
+
+ )}
+ {portConnected && done && (
+ Flash Again
+ )}
+ {done && (
+
+ Proceed to Verify
+
+ )}
+ {portConnected && !done && (
+
+ Start Flashing
+
+ )}
+
+ )}
+ {busy && (
+
+
+
+ )}
+
+
+ )
+
+ // ── Right panel: flash output log ─────────────────────────────────────────
+ const FlashOutputPanel = (
+
+
+ Flash Output
+
+
+ {log.length === 0
+ ?
{flashing ? 'Connecting…' : 'Output will appear here once flashing starts.'}
+ : log.map((line, i) =>
{line}
)
+ }
+
+
+
+ )
+
+ return (
+
+ {/* Info panel (left) | Flash output (right) */}
+
+ {InfoPanel}
+ {FlashOutputPanel}
+
+
+ )
+}
+
+// ─── Step 3: Verify ───────────────────────────────────────────────────────────
+
+function StepVerify({ device, onVerified }) {
+ const [polling, setPolling] = useState(false)
+ const [timedOut, setTimedOut] = useState(false)
+ const [verified, setVerified] = useState(false)
+ const [heartbeatData, setHeartbeatData] = useState(null)
+ const [error, setError] = useState('')
+ const intervalRef = useRef(null)
+ const timeoutRef = useRef(null)
+
+ const startPolling = useCallback(() => {
+ if (polling) return
+ setPolling(true); setTimedOut(false); setError('')
+
+ const startTime = Date.now()
+
+ intervalRef.current = setInterval(async () => {
+ try {
+ const hbData = await api.get(`/mqtt/heartbeats/${device.serial_number}?limit=1&offset=0`)
+ if (hbData.heartbeats?.length > 0) {
+ const latest = hbData.heartbeats[0]
+ const receivedMs = latest.received_at
+ ? Date.parse(latest.received_at.replace(' ', 'T') + 'Z') : NaN
+ if (!isNaN(receivedMs) && receivedMs > startTime) {
+ clearInterval(intervalRef.current)
+ clearTimeout(timeoutRef.current)
+ try {
+ await api.request(`/manufacturing/devices/${device.serial_number}/status`, {
+ method: 'PATCH',
+ body: JSON.stringify({ status: 'provisioned', note: 'Auto-verified via wizard' }),
+ })
+ } catch (_) {}
+ const deviceData = await api.get(`/manufacturing/devices/${device.serial_number}`)
+ setHeartbeatData(latest)
+ setPolling(false)
+ setVerified(true)
+ onVerified({ ...deviceData, mfg_status: 'provisioned' })
+ }
+ }
+ } catch (err) { setError(err.message) }
+ }, VERIFY_POLL_MS)
+
+ timeoutRef.current = setTimeout(() => {
+ clearInterval(intervalRef.current)
+ setPolling(false); setTimedOut(true)
+ }, VERIFY_TIMEOUT_MS)
+ }, [polling, device.serial_number, onVerified])
+
+ useEffect(() => {
+ startPolling()
+ return () => { clearInterval(intervalRef.current); clearTimeout(timeoutRef.current) }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [])
+
+ const stopPolling = () => {
+ clearInterval(intervalRef.current); clearTimeout(timeoutRef.current); setPolling(false)
+ }
+
+ return (
+
+
+
+ Waiting for Device
+
+
+ {polling && !verified && (
+
+
+
+ Waiting for device to connect…
+
+ Power cycle the device and ensure it can reach the MQTT broker.
+
+
+
Stop
+
+ )}
+
+ {verified && heartbeatData && (
+
+
+
+
+
+
Device is live!
+
+
+
+
+
+
+
+
+ )}
+
+ {timedOut && !verified && (
+
+
+ Timed out after {VERIFY_TIMEOUT_MS / 1000}s. Check WiFi credentials and MQTT broker connectivity.
+
+
Retry Verification
+
+ )}
+
+ {error && !timedOut && !verified && (
+
+ )}
+
+
+ Polling every {VERIFY_POLL_MS / 1000}s · timeout {VERIFY_TIMEOUT_MS / 1000}s
+
+
+ )
+}
+
+// ─── Step 4: Done ─────────────────────────────────────────────────────────────
+
+function StepDone({ device, onProvisionNext }) {
+ const navigate = useNavigate()
+ const [showSerialLogs, setShowSerialLogs] = useState(false)
+
+ return (
+
+
+
+
+
+
+
+ Device Provisioned
+
+
+ {device?.serial_number} is live.
+
+
+
+
+ {/* Device summary grid */}
+
+
+
+
Serial Number
+
{device?.serial_number}
+
+
+
Status
+
{device?.mfg_status}
+
+
+
+
+
Board Type
+
{BOARD_TYPE_LABELS[device?.hw_type] || device?.hw_type}
+
+
+
HW Version
+
{formatHwVersion(device?.hw_version)}
+
+
+
+
+
+
+ Provision Next Device
+
+ navigate(`/manufacturing/devices/${device?.serial_number}`)}>
+ View in Inventory
+
+ setShowSerialLogs(true)}>
+ View Serial Logs
+
+
+
+
setShowSerialLogs(false)} logs={[]} />
+
+ )
+}
+
+// ─── Main Wizard ──────────────────────────────────────────────────────────────
+// Steps: 0=Mode 1=Select/Create device 2=Flash 3=Verify 4=Done
+
+export default function ProvisioningWizard() {
+ const navigate = useNavigate()
+ const [searchParams] = useSearchParams()
+ const preloadSn = searchParams.get('sn') || ''
+
+ const [step, setStep] = useState(preloadSn ? 1 : 0)
+ const [mode, setMode] = useState(preloadSn ? 'existing' : null)
+ const [device, setDevice] = useState(null)
+ const [bespokeOverride, setBespokeOverride] = useState(null)
+ const createdSnRef = useRef(null)
+
+ const handleModePick = (m) => { setMode(m); setStep(1) }
+
+ const handleDeviceSelected = (d, bespoke = null) => {
+ setBespokeOverride(bespoke)
+ setDevice(d)
+ setStep(2)
+ }
+
+ const handleCreatedSn = (sn) => { createdSnRef.current = sn }
+
+ const handleFlashed = () => { setStep(3) }
+
+ const handleVerified = (updatedDevice) => { setDevice(updatedDevice); setStep(4) }
+
+ const handleProvisionNext = () => {
+ setStep(0); setMode(null); setDevice(null); setBespokeOverride(null); createdSnRef.current = null
+ }
+
+ return (
+
+
+ navigate('/manufacturing')}>
+
+ Back to Inventory
+
+
+
+ {/* Step indicator */}
+
+
+
+
+ {/* Step content — floating on page background, no card wrapper */}
+
+ {step === 0 && }
+
+ {step === 1 && (
+
+ )}
+
+ {step === 2 && device && (
+
+ )}
+
+ {step === 3 && device && (
+
+ )}
+
+ {step === 4 && device && (
+
+ )}
+
+
+ )
+}
diff --git a/frontend/src/pages/public/cloudflash/CloudFlashPage.jsx b/frontend/src/pages/public/cloudflash/CloudFlashPage.jsx
new file mode 100644
index 0000000..196f3cc
--- /dev/null
+++ b/frontend/src/pages/public/cloudflash/CloudFlashPage.jsx
@@ -0,0 +1,4 @@
+// TODO: implement
+export default function CloudFlashPage() {
+ return null
+}
diff --git a/frontend/src/pages/public/serial/SerialMonitorPage.jsx b/frontend/src/pages/public/serial/SerialMonitorPage.jsx
new file mode 100644
index 0000000..8b54e23
--- /dev/null
+++ b/frontend/src/pages/public/serial/SerialMonitorPage.jsx
@@ -0,0 +1,4 @@
+// TODO: implement
+export default function SerialMonitorPage() {
+ return null
+}
diff --git a/frontend/src/pages/settings/PublicFeaturesSettings.jsx b/frontend/src/pages/settings/PublicFeaturesSettings.jsx
new file mode 100644
index 0000000..1a1af5d
--- /dev/null
+++ b/frontend/src/pages/settings/PublicFeaturesSettings.jsx
@@ -0,0 +1,221 @@
+// frontend/src/pages/settings/PublicFeaturesSettings.jsx
+
+import { useState, useEffect } from 'react'
+import api from '@/lib/api'
+import { useToast } from '@/components/ui/Toast'
+import PageHeader from '@/components/ui/PageHeader'
+import Card from '@/components/ui/Card'
+import Spinner from '@/components/ui/Spinner'
+
+// ─── Feature definitions ────────────────────────────────────────────────────
+
+const FEATURES = [
+ {
+ key: 'cloudflash_enabled',
+ title: 'CloudFlash',
+ description: 'Allows end-users to flash their own devices via USB directly from the browser. When disabled, the CloudFlash page is inaccessible and displays a maintenance message.',
+ url: '/cloudflash',
+ icon: (
+
+
+
+ ),
+ },
+]
+
+// ─── Toggle switch ──────────────────────────────────────────────────────────
+
+function ToggleSwitch({ enabled, onChange, disabled, id }) {
+ return (
+ onChange(!enabled)}
+ style={{
+ flexShrink: 0,
+ position: 'relative',
+ display: 'inline-flex',
+ width: '44px',
+ height: '24px',
+ borderRadius: 'var(--radius-full)',
+ border: 'none',
+ cursor: disabled ? 'not-allowed' : 'pointer',
+ padding: 0,
+ transition: 'background 0.2s',
+ background: enabled
+ ? 'var(--color-success)'
+ : 'var(--color-bg-island)',
+ boxShadow: enabled
+ ? 'inset 0 1px 3px rgba(0,0,0,0.2), 0 0 0 1px var(--color-success)'
+ : 'inset 0 1px 3px rgba(0,0,0,0.2), 0 0 0 1px var(--color-border-strong)',
+ opacity: disabled ? 0.5 : 1,
+ }}
+ >
+
+
+ )
+}
+
+// ─── Feature row ────────────────────────────────────────────────────────────
+
+function FeatureRow({ feature, enabled, saving, onToggle }) {
+ return (
+
+ {/* Icon */}
+
+ {feature.icon}
+
+
+ {/* Text */}
+
+
+
+ {feature.title}
+
+
+ {enabled ? 'Live' : 'Disabled'}
+
+
+
+ {feature.description}
+
+ {feature.url && (
+
+ {feature.url}
+
+ )}
+
+
+ {/* Toggle */}
+
+
+
+
+ )
+}
+
+// ─── Main ─────────────────────────────────────────────────────────────────
+
+export default function PublicFeaturesSettings() {
+ const { toast } = useToast()
+
+ const [settings, setSettings] = useState(null)
+ const [loading, setLoading] = useState(true)
+ const [saving, setSaving] = useState(false)
+
+ useEffect(() => {
+ api.get('/settings/public-features')
+ .then(setSettings)
+ .catch((e) => toast.danger('Failed to load settings', e.message))
+ .finally(() => setLoading(false))
+ }, [])
+
+ const handleToggle = async (key, value) => {
+ setSaving(true)
+ try {
+ const updated = await api.put('/settings/public-features', { [key]: value })
+ setSettings(updated)
+ toast.success('Settings saved', `${key.replace(/_/g, ' ')} has been ${value ? 'enabled' : 'disabled'}.`)
+ } catch (e) {
+ toast.danger('Failed to save', e.message)
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ return (
+
+
+
+ {loading ? (
+
+
+
+ ) : settings ? (
+
+ {FEATURES.map((feature) => (
+ handleToggle(feature.key, val)}
+ />
+ ))}
+
+ ) : (
+
+
+ No settings available.
+
+
+ )}
+
+ )
+}
diff --git a/frontend/src/pages/settings/automations/AutomationsPage.jsx b/frontend/src/pages/settings/automations/AutomationsPage.jsx
new file mode 100644
index 0000000..72ce8e9
--- /dev/null
+++ b/frontend/src/pages/settings/automations/AutomationsPage.jsx
@@ -0,0 +1,111 @@
+// frontend/src/pages/settings/automations/AutomationsPage.jsx
+//
+// Automations — background task runner for scheduled CRM/system jobs.
+// Each automation has: a name, description, toggle (enabled/disabled),
+// configurable interval, last-run timestamp, last-run result, and a
+// "Run now" manual trigger.
+//
+// This page is a PLACEHOLDER. The full system will be built in a later pass.
+
+import PageHeader from '@/components/ui/PageHeader'
+import Card from '@/components/ui/Card'
+import Icon from '@/components/ui/Icon'
+
+export default function AutomationsPage() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ Automations — Coming Soon
+
+
+ This page will let you manage all background automations in one place —
+ with toggles, run intervals, manual triggers, and a log of each run.
+
+
+
+
+
+ Planned automations
+
+
+ {[
+ 'Auto-set customers to Archived when all orders are complete',
+ 'Auto-set customers to Went Cold after X months of silence',
+ 'Auto-flip Archived customers back to Active when a new order opens',
+ 'Periodic CRM data integrity checks',
+ ].map((item) => (
+
+
+
+ {item}
+
+
+ ))}
+
+
+
+
+
+ )
+}
diff --git a/frontend/src/pages/settings/staff/StaffDetail.jsx b/frontend/src/pages/settings/staff/StaffDetail.jsx
new file mode 100644
index 0000000..61021e8
--- /dev/null
+++ b/frontend/src/pages/settings/staff/StaffDetail.jsx
@@ -0,0 +1,488 @@
+// frontend/src/pages/settings/staff/StaffDetail.jsx
+
+import { useState, useEffect } from 'react'
+import { useParams, useNavigate } from 'react-router-dom'
+import api from '@/lib/api'
+import { useAuth } from '@/hooks/useAuth'
+import { useToast } from '@/components/ui/Toast'
+import PageHeader from '@/components/ui/PageHeader'
+import Button from '@/components/ui/Button'
+import Card from '@/components/ui/Card'
+import StatusBadge from '@/components/ui/StatusBadge'
+import Spinner from '@/components/ui/Spinner'
+import Modal from '@/components/ui/Modal'
+import FormField from '@/components/ui/FormField'
+import ConfirmDialog from '@/components/ui/ConfirmDialog'
+import Icon from '@/components/ui/Icon'
+
+// ─── Role meta ─────────────────────────────────────────────────────────────
+
+const ROLE_VARIANT = {
+ sysadmin: 'danger',
+ admin: 'warning',
+ editor: 'info',
+ user: 'neutral',
+}
+
+// ─── Helpers ───────────────────────────────────────────────────────────────
+
+function InfoRow({ label, children }) {
+ return (
+
+
+ {label}
+
+
+ {children || — }
+
+
+ )
+}
+
+// ─── Permission display pill ────────────────────────────────────────────────
+
+function PermPill({ value }) {
+ if (value) {
+ return (
+
+
+
+
+ Yes
+
+ )
+ }
+ return (
+
+ No
+
+ )
+}
+
+function AccessPill({ viewVal, editVal }) {
+ if (editVal) return (
+ Edit
+ )
+ if (viewVal) return (
+ View
+ )
+ return (
+ None
+ )
+}
+
+function PermRow({ label, value }) {
+ return (
+
+ )
+}
+
+function SegRow({ label, viewVal, editVal }) {
+ return (
+
+ )
+}
+
+function PermCard({ title, children }) {
+ return (
+
+
+ {children}
+
+
+ )
+}
+
+// ─── Reset Password Modal ──────────────────────────────────────────────────
+
+function ResetPasswordModal({ open, onClose, memberId, memberName }) {
+ const { toast } = useToast()
+ const [password, setPassword] = useState('')
+ const [saving, setSaving] = useState(false)
+ const [error, setError] = useState('')
+
+ function handleClose() {
+ setPassword('')
+ setError('')
+ onClose()
+ }
+
+ async function handleSubmit(e) {
+ e.preventDefault()
+ setError('')
+ if (password.length < 6) { setError('Password must be at least 6 characters.'); return }
+ setSaving(true)
+ try {
+ await api.put(`/staff/${memberId}/password`, { new_password: password })
+ toast.success('Password updated', `Password for ${memberName} has been changed.`)
+ handleClose()
+ } catch (err) {
+ setError(err.message || 'Failed to update password.')
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ return (
+
+ Cancel
+ Update Password
+ >
+ }
+ >
+
+ Set a new password for {memberName} .
+
+ setPassword(e.target.value)}
+ placeholder="Min. 6 characters"
+ error={error}
+ required
+ />
+
+ )
+}
+
+// ─── Main ─────────────────────────────────────────────────────────────────
+
+export default function StaffDetail() {
+ const { id } = useParams()
+ const navigate = useNavigate()
+ const { user } = useAuth()
+ const toast = useToast()
+
+ const [member, setMember] = useState(null)
+ const [loading, setLoading] = useState(true)
+ const [showDelete, setShowDelete] = useState(false)
+ const [deleting, setDeleting] = useState(false)
+ const [showResetPw, setShowResetPw] = useState(false)
+
+ useEffect(() => {
+ setLoading(true)
+ api.get(`/staff/${id}`)
+ .then(setMember)
+ .catch((err) => toast.danger('Failed to load staff member', err.message))
+ .finally(() => setLoading(false))
+ }, [id])
+
+ const handleDelete = async () => {
+ setDeleting(true)
+ try {
+ await api.delete(`/staff/${id}`)
+ toast.success('Staff member deleted', `${member.name} has been removed.`)
+ navigate('/settings/staff')
+ } catch (err) {
+ toast.danger('Delete failed', err.message)
+ setDeleting(false)
+ setShowDelete(false)
+ }
+ }
+
+ if (loading) {
+ return (
+
+ )
+ }
+
+ if (!member) return null
+
+ const canEdit = !(user?.role === 'admin' && member.role === 'sysadmin')
+ const canDelete = canEdit && member.id !== user?.sub
+ 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 (
+
+
+
+ {canEdit && (
+ <>
+ setShowResetPw(true)}>
+
+ Reset Password
+
+ navigate(`/settings/staff/${id}/edit`)}>
+ Edit
+
+ >
+ )}
+ {canDelete && (
+ setShowDelete(true)}>
+ Delete
+
+ )}
+
+
+
+ {/* Account info card */}
+
+
+ {member.name}
+ {member.email}
+
+
+ {member.role?.charAt(0).toUpperCase() + member.role?.slice(1)}
+
+
+
+
+ {member.is_active ? 'Active' : 'Inactive'}
+
+
+
+
+ {member.id}
+
+
+
+
+
+ {/* Admin / Sysadmin — full access notice */}
+ {(member.role === 'sysadmin' || member.role === 'admin') && (
+
+
+
+
+
+
+ {member.role === 'sysadmin'
+ ? 'SysAdmin has full god-mode access to all features and settings.'
+ : 'Admin has full access to all features, except managing SysAdmin accounts and system-level settings.'}
+
+
+
+ )}
+
+ {/* Granular permissions grid */}
+ {showPerms && (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* CRM Customers — full width */}
+
+
+
+ CRM Customers
+
+ {cc.full_access && (
+ Full Access
+ )}
+
+
+
+
+ {/* API + MQTT */}
+
+ >
+ )}
+
+
setShowResetPw(false)}
+ memberId={id}
+ memberName={member.name}
+ />
+
+ setShowDelete(false)}
+ />
+
+ )
+}
diff --git a/frontend/src/pages/settings/staff/StaffForm.jsx b/frontend/src/pages/settings/staff/StaffForm.jsx
new file mode 100644
index 0000000..42ca209
--- /dev/null
+++ b/frontend/src/pages/settings/staff/StaffForm.jsx
@@ -0,0 +1,615 @@
+// frontend/src/pages/settings/staff/StaffForm.jsx
+
+import { useState, useEffect } from 'react'
+import { useParams, useNavigate } from 'react-router-dom'
+import api from '@/lib/api'
+import { useAuth } from '@/hooks/useAuth'
+import { useToast } from '@/components/ui/Toast'
+import PageHeader from '@/components/ui/PageHeader'
+import Button from '@/components/ui/Button'
+import Card from '@/components/ui/Card'
+import FormField from '@/components/ui/FormField'
+import Spinner from '@/components/ui/Spinner'
+
+// ─── Default permission sets ────────────────────────────────────────────────
+
+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 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 }
+}
+
+// ─── Permission toggle row ──────────────────────────────────────────────────
+
+function PermToggle({ label, description, value, onChange, disabled }) {
+ return (
+
+
+
{label}
+ {description && (
+
+ {description}
+
+ )}
+
+
+ onChange(false)}
+ style={{
+ padding: '4px 10px',
+ fontSize: 'var(--font-size-xs)',
+ fontWeight: !value ? 'var(--font-weight-semibold)' : 'var(--font-weight-normal)',
+ cursor: 'pointer',
+ borderRight: '1px solid var(--color-border-strong)',
+ background: !value ? 'var(--color-danger-bg)' : 'transparent',
+ color: !value ? 'var(--color-danger)' : 'var(--color-text-muted)',
+ transition: 'background 0.15s, color 0.15s',
+ border: 'none',
+ borderRight: '1px solid var(--color-border-strong)',
+ }}
+ >
+ Off
+
+ onChange(true)}
+ style={{
+ padding: '4px 10px',
+ fontSize: 'var(--font-size-xs)',
+ fontWeight: value ? 'var(--font-weight-semibold)' : 'var(--font-weight-normal)',
+ cursor: 'pointer',
+ background: value ? 'var(--color-success-bg)' : 'transparent',
+ color: value ? 'var(--color-success)' : 'var(--color-text-muted)',
+ transition: 'background 0.15s, color 0.15s',
+ border: 'none',
+ }}
+ >
+ On
+
+
+
+ )
+}
+
+// ─── 3-state segmented row ──────────────────────────────────────────────────
+
+function SegmentedToggle({ label, description, value, onChange, disabled }) {
+ return (
+
+
+
{label}
+ {description && (
+
{description}
+ )}
+
+
+ {[
+ { key: 'none', label: 'None', bg: 'var(--color-danger-bg)', fg: 'var(--color-danger)' },
+ { key: 'view', label: 'View', bg: 'var(--color-info-bg)', fg: 'var(--color-info)' },
+ { key: 'edit', label: 'Edit', bg: 'var(--color-success-bg)', fg: 'var(--color-success)' },
+ ].map((opt, i, arr) => (
+ onChange(opt.key)}
+ style={{
+ padding: '4px 10px',
+ fontSize: 'var(--font-size-xs)',
+ fontWeight: value === opt.key ? 'var(--font-weight-semibold)' : 'var(--font-weight-normal)',
+ cursor: 'pointer',
+ background: value === opt.key ? opt.bg : 'transparent',
+ color: value === opt.key ? opt.fg : 'var(--color-text-muted)',
+ transition: 'background 0.15s, color 0.15s',
+ border: 'none',
+ borderRight: i < arr.length - 1 ? '1px solid var(--color-border-strong)' : 'none',
+ }}
+ >
+ {opt.label}
+
+ ))}
+
+
+ )
+}
+
+function PermSection({ title, children }) {
+ return (
+
+
+ {children}
+
+
+ )
+}
+
+// ─── Main ─────────────────────────────────────────────────────────────────
+
+export default function StaffForm() {
+ const { id } = useParams()
+ const navigate = useNavigate()
+ const { user } = useAuth()
+ const toast = useToast()
+ const isEdit = !!id
+
+ 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) 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 })
+ if (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(deepClone(EDITOR_PERMS))
+ } else {
+ setPermissions(deepClone(USER_PERMS))
+ }
+ })
+ .catch((err) => { setError(err.message); toast.danger('Failed to load', err.message) })
+ .finally(() => setLoading(false))
+ }, [id])
+
+ const handleRoleChange = (newRole) => {
+ setForm((f) => ({ ...f, role: newRole }))
+ if (newRole === 'editor') setPermissions(deepClone(EDITOR_PERMS))
+ else if (newRole === 'user') setPermissions(deepClone(USER_PERMS))
+ }
+
+ 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 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 }
+ if (isEdit) {
+ body.is_active = form.is_active
+ body.permissions = (form.role === 'editor' || form.role === 'user') ? permissions : null
+ await api.put(`/staff/${id}`, body)
+ toast.success('Saved', 'Staff member updated successfully.')
+ navigate(`/settings/staff/${id}`)
+ } else {
+ body.password = form.password
+ if (form.role === 'editor' || form.role === 'user') body.permissions = permissions
+ const result = await api.post('/staff', body)
+ toast.success('Created', `${form.name} has been added to the team.`)
+ navigate(`/settings/staff/${result.id}`)
+ }
+ } catch (err) {
+ setError(err.message || 'Something went wrong.')
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ if (loading) {
+ return (
+
+ )
+ }
+
+ const roleOptions = user?.role === 'sysadmin'
+ ? ['sysadmin', 'admin', 'editor', 'user']
+ : ['editor', 'user']
+
+ 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 (
+
+
+ navigate(isEdit ? `/settings/staff/${id}` : '/settings/staff')}>
+ Cancel
+
+
+ {isEdit ? 'Save Changes' : 'Create Staff Member'}
+
+
+
+
+
+ )
+}
diff --git a/frontend/src/pages/settings/staff/StaffList.jsx b/frontend/src/pages/settings/staff/StaffList.jsx
new file mode 100644
index 0000000..1cf31ee
--- /dev/null
+++ b/frontend/src/pages/settings/staff/StaffList.jsx
@@ -0,0 +1,295 @@
+// frontend/src/pages/settings/staff/StaffList.jsx
+
+import { useState, useEffect, useCallback } from 'react'
+import { useNavigate } from 'react-router-dom'
+import api from '@/lib/api'
+import { useAuth } from '@/hooks/useAuth'
+import { useToast } from '@/components/ui/Toast'
+import PageHeader from '@/components/ui/PageHeader'
+import Button from '@/components/ui/Button'
+import StatusBadge from '@/components/ui/StatusBadge'
+import SearchBar from '@/components/ui/SearchBar'
+import DataTable from '@/components/ui/DataTable'
+import Pagination from '@/components/ui/Pagination'
+import Select from '@/components/ui/Select'
+import ConfirmDialog from '@/components/ui/ConfirmDialog'
+import Icon from '@/components/ui/Icon'
+
+// ─── Role meta ─────────────────────────────────────────────────────────────
+
+const ROLE_VARIANT = {
+ sysadmin: 'danger',
+ admin: 'warning',
+ editor: 'info',
+ user: 'neutral',
+}
+
+const ROLE_OPTIONS = [
+ { value: '', label: 'All Roles' },
+ { value: 'sysadmin', label: 'Sysadmin' },
+ { value: 'admin', label: 'Admin' },
+ { value: 'editor', label: 'Editor' },
+ { value: 'user', label: 'User' },
+]
+
+// ─── Columns ───────────────────────────────────────────────────────────────
+
+function buildColumns(navigate, user, onDeleteClick) {
+ return [
+ {
+ key: 'name',
+ label: 'Name',
+ sortable: true,
+ alwaysOn: true,
+ render: (row) => (
+
+ {/* Avatar circle */}
+
+ {row.name?.charAt(0)?.toUpperCase() || '?'}
+
+
+ {row.name}
+ {row.id === user?.sub && (
+
+ (you)
+
+ )}
+
+
+ ),
+ },
+ {
+ key: 'email',
+ label: 'Email',
+ sortable: true,
+ render: (row) => (
+
+ {row.email}
+
+ ),
+ },
+ {
+ key: 'role',
+ label: 'Role',
+ sortable: true,
+ render: (row) => (
+
+ {row.role?.charAt(0).toUpperCase() + row.role?.slice(1)}
+
+ ),
+ },
+ {
+ key: 'is_active',
+ label: 'Status',
+ sortable: false,
+ render: (row) => (
+
+ {row.is_active ? 'Active' : 'Inactive'}
+
+ ),
+ },
+ {
+ key: '_actions',
+ label: '',
+ sortable: false,
+ alwaysOn: true,
+ render: (row) => {
+ const canEdit = !(user?.role === 'admin' && row.role === 'sysadmin')
+ const canDelete = canEdit && row.id !== user?.sub
+ if (!canEdit && !canDelete) return null
+ return (
+ e.stopPropagation()}>
+ {canEdit && (
+ navigate(`/settings/staff/${row.id}/edit`)}
+ >
+ Edit
+
+ )}
+ {canDelete && (
+ onDeleteClick(row)}
+ >
+ Delete
+
+ )}
+
+ )
+ },
+ },
+ ]
+}
+
+// ─── Main ─────────────────────────────────────────────────────────────────
+
+export default function StaffList() {
+ const navigate = useNavigate()
+ const { user } = useAuth()
+ const toast = useToast()
+
+ const [staff, setStaff] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [search, setSearch] = useState('')
+ const [roleFilter, setRoleFilter] = useState('')
+ const [deleteTarget, setDeleteTarget] = useState(null)
+ const [deleting, setDeleting] = useState(false)
+ const [sortKey, setSortKey] = useState('name')
+ const [sortDir, setSortDir] = useState('asc')
+ const [page, setPage] = useState(1)
+ const PAGE_SIZE = 20
+
+ const fetchStaff = useCallback(async () => {
+ setLoading(true)
+ try {
+ const params = new URLSearchParams()
+ if (search) params.set('search', search)
+ if (roleFilter) params.set('role', roleFilter)
+ const qs = params.toString()
+ const data = await api.get(`/staff${qs ? `?${qs}` : ''}`)
+ setStaff(data.staff || [])
+ setPage(1)
+ } catch (err) {
+ toast.danger('Failed to load staff', err.message)
+ } finally {
+ setLoading(false)
+ }
+ }, [search, roleFilter])
+
+ useEffect(() => { fetchStaff() }, [fetchStaff])
+
+ const handleDelete = async () => {
+ if (!deleteTarget) return
+ setDeleting(true)
+ try {
+ await api.delete(`/staff/${deleteTarget.id}`)
+ toast.success('Staff member deleted', `${deleteTarget.name} has been removed.`)
+ setDeleteTarget(null)
+ fetchStaff()
+ } catch (err) {
+ toast.danger('Delete failed', err.message)
+ } finally {
+ setDeleting(false)
+ }
+ }
+
+ const handleSort = (key, dir) => { setSortKey(key); setSortDir(dir) }
+
+ // Client-side sort (API doesn't support sort params)
+ const sorted = [...staff].sort((a, b) => {
+ const av = a[sortKey] ?? ''
+ const bv = b[sortKey] ?? ''
+ const cmp = String(av).localeCompare(String(bv), undefined, { sensitivity: 'base', numeric: true })
+ return sortDir === 'asc' ? cmp : -cmp
+ })
+
+ // Paginate
+ const paginated = sorted.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE)
+
+ const columns = buildColumns(navigate, user, setDeleteTarget)
+
+ return (
+
+
+ navigate('/settings/staff/new')}>
+
+ Add Staff Member
+
+
+
+ {/* Toolbar */}
+
+
+
+
+
+ setRoleFilter(e.target.value)}
+ >
+ {ROLE_OPTIONS.map((o) => (
+ {o.label}
+ ))}
+
+
+ {!loading && (
+
+ {sorted.length} {sorted.length === 1 ? 'member' : 'members'}
+
+ )}
+
+
+ {/* Table */}
+
navigate(`/settings/staff/${row.id}`)}
+ emptyMessage="No staff members found"
+ emptyDescription={search || roleFilter ? 'Try adjusting your search or filter.' : 'Add the first staff member to get started.'}
+ />
+
+ {sorted.length > PAGE_SIZE && (
+
+ )}
+
+ setDeleteTarget(null)}
+ />
+
+ )
+}
diff --git a/frontend/src/providers/AuthProvider.jsx b/frontend/src/providers/AuthProvider.jsx
new file mode 100644
index 0000000..b7139c3
--- /dev/null
+++ b/frontend/src/providers/AuthProvider.jsx
@@ -0,0 +1,133 @@
+import { createContext, useContext, useState, useEffect } from "react";
+import api from "@/lib/api";
+
+const AuthContext = createContext(null);
+
+export function AuthProvider({ children }) {
+ const [user, setUser] = useState(() => {
+ const stored = localStorage.getItem("user");
+ return stored ? JSON.parse(stored) : null;
+ });
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ const token = localStorage.getItem("access_token");
+ if (!token) {
+ setUser(null);
+ setLoading(false);
+ return;
+ }
+
+ try {
+ const payload = JSON.parse(atob(token.split(".")[1]));
+ if (payload.exp * 1000 < Date.now()) {
+ localStorage.removeItem("access_token");
+ localStorage.removeItem("user");
+ setUser(null);
+ }
+ } catch {
+ localStorage.removeItem("access_token");
+ localStorage.removeItem("user");
+ setUser(null);
+ }
+ setLoading(false);
+ }, []);
+
+ const login = async (email, password) => {
+ const data = await api.post("/auth/login", { email, password });
+ localStorage.setItem("access_token", data.access_token);
+ const userInfo = {
+ name: data.name,
+ role: data.role,
+ permissions: data.permissions || null,
+ };
+ localStorage.setItem("user", JSON.stringify(userInfo));
+ setUser(userInfo);
+
+ // Fetch full profile from /staff/me for up-to-date permissions
+ try {
+ const me = await api.get("/staff/me");
+ if (me.permissions) {
+ const updated = { ...userInfo, permissions: me.permissions };
+ localStorage.setItem("user", JSON.stringify(updated));
+ setUser(updated);
+ }
+ } catch {
+ // Non-critical, permissions from login response are used
+ }
+
+ return data;
+ };
+
+ const logout = () => {
+ localStorage.removeItem("access_token");
+ localStorage.removeItem("user");
+ setUser(null);
+ };
+
+ const hasRole = (...roles) => {
+ if (!user) return false;
+ if (user.role === "sysadmin") return true;
+ 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
+ if (user.role === "sysadmin" || user.role === "admin") return true;
+
+ const perms = user.permissions;
+ if (!perms) return false;
+
+ // 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];
+ };
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function useAuth() {
+ const context = useContext(AuthContext);
+ if (!context) {
+ throw new Error("useAuth must be used within an AuthProvider");
+ }
+ return context;
+}
diff --git a/frontend/src/providers/ThemeProvider.jsx b/frontend/src/providers/ThemeProvider.jsx
new file mode 100644
index 0000000..5f5c01f
--- /dev/null
+++ b/frontend/src/providers/ThemeProvider.jsx
@@ -0,0 +1,4 @@
+// TODO: implement
+export default function ThemeProvider() {
+ return null
+}
diff --git a/frontend/src/providers/ToastProvider.jsx b/frontend/src/providers/ToastProvider.jsx
new file mode 100644
index 0000000..07ccb4f
--- /dev/null
+++ b/frontend/src/providers/ToastProvider.jsx
@@ -0,0 +1,4 @@
+// TODO: implement
+export default function ToastProvider() {
+ return null
+}
diff --git a/frontend/src/router/index.jsx b/frontend/src/router/index.jsx
new file mode 100644
index 0000000..b2a0da0
--- /dev/null
+++ b/frontend/src/router/index.jsx
@@ -0,0 +1,206 @@
+// frontend/src/router/index.jsx
+// Root router for v2. All routes start from /.
+
+import { Routes, Route, Navigate } from 'react-router-dom'
+import { useAuth } from '@/hooks/useAuth'
+import MainLayout from '@/components/layout/MainLayout'
+import LoginPage from '@/pages/auth/LoginPage'
+import DashboardPage from '@/pages/dashboard/DashboardPage'
+import DeviceList from '@/pages/bellcloud/devices/DeviceList'
+import DeviceDetail from '@/pages/bellcloud/devices/DeviceDetail'
+import DeviceMapPage from '@/pages/bellcloud/devices/DeviceMapPage'
+import StyleGuide from '@/pages/dev/StyleGuide'
+import CardFontSample from '@/pages/dev/CardFontSample'
+import UserList from '@/pages/bellcloud/users/UserList'
+import UserDetail from '@/pages/bellcloud/users/UserDetail'
+import UserForm from '@/pages/bellcloud/users/UserForm'
+import MelodyList from '@/pages/bellcloud/melodies/MelodyList'
+import ArchetypeList from '@/pages/bellcloud/melodies/archetypes/ArchetypeList'
+import ArchetypeForm from '@/pages/bellcloud/melodies/archetypes/ArchetypeForm'
+import MelodyComposer from '@/pages/bellcloud/melodies/MelodyComposer'
+import MelodySettings from '@/pages/bellcloud/melodies/MelodySettings'
+import MelodyDetail from '@/pages/bellcloud/melodies/MelodyDetail'
+import MelodyForm from '@/pages/bellcloud/melodies/MelodyForm'
+import MailPage from '@/pages/crm/comms/mail/MailPage'
+import CommsPage from '@/pages/crm/comms/CommsPage'
+import ProductList from '@/pages/crm/products/ProductList'
+import ProductForm from '@/pages/crm/products/ProductForm'
+import QuotationList from '@/pages/crm/quotations/QuotationList'
+import QuotationForm from '@/pages/crm/quotations/QuotationForm'
+import StaffList from '@/pages/settings/staff/StaffList'
+import StaffDetail from '@/pages/settings/staff/StaffDetail'
+import StaffForm from '@/pages/settings/staff/StaffForm'
+import PublicFeaturesSettings from '@/pages/settings/PublicFeaturesSettings'
+import AutomationsPage from '@/pages/settings/automations/AutomationsPage'
+import ApiReferencePage from '@/pages/engineering/developer/ApiReferencePage'
+import CustomerList from '@/pages/crm/customers/CustomerList'
+import CustomerDetail from '@/pages/crm/customers/CustomerDetail'
+import CustomerForm from '@/pages/crm/customers/CustomerForm'
+import OrderList from '@/pages/crm/orders/OrderList'
+import OrderDetail from '@/pages/crm/orders/OrderDetail'
+import DeviceInventory from '@/pages/engineering/manufacturing/DeviceInventory'
+import DeviceInventoryDetail from '@/pages/engineering/manufacturing/DeviceInventoryDetail'
+import FlashAssetManager from '@/pages/engineering/manufacturing/FlashAssetManager'
+import FirmwareManagerPage from '@/pages/engineering/firmware/FirmwareManagerPage'
+import ProvisioningWizard from '@/pages/engineering/manufacturing/ProvisioningWizard'
+import HelpdeskPage from '@/pages/crm/comms/helpdesk/HelpdeskPage'
+
+// ---------------------------------------------------------------------------
+// Coming Soon placeholder
+// ---------------------------------------------------------------------------
+function ComingSoon() {
+ return (
+
+
+
+
+
Coming Soon
+
This page is being built.
+
+ )
+}
+
+// ---------------------------------------------------------------------------
+// ProtectedRoute
+// ---------------------------------------------------------------------------
+function ProtectedRoute() {
+ const { user, loading } = useAuth()
+ if (loading) {
+ return (
+
+ )
+ }
+ if (!user) return
+ return
+}
+
+// ---------------------------------------------------------------------------
+// PermissionGate
+// ---------------------------------------------------------------------------
+function PermissionGate({ section, action = 'view', children }) {
+ const { hasPermission } = useAuth()
+ if (!hasPermission(section, action)) return
+ return children
+}
+
+// ---------------------------------------------------------------------------
+// RoleGate
+// ---------------------------------------------------------------------------
+function RoleGate({ roles, children }) {
+ const { hasRole } = useAuth()
+ if (!hasRole(...roles)) return
+ return children
+}
+
+// ---------------------------------------------------------------------------
+// AccessDenied
+// ---------------------------------------------------------------------------
+function AccessDenied() {
+ return (
+
+
+
Access Denied
+
You don't have permission to access this feature.
+
+
+ )
+}
+
+// ---------------------------------------------------------------------------
+// Root router
+// ---------------------------------------------------------------------------
+export default function V2Router() {
+ return (
+
+
+
+ {/* ── Public routes ─────────────────────────────────────────────── */}
+ } />
+ } />
+ } />
+ } />
+ } />
+
+ {/* ── Protected routes ──────────────────────────────────────────── */}
+ }>
+ } />
+
+ {/* Melodies */}
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+
+ {/* Devices */}
+ } />
+ } />
+ {/* Map preview — temporary unlisted route while map view is under construction */}
+ } />
+ } />
+ } />
+
+ {/* App Users */}
+ } />
+ } />
+ } />
+ } />
+
+ {/* MQTT */}
+ } />
+ } />
+ } />
+
+ {/* Manufacturing */}
+ } />
+ } />
+ } />
+ } />
+
+ {/* Mail */}
+ } />
+
+ {/* Helpdesk */}
+ } />
+
+ {/* CRM */}
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+
+ {/* Developer */}
+ } />
+
+ {/* Settings */}
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+
+ {/* Catch-all */}
+ } />
+
+
+
+
+ )
+}
diff --git a/frontend/src/styles/components.css b/frontend/src/styles/components.css
new file mode 100644
index 0000000..1ee306c
--- /dev/null
+++ b/frontend/src/styles/components.css
@@ -0,0 +1,3058 @@
+/*
+ * BellSystems v2 — Shared Component Styles
+ * All pseudo-class states (:hover, :focus, :active, :disabled) live here.
+ * Imported by global.css — never import directly from component files.
+ *
+ * Naming convention: .{component}-{variant/modifier}
+ * All values must use tokens from tokens.css via var().
+ */
+
+/* ==========================================================================
+ KEYFRAMES
+ ========================================================================== */
+
+@keyframes fade-in {
+ from { opacity: 0; }
+ to { opacity: 1; }
+}
+
+@keyframes slide-up {
+ from { opacity: 0; transform: translateY(8px); }
+ to { opacity: 1; transform: translateY(0); }
+}
+
+@keyframes skeleton-pulse {
+ 0%, 100% { opacity: 0.4; }
+ 50% { opacity: 0.8; }
+}
+
+@keyframes toast-in {
+ from { opacity: 0; transform: translateX(calc(100% + var(--space-6))); }
+ to { opacity: 1; transform: translateX(0); }
+}
+
+@keyframes toast-out {
+ from { opacity: 1; transform: translateX(0); max-height: 120px; margin-bottom: var(--space-2); }
+ to { opacity: 0; transform: translateX(calc(100% + var(--space-6))); max-height: 0; margin-bottom: 0; }
+}
+
+@keyframes toast-progress {
+ from { transform: scaleX(1); }
+ to { transform: scaleX(0); }
+}
+
+/* ==========================================================================
+ BUTTON (.btn)
+ ========================================================================== */
+
+.btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: var(--space-2);
+ font-family: var(--font-family-base);
+ font-weight: var(--font-weight-semibold);
+ line-height: 1;
+ border-radius: var(--radius-md);
+ border: 1px solid transparent;
+ white-space: nowrap;
+ user-select: none;
+ cursor: pointer;
+ text-decoration: none;
+ transition:
+ background var(--transition-fast),
+ color var(--transition-fast),
+ border-color var(--transition-fast),
+ box-shadow var(--transition-fast),
+ opacity var(--transition-fast),
+ filter var(--transition-fast);
+}
+
+.btn:focus-visible {
+ outline: 2px solid var(--color-border-focus);
+ outline-offset: 2px;
+}
+
+.btn:disabled,
+.btn[aria-disabled="true"] {
+ opacity: 0.4;
+ cursor: not-allowed;
+ pointer-events: none;
+}
+
+/* — Sizes — */
+.btn-sm {
+ padding: var(--space-1) var(--space-3);
+ font-size: var(--font-size-sm);
+ gap: var(--space-1);
+}
+
+.btn-md {
+ padding: var(--space-2) var(--space-4);
+ font-size: var(--font-size-base);
+}
+
+.btn-lg {
+ padding: var(--space-3) var(--space-6);
+ font-size: var(--font-size-md);
+}
+
+/* — Variants — */
+
+/* Primary: Indigo Glow → Violet Pulse gradient */
+.btn-primary {
+ background: var(--gradient-primary);
+ color: var(--color-text-inverse);
+ border-color: transparent;
+}
+.btn-primary:hover:not(:disabled):not([aria-disabled="true"]) {
+ box-shadow: var(--shadow-primary-glow);
+ filter: brightness(1.06);
+}
+.btn-primary:active:not(:disabled) {
+ filter: brightness(0.96);
+ box-shadow: none;
+}
+
+/* Secondary: Island bg + ghost border */
+.btn-secondary {
+ background: var(--color-bg-island);
+ color: var(--color-text-secondary);
+ border-color: var(--color-border-strong);
+}
+.btn-secondary:hover:not(:disabled):not([aria-disabled="true"]) {
+ background: var(--color-bg-elevated);
+ color: var(--color-text-primary);
+ border-color: var(--color-border-focus);
+ box-shadow: 0px 4px 12px rgba(192, 193, 255, 0.12);
+}
+.btn-secondary:active:not(:disabled) {
+ background: var(--color-bg-island);
+ box-shadow: none;
+}
+
+/* Ghost: transparent, on hover gets a surface tint + whisper glow */
+.btn-ghost {
+ background: transparent;
+ color: var(--color-text-secondary);
+ border-color: transparent;
+}
+.btn-ghost:hover:not(:disabled):not([aria-disabled="true"]) {
+ background: var(--color-bg-elevated);
+ color: var(--color-text-primary);
+ box-shadow: 0px 2px 8px rgba(192, 193, 255, 0.08);
+}
+.btn-ghost:active:not(:disabled) {
+ background: var(--color-bg-island);
+ box-shadow: none;
+}
+
+/* Danger: tint at rest → solid fill + coral glow on hover */
+.btn-danger {
+ background: var(--color-danger-bg);
+ color: var(--color-danger);
+ border-color: transparent;
+}
+.btn-danger:hover:not(:disabled):not([aria-disabled="true"]) {
+ background: var(--color-danger);
+ color: var(--color-bg-abyss);
+ border-color: transparent;
+ box-shadow: var(--shadow-danger-glow);
+ filter: brightness(1.06);
+}
+.btn-danger:active:not(:disabled) {
+ filter: brightness(0.94);
+ box-shadow: none;
+}
+
+/* Success: tint at rest → solid fill + emerald glow on hover */
+.btn-success {
+ background: var(--color-success-bg);
+ color: var(--color-success);
+ border-color: transparent;
+}
+.btn-success:hover:not(:disabled):not([aria-disabled="true"]) {
+ background: var(--color-success);
+ color: var(--color-bg-abyss);
+ border-color: transparent;
+ box-shadow: var(--shadow-success-glow);
+ filter: brightness(1.06);
+}
+.btn-success:active:not(:disabled) {
+ filter: brightness(0.94);
+ box-shadow: none;
+}
+
+/* Info: aqua-sky tint at rest → solid fill + sky glow on hover */
+.btn-info {
+ background: var(--color-info-bg);
+ color: var(--color-info);
+ border-color: transparent;
+}
+.btn-info:hover:not(:disabled):not([aria-disabled="true"]) {
+ background: var(--color-info);
+ color: var(--color-bg-abyss);
+ border-color: transparent;
+ box-shadow: 0 4px 16px rgba(123, 208, 255, 0.35);
+ filter: brightness(1.06);
+}
+.btn-info:active:not(:disabled) {
+ filter: brightness(0.94);
+ box-shadow: none;
+}
+
+/* Warning: amber tint at rest → solid fill + amber glow on hover */
+.btn-warning {
+ background: var(--color-warning-bg);
+ color: var(--color-warning);
+ border-color: transparent;
+}
+.btn-warning:hover:not(:disabled):not([aria-disabled="true"]) {
+ background: var(--color-warning);
+ color: var(--color-bg-abyss);
+ border-color: transparent;
+ box-shadow: 0 4px 16px rgba(251, 191, 36, 0.35);
+ filter: brightness(1.06);
+}
+.btn-warning:active:not(:disabled) {
+ filter: brightness(0.94);
+ box-shadow: none;
+}
+
+/* Table-actions: invisible at rest, becomes secondary on row-hover or direct hover */
+.btn-table-actions {
+ background: transparent;
+ color: var(--color-text-more-muted);
+ border-color: transparent;
+}
+.btn-table-actions:hover:not(:disabled):not([aria-disabled="true"]),
+.btn-table-actions[aria-expanded="true"],
+tr:hover .btn-table-actions,
+tr:focus-within .btn-table-actions {
+ background: var(--color-primary-subtle);
+ color: var(--color-text-secondary);
+ border-color: var(--color-border-strong);
+ box-shadow: 0px 4px 12px rgba(192, 193, 255, 0.12);
+}
+.btn-table-actions:active:not(:disabled) {
+ background: var(--color-bg-island);
+ box-shadow: none;
+}
+
+/* ==========================================================================
+ SEGMENTED CONTROL (.seg-ctrl)
+ A row of mutually-exclusive toggle buttons sharing one visual frame.
+ Each slot reuses .btn + .btn-{variant} + .btn-{size} — no extra colours.
+ ========================================================================== */
+
+.seg-ctrl {
+ display: inline-flex;
+ align-items: stretch;
+ border: 1px solid var(--color-border-strong);
+ border-radius: var(--radius-md);
+ overflow: hidden;
+ flex-shrink: 0;
+}
+
+/* Remove individual border-radius and the outer border from every slot.
+ Vertical padding + line-height match .input / .searchbar-input
+ so SegmentedControl is always the same height as search inputs in toolbars. */
+.seg-ctrl__btn {
+ border-radius: 0 !important;
+ border-top: none !important;
+ border-bottom: none !important;
+ border-left: none !important;
+ border-right: none !important;
+ position: relative;
+ padding-top: var(--space-3) !important;
+ padding-bottom: var(--space-3) !important;
+ line-height: var(--line-height-base) !important;
+}
+
+/* Internal dividers between buttons */
+.seg-ctrl__btn + .seg-ctrl__btn::before {
+ content: '';
+ position: absolute;
+ left: 0;
+ top: 20%;
+ height: 60%;
+ width: 1px;
+ background: var(--color-border-strong);
+ pointer-events: none;
+}
+
+/* Active item removes the left divider line */
+.seg-ctrl__btn[aria-pressed="true"]::before,
+.seg-ctrl__btn[aria-pressed="true"] + .seg-ctrl__btn::before {
+ opacity: 0;
+}
+
+/* ==========================================================================
+ ICON BUTTON GROUP (.icon-btn-group)
+ A compact strip of icon-only action buttons: [ icon | icon | icon ]
+ Each slot reuses .btn + .btn-{variant} + .btn-{size}.
+ ========================================================================== */
+
+.icon-btn-group {
+ display: inline-flex;
+ align-items: stretch;
+ border: 1px solid var(--color-border-strong);
+ border-radius: var(--radius-md);
+ overflow: hidden;
+ flex-shrink: 0;
+}
+
+.icon-btn-group__btn {
+ border-radius: 0 !important;
+ border: none !important;
+ position: relative;
+ /* Match the height of .input / .searchbar-input (12px top+bottom padding + 1.5 line-height on 14px = 45px).
+ Vertical padding overrides .btn-md's 8px so the group sits flush in a toolbar row. */
+ padding-top: var(--space-3) !important;
+ padding-bottom: var(--space-3) !important;
+ padding-left: var(--space-3) !important;
+ padding-right: var(--space-3) !important;
+}
+
+.icon-btn-group__btn + .icon-btn-group__btn::before {
+ content: '';
+ position: absolute;
+ left: 0;
+ top: 20%;
+ height: 60%;
+ width: 1px;
+ background: var(--color-border-strong);
+ pointer-events: none;
+}
+
+/* ==========================================================================
+ INPUT (.input)
+ ========================================================================== */
+
+.input {
+ width: 100%;
+ /* Cutout well — sits one tonal step below the card surface */
+ background: var(--color-bg-abyss);
+ color: var(--color-text-primary);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-md);
+ padding: var(--space-3) var(--space-4);
+ font-family: var(--font-family-base);
+ font-size: var(--font-size-base);
+ line-height: var(--line-height-base);
+ outline: none;
+ /*
+ * Three-layer inset stack:
+ * 1. Deep top shadow — the "wall" above the cutout casts darkness in
+ * 2. Soft ambient well — general depth across the whole recess
+ * 3. Hairline bottom — faint light bounce off the floor of the cutout
+ */
+ box-shadow:
+ inset 0 2px 5px rgba(0, 0, 0, 0.55),
+ inset 0 1px 10px rgba(0, 0, 0, 0.30),
+ inset 0 -1px 0 rgba(192, 193, 255, 0.04);
+ transition:
+ box-shadow var(--transition-fast),
+ background var(--transition-fast);
+}
+
+.input::placeholder {
+ color: var(--color-text-muted);
+}
+
+.input:focus {
+ background: var(--color-bg-base);
+ border-color: var(--color-border-focus);
+ box-shadow:
+ inset 0 2px 5px rgba(0, 0, 0, 0.60),
+ inset 0 1px 10px rgba(0, 0, 0, 0.35),
+ inset 0 -1px 0 rgba(192, 193, 255, 0.06),
+ 0 0 0 2px var(--color-border-focus);
+}
+
+.input:disabled {
+ opacity: 0.45;
+ cursor: not-allowed;
+}
+
+/* Remove number input spinners globally */
+.input[type="number"]::-webkit-inner-spin-button,
+.input[type="number"]::-webkit-outer-spin-button {
+ -webkit-appearance: none;
+ margin: 0;
+}
+
+.input-error {
+ border-color: var(--color-danger) !important;
+ box-shadow:
+ inset 0 2px 5px rgba(0, 0, 0, 0.55),
+ inset 0 1px 10px rgba(0, 0, 0, 0.30),
+ inset 0 -1px 0 rgba(255, 92, 92, 0.06),
+ 0 0 0 2px rgba(255, 92, 92, 0.22) !important;
+}
+
+/* ==========================================================================
+ SELECT (.select-trigger / .select-menu)
+ Custom select replaces native for full design-system control.
+ ========================================================================== */
+
+/* Trigger button — inherits all .input styles */
+.select-trigger {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--space-2);
+ cursor: pointer;
+ text-align: left;
+}
+
+.select-trigger:disabled {
+ cursor: not-allowed;
+}
+
+.select-placeholder {
+ color: var(--color-text-muted);
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.select-value {
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.select-chevron {
+ flex-shrink: 0;
+ color: var(--color-text-muted);
+ display: flex;
+ align-items: center;
+ transition: transform var(--transition-fast);
+}
+
+.select-trigger-open .select-chevron {
+ transform: rotate(180deg);
+}
+
+/* Floating dropdown panel */
+.select-menu {
+ position: fixed;
+ z-index: 9999;
+ background: var(--color-bg-elevated);
+ border: 1px solid var(--color-border-strong);
+ border-radius: var(--radius-lg);
+ box-shadow:
+ 0 4px 6px -1px rgba(0, 0, 0, 0.4),
+ 0 12px 32px -4px rgba(0, 0, 0, 0.5),
+ inset 0 1px 0 rgba(255, 255, 255, 0.04);
+ padding: var(--space-1);
+ max-height: 280px;
+ overflow-y: auto;
+ /* Animate in */
+ animation: select-open 0.12s ease-out both;
+}
+
+@keyframes select-open {
+ from { opacity: 0; transform: translateY(-4px) scale(0.98); }
+ to { opacity: 1; transform: translateY(0) scale(1); }
+}
+
+/* Option rows */
+.select-option {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ padding: var(--space-2) var(--space-3);
+ border-radius: var(--radius-md);
+ font-size: var(--font-size-sm);
+ color: var(--color-text-primary);
+ cursor: pointer;
+ transition: background var(--transition-fast);
+ user-select: none;
+}
+
+.select-option:hover:not(.select-option-disabled) {
+ background: var(--color-bg-island);
+}
+
+.select-option-selected {
+ color: var(--color-primary);
+ background: rgba(192, 193, 255, 0.08);
+}
+
+.select-option-selected:hover {
+ background: rgba(192, 193, 255, 0.12) !important;
+}
+
+.select-option-disabled {
+ opacity: 0.4;
+ cursor: not-allowed;
+}
+
+.select-option-check {
+ flex-shrink: 0;
+ color: var(--color-primary);
+}
+
+/* Keep old .select-wrapper for any legacy uses */
+.select-wrapper {
+ position: relative;
+ width: 100%;
+}
+
+.select-arrow {
+ position: absolute;
+ right: var(--space-3);
+ top: 50%;
+ transform: translateY(-50%);
+ pointer-events: none;
+ color: var(--color-text-muted);
+}
+
+/* Textarea */
+.input.textarea {
+ resize: vertical;
+ min-height: 80px;
+}
+
+/* ==========================================================================
+ STATUS BADGE (.badge)
+ ========================================================================== */
+
+.badge {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--space-1);
+ padding: 2px var(--space-2);
+ border-radius: var(--radius-full);
+ font-size: var(--font-size-xs);
+ font-weight: var(--font-weight-semibold);
+ letter-spacing: var(--tracking-wide);
+ text-transform: uppercase;
+ white-space: nowrap;
+ line-height: var(--line-height-tight);
+}
+
+.badge-dot {
+ width: 6px;
+ height: 6px;
+ border-radius: var(--radius-full);
+ background: currentColor;
+ flex-shrink: 0;
+}
+
+.badge-success { background: var(--color-success-bg); color: var(--color-success); }
+.badge-warning { background: var(--color-warning-bg); color: var(--color-warning); }
+.badge-danger { background: var(--color-danger-bg); color: var(--color-danger); }
+.badge-info { background: var(--color-info-bg); color: var(--color-info); }
+.badge-primary { background: var(--color-primary-subtle); color: var(--color-primary); }
+.badge-neutral {
+ background: var(--color-bg-elevated);
+ color: var(--color-text-muted);
+}
+
+/* ==========================================================================
+ PILL BUTTON (.pill-btn)
+ Interactive badge-sized pill that opens a dropdown. Same visual weight
+ as StatusBadge — sits inline with text and badges.
+ ========================================================================== */
+
+.pill-btn {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--space-1);
+ padding: 2px var(--space-2);
+ border-radius: var(--radius-full);
+ /* Hard-reset — overrides any inherited heading/title font size */
+ font-family: var(--font-family-base) !important;
+ font-size: var(--font-size-xs) !important;
+ font-weight: var(--font-weight-semibold) !important;
+ letter-spacing: var(--tracking-wide);
+ text-transform: uppercase;
+ white-space: nowrap;
+ line-height: var(--line-height-tight);
+ border: 1px solid transparent;
+ cursor: pointer;
+ transition: filter var(--transition-fast), box-shadow var(--transition-fast);
+ outline: none;
+ vertical-align: middle;
+}
+.pill-btn:hover { filter: brightness(1.12); }
+.pill-btn:active { filter: brightness(0.92); }
+.pill-btn:focus-visible {
+ outline: 2px solid var(--color-primary);
+ outline-offset: 2px;
+}
+
+.pill-btn-icon { display: flex; align-items: center; }
+
+/* Colour variants — mirror badge colours */
+.pill-btn-neutral { background: var(--color-bg-elevated); color: var(--color-text-muted); }
+.pill-btn-success { background: var(--color-success-bg); color: var(--color-success); }
+.pill-btn-warning { background: var(--color-warning-bg); color: var(--color-warning); }
+.pill-btn-danger { background: var(--color-danger-bg); color: var(--color-danger); }
+.pill-btn-info { background: var(--color-info-bg); color: var(--color-info); }
+.pill-btn-primary { background: var(--color-primary-subtle); color: var(--color-primary); }
+
+/* Dropdown menu */
+.pill-btn-menu {
+ min-width: 140px;
+ background: var(--color-bg-surface);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-md);
+ box-shadow: var(--shadow-lg);
+ padding: var(--space-1);
+ display: flex;
+ flex-direction: column;
+ gap: 1px;
+}
+
+.pill-btn-option {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ width: 100%;
+ padding: var(--space-2) var(--space-3);
+ border-radius: var(--radius-sm);
+ font-size: var(--font-size-sm);
+ color: var(--color-text-secondary);
+ background: transparent;
+ border: none;
+ cursor: pointer;
+ text-align: left;
+ transition: background var(--transition-fast), color var(--transition-fast);
+}
+.pill-btn-option:hover {
+ background: var(--color-bg-elevated);
+ color: var(--color-text-primary);
+}
+.pill-btn-option--active {
+ color: var(--color-primary);
+}
+.pill-btn-option--active:hover {
+ background: var(--color-primary-subtle);
+}
+
+/* ==========================================================================
+ DATA TABLE (.table)
+ ========================================================================== */
+
+.table-container {
+ background: rgba(28, 32, 38, 0.30);
+ backdrop-filter: var(--blur-modal);
+ -webkit-backdrop-filter: var(--blur-modal);
+ border-radius: var(--radius-lg);
+ border: 1px solid var(--color-border);
+ box-shadow: var(--shadow-card), var(--shadow-md);
+ overflow: hidden;
+}
+
+.table {
+ width: 100%;
+ border-collapse: collapse;
+}
+
+.table-thead th {
+ padding: var(--space-3) var(--space-4);
+ text-align: left;
+ font-size: var(--font-size-xs);
+ font-weight: var(--font-weight-semibold);
+ color: var(--color-text-secondary);
+ letter-spacing: var(--tracking-wide);
+ text-transform: uppercase;
+ background: rgba(28, 32, 38, 0.50);
+ border-bottom: 1px solid var(--color-border);
+ white-space: nowrap;
+}
+
+.table-thead th.sortable {
+ cursor: pointer;
+ user-select: none;
+}
+.table-thead th.sortable:hover {
+ color: var(--color-text-primary);
+}
+
+.table-tbody tr {
+ border-bottom: none;
+ transition: background var(--transition-fast);
+}
+/* Alternating row tint — applied via data-row-tint on so group-hover can
+ override it cleanly without a second paint layer on . */
+.table-tbody tr[data-row-tint="1"] td {
+ background: var(--color-tint-row);
+}
+.table-tbody tr.clickable {
+ cursor: pointer;
+}
+/* Default hover for standalone rows (no group) */
+.table-tbody tr:not([data-row-group]):hover td {
+ background: rgba(49, 53, 60, 0.50);
+}
+.table-tbody tr.selected td {
+ background: rgba(49, 53, 60, 0.50);
+ box-shadow: inset 3px 0 0 var(--color-primary);
+}
+
+.table-tbody td {
+ padding: var(--space-3) var(--space-4);
+ font-size: var(--font-size-base);
+ color: var(--color-text-primary);
+ vertical-align: middle;
+}
+
+/* When a main row has expanded sub-rows below it, suppress its bottom padding
+ so the combined main-row + sub-rows block has even top/bottom breathing room. */
+.table-tbody tr.dt-has-subrows td {
+ padding-bottom: var(--space-1);
+}
+
+/* Group hover: all rows in the group (main + sub-rows) tint together.
+ Overrides the tint-row background on since both rules target . */
+.table-tbody tr.dt-group-hover td {
+ background: rgba(49, 53, 60, 0.50);
+}
+
+/* Skeleton loading rows */
+.table-skeleton {
+ display: block;
+ height: 14px;
+ border-radius: var(--radius-sm);
+ background: var(--color-bg-elevated);
+ animation: skeleton-pulse 1.4s ease-in-out infinite;
+}
+
+/* Empty state */
+.table-empty {
+ padding: var(--space-16) var(--space-6);
+ text-align: center;
+ color: var(--color-text-muted);
+ font-size: var(--font-size-base);
+}
+
+.table-empty-title {
+ font-size: var(--font-size-md);
+ font-weight: var(--font-weight-medium);
+ color: var(--color-text-secondary);
+ margin-bottom: var(--space-2);
+}
+
+/* ── DataTable: drag-to-reorder + column manager ─────────────────────────── */
+
+/* Draggable column headers */
+.dt-th--draggable {
+ cursor: grab;
+}
+.dt-th--draggable:active {
+ cursor: grabbing;
+}
+.dt-th--dragging {
+ opacity: 0.35;
+}
+
+/* Column manager th cell — right-aligned, same padding as row cells, no cursor override */
+.dt-col-mgr-th {
+ padding: var(--space-3) var(--space-4) !important;
+ vertical-align: middle;
+ text-align: right !important;
+ white-space: nowrap;
+ cursor: default;
+}
+/* The Edit button inside dt-col-mgr-th uses .btn-table-actions — no extra CSS needed */
+
+/* Dropdown wrapper — portalled to body, positioned via JS */
+.dt-col-picker-wrap {
+ position: fixed;
+ z-index: 9999;
+}
+
+/* The picker panel itself */
+.dt-col-picker {
+ background: var(--color-bg-elevated);
+ border: 1px solid var(--color-border-strong);
+ border-radius: var(--radius-lg);
+ box-shadow: var(--shadow-lg);
+ padding: var(--space-2) 0;
+ min-width: 190px;
+ max-height: 340px;
+ overflow-y: auto;
+}
+
+/* Each checkbox row */
+.dt-col-picker-item {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ padding: var(--space-2) var(--space-3);
+ cursor: pointer;
+ color: var(--color-text-secondary);
+ font-size: var(--font-size-sm);
+ transition: background var(--transition-fast);
+}
+.dt-col-picker-item:hover {
+ background: var(--color-bg-island);
+}
+.dt-col-picker-item--disabled {
+ cursor: not-allowed;
+ color: var(--color-text-muted);
+ opacity: 0.6;
+}
+
+/* Sort icon wrapper */
+.dt-sort-icon {
+ display: inline-flex;
+ flex-direction: column;
+ gap: 0;
+ margin-left: var(--space-1);
+ flex-shrink: 0;
+ vertical-align: middle;
+ margin-bottom: 1px;
+}
+
+/* ==========================================================================
+ ROW ACTIONS (.row-actions-*)
+ ========================================================================== */
+
+/* Trigger button uses .btn.btn-table-actions — no extra CSS needed here */
+
+/* Portal dropdown panel */
+.row-actions-menu {
+ min-width: 168px;
+ background: var(--color-bg-elevated);
+ border: 1px solid var(--color-border-strong);
+ border-radius: var(--radius-md);
+ box-shadow: var(--shadow-lg);
+ padding: var(--space-1) 0;
+ overflow: hidden;
+}
+
+/* Each item */
+.row-actions-item {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ width: 100%;
+ padding: var(--space-2) var(--space-3);
+ background: transparent;
+ border: none;
+ border-left: none;
+ border-right: none;
+ border-bottom: none;
+ font-size: var(--font-size-sm);
+ font-weight: var(--font-weight-medium);
+ font-family: var(--font-family-base);
+ cursor: pointer;
+ text-align: left;
+ transition: background var(--transition-fast);
+}
+.row-actions-item:hover {
+ background: var(--color-bg-island);
+}
+.row-actions-item-icon {
+ display: inline-flex;
+ align-items: center;
+ flex-shrink: 0;
+}
+
+/* ==========================================================================
+ MODAL (.modal)
+ ========================================================================== */
+
+.modal-overlay {
+ position: fixed;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.60);
+ z-index: var(--z-overlay);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: var(--space-4);
+ animation: fade-in var(--transition-base) forwards;
+}
+
+.modal {
+ background: var(--color-bg-surface);
+ backdrop-filter: var(--blur-modal);
+ -webkit-backdrop-filter: var(--blur-modal);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-xl);
+ box-shadow: var(--shadow-lg);
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ max-height: calc(100dvh - var(--space-8));
+ width: 100%;
+ animation: slide-up var(--transition-base) forwards;
+ overflow: hidden;
+}
+
+.modal-sm { max-width: 480px; }
+.modal-md { max-width: 640px; }
+.modal-lg { max-width: 800px; }
+.modal-xl { max-width: 60vw; max-height: 60dvh; }
+.modal-xxl { max-width: 85vw; max-height: 85dvh; }
+.modal-full { max-width: calc(100vw - var(--space-16)); height: calc(100dvh - var(--space-16)); }
+
+.modal-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--space-4);
+ padding: var(--space-6);
+ border-bottom: 1px solid var(--color-border);
+ flex-shrink: 0;
+}
+
+.modal-title {
+ font-family: var(--font-family-display);
+ font-size: 1.125rem; /* Barlow Condensed reads larger — 18px displays like 20px body */
+ font-weight: var(--font-weight-semibold);
+ color: var(--color-text-primary);
+ line-height: var(--line-height-tight);
+ letter-spacing: var(--tracking-tight);
+}
+
+.modal-close {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 32px;
+ height: 32px;
+ border-radius: var(--radius-md);
+ background: transparent;
+ border: none;
+ color: var(--color-text-muted);
+ cursor: pointer;
+ flex-shrink: 0;
+ transition: background var(--transition-fast), color var(--transition-fast);
+}
+.modal-close:hover {
+ background: var(--color-bg-elevated);
+ color: var(--color-text-primary);
+}
+.modal-close:focus-visible {
+ outline: 2px solid var(--color-border-focus);
+ outline-offset: 2px;
+}
+
+.modal-body {
+ padding: var(--space-6);
+ overflow-y: auto;
+ flex: 1;
+}
+
+.modal-footer {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ gap: var(--space-3);
+ padding: var(--space-4) var(--space-6);
+ border-top: 1px solid var(--color-border);
+ flex-shrink: 0;
+}
+
+/* ==========================================================================
+ PAGE HEADER (.page-header)
+ ========================================================================== */
+
+.page-header {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-1);
+}
+
+.page-header-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--space-4);
+ flex-wrap: nowrap;
+}
+
+.page-header-title {
+ font-family: var(--font-family-display);
+ font-size: 1.75rem; /* 28px — Barlow Condensed reads larger than Onest at same px */
+ font-weight: var(--font-weight-semibold);
+ color: var(--color-text-primary);
+ line-height: var(--line-height-tight);
+ letter-spacing: var(--tracking-tight);
+}
+
+.page-header-subtitle {
+ font-size: var(--font-size-base);
+ color: var(--color-text-muted);
+ line-height: var(--line-height-base);
+ margin-top: var(--space-1);
+}
+
+.page-header-actions {
+ display: flex;
+ align-items: center;
+ gap: var(--space-3);
+ flex-shrink: 0;
+ flex-wrap: nowrap;
+}
+
+.page-header-actions .btn {
+ white-space: nowrap;
+ flex-shrink: 0;
+}
+
+.btn svg {
+ display: inline-block;
+ flex-shrink: 0;
+}
+
+/* ==========================================================================
+ PROFILE PAGE HEADER (.profile-header)
+ Entity detail pages: avatar + name + subtitle + optional actions.
+ Two variants: .profile-header-inner (no actions) and
+ .profile-header-inner--with-actions (actions slot).
+ ========================================================================== */
+
+.profile-header {
+ display: flex;
+ flex-direction: column;
+}
+
+/* Inner row — avatar + text side by side */
+.profile-header-inner {
+ display: flex;
+ align-items: center;
+ gap: var(--space-5);
+}
+
+/* With-actions variant: left cluster + right actions */
+.profile-header-inner--with-actions {
+ justify-content: space-between;
+ flex-wrap: wrap;
+ gap: var(--space-4);
+}
+
+.profile-header-left {
+ display: flex;
+ align-items: center;
+ gap: var(--space-5);
+ min-width: 0;
+}
+
+/* Avatar */
+.profile-header-avatar {
+ position: relative;
+ border-radius: var(--radius-xl);
+ overflow: hidden;
+ background-color: var(--color-bg-elevated);
+ border: 2px solid var(--color-primary-subtle);
+ box-shadow: var(--shadow-md);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+}
+
+.profile-header-avatar--clickable {
+ cursor: pointer;
+}
+
+.profile-header-avatar--clickable:hover .profile-header-avatar-overlay {
+ opacity: 1;
+}
+
+.profile-header-avatar-img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ display: block;
+}
+
+.profile-header-avatar-initial {
+ font-family: var(--font-family-display);
+ font-size: var(--font-size-xl);
+ font-weight: var(--font-weight-bold);
+ color: var(--color-primary);
+ user-select: none;
+}
+
+.profile-header-avatar-overlay {
+ position: absolute;
+ inset: 0;
+ background-color: rgba(10, 14, 20, 0.60);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ opacity: 0;
+ transition: opacity 0.18s ease;
+ color: var(--color-text-primary);
+}
+
+/* Text block */
+.profile-header-text {
+ min-width: 0;
+}
+
+.profile-header-name-row {
+ display: flex;
+ align-items: center;
+ gap: var(--space-3);
+ flex-wrap: wrap;
+}
+
+.profile-header-name {
+ font-family: var(--font-family-display);
+ font-size: 1.75rem;
+ font-weight: var(--font-weight-semibold);
+ color: var(--color-text-primary);
+ line-height: var(--line-height-tight);
+ letter-spacing: var(--tracking-tight);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.profile-header-badge {
+ flex-shrink: 0;
+}
+
+.profile-header-subtitle {
+ font-size: var(--font-size-base);
+ color: var(--color-text-muted);
+ margin-top: var(--space-1);
+ line-height: var(--line-height-base);
+}
+
+/* Actions slot */
+.profile-header-actions {
+ display: flex;
+ align-items: center;
+ gap: var(--space-3);
+ flex-shrink: 0;
+}
+
+/* ==========================================================================
+ BREADCRUMBS (.breadcrumbs)
+ ========================================================================== */
+
+.breadcrumbs {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ flex-wrap: wrap;
+ font-size: var(--font-size-sm);
+ color: var(--color-text-muted);
+ margin-bottom: var(--space-1);
+}
+
+.breadcrumbs a {
+ color: var(--color-text-muted);
+ text-decoration: none;
+ transition: color var(--transition-fast);
+}
+.breadcrumbs a:hover {
+ color: var(--color-text-accent);
+}
+
+.breadcrumbs-sep {
+ color: var(--color-border-raw);
+ user-select: none;
+}
+
+.breadcrumbs-current {
+ color: var(--color-text-secondary);
+}
+
+/* ==========================================================================
+ PAGINATION (.pagination)
+ ========================================================================== */
+
+.pagination {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--space-4);
+ padding-top: var(--space-4);
+ flex-wrap: wrap;
+}
+
+.pagination-info {
+ font-size: var(--font-size-sm);
+ color: var(--color-text-muted);
+}
+
+.pagination-controls {
+ display: flex;
+ align-items: center;
+ gap: var(--space-1);
+}
+
+.pagination-btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 32px;
+ height: 32px;
+ padding: 0 var(--space-2);
+ border-radius: var(--radius-md);
+ font-size: var(--font-size-sm);
+ font-weight: var(--font-weight-medium);
+ color: var(--color-text-secondary);
+ background: transparent;
+ border: 1px solid transparent;
+ cursor: pointer;
+ transition: background var(--transition-fast), color var(--transition-fast), border-color var(--transition-fast);
+ user-select: none;
+}
+.pagination-btn:hover:not(:disabled) {
+ background: var(--color-bg-elevated);
+ color: var(--color-text-primary);
+}
+.pagination-btn.active {
+ background: var(--color-primary-subtle);
+ color: var(--color-primary);
+ border-color: var(--color-primary-subtle);
+}
+.pagination-btn:disabled {
+ opacity: 0.35;
+ cursor: not-allowed;
+}
+
+/* Per-page size buttons group */
+.pagination-size-group {
+ display: flex;
+ align-items: center;
+ gap: 2px;
+ background: var(--color-bg-abyss);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-md);
+ padding: 2px;
+}
+.pagination-size-btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 32px;
+ height: 24px;
+ padding: 0 var(--space-2);
+ border: none;
+ border-radius: calc(var(--radius-md) - 2px);
+ background: transparent;
+ color: var(--color-text-muted);
+ font-size: var(--font-size-sm);
+ font-family: var(--font-family-base);
+ font-weight: var(--font-weight-normal);
+ cursor: pointer;
+ transition: background var(--transition-fast), color var(--transition-fast);
+ white-space: nowrap;
+}
+.pagination-size-btn:hover:not(.pagination-size-btn--active) {
+ background: var(--color-bg-elevated);
+ color: var(--color-text-secondary);
+}
+.pagination-size-btn.pagination-size-btn--active,
+.pagination-size-btn.pagination-size-btn--active:hover {
+ background: var(--color-bg-island);
+ color: var(--color-text-primary);
+ font-weight: var(--font-weight-medium);
+ cursor: default;
+}
+
+/* Per-page size selector inside pagination (legacy — keep for compat) */
+.pagination-size-select {
+ background: var(--color-bg-surface);
+ color: var(--color-text-secondary);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-md);
+ padding: var(--space-1) var(--space-2);
+ font-size: var(--font-size-sm);
+ font-family: var(--font-family-base);
+ cursor: pointer;
+ outline: none;
+ transition: border-color var(--transition-fast);
+}
+.pagination-size-select:focus {
+ border-color: var(--color-border-focus);
+}
+
+/* ==========================================================================
+ CARD (.card)
+ ========================================================================== */
+
+.card {
+ display: flex;
+ flex-direction: column;
+ border-radius: var(--radius-lg);
+ overflow: hidden;
+ position: relative;
+ break-inside: avoid; /* stays whole inside .masonry-grid columns */
+}
+
+.card-flat {
+ background: rgba(28, 32, 38, 0.30);
+ backdrop-filter: var(--blur-modal);
+ -webkit-backdrop-filter: var(--blur-modal);
+ box-shadow: var(--shadow-card);
+ border: 1px solid var(--color-border);
+}
+
+.card-elevated {
+ background: rgba(38, 42, 49, 0.40);
+ backdrop-filter: var(--blur-modal);
+ -webkit-backdrop-filter: var(--blur-modal);
+ box-shadow: var(--shadow-md);
+ border: 1px solid var(--color-border);
+}
+
+.card-outlined {
+ background: transparent;
+ border: 1px solid var(--color-border-strong);
+}
+
+.card-header {
+ padding: var(--space-5) var(--space-8);
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-1);
+ background: linear-gradient(
+ to bottom,
+ rgba(192, 193, 255, 0.04) 0%,
+ transparent 100%
+ );
+}
+
+.card-title-row {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ color: var(--color-text-more-muted);
+}
+
+.card-header-action {
+ margin-left: auto;
+}
+
+.card-title {
+ font-family: 'Inter', var(--font-family-base);
+ font-size: var(--font-size-xs); /* 11px */
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.10em;
+ color: var(--color-text-more-muted);
+ line-height: var(--line-height-tight);
+}
+
+/* Inset divider — lives inside .card-header so it respects the header's padding */
+.card-header-divider {
+ height: 1px;
+ background: var(--color-border);
+ margin-top: var(--space-5);
+}
+
+.card-subtitle {
+ font-size: var(--font-size-xs);
+ color: var(--color-text-muted);
+ line-height: var(--line-height-base);
+ padding-left: calc(15px + var(--space-2)); /* indent to align under title, past icon */
+}
+
+/* When there is no icon, remove the subtitle indent */
+.card-header--no-icon .card-subtitle {
+ padding-left: 0;
+}
+
+.card-body {
+ padding: var(--space-1) var(--space-8) var(--space-8);
+ flex: 1;
+}
+
+.card-body-flush {
+ flex: 1;
+}
+
+/* Field label — subsection labels inside card bodies.
+ Usage: or */
+.card-field-label {
+ font-family: 'Inter', var(--font-family-base);
+ font-size: var(--font-size-xs);
+ font-weight: var(--font-weight-semibold);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ color: var(--color-text-more-muted);
+ line-height: var(--line-height-tight);
+}
+
+.card-footer {
+ padding: var(--space-4) var(--space-8);
+ border-top: 1px solid var(--color-border);
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ gap: var(--space-3);
+ background: rgba(0, 0, 0, 0.08);
+}
+
+/* ==========================================================================
+ TABS (.tabs)
+ ========================================================================== */
+
+/* — Line variant — */
+.tabs-line {
+ width: 100%;
+ border-bottom: 1px solid var(--color-border);
+ overflow-x: auto;
+ scrollbar-width: none;
+}
+.tabs-line::-webkit-scrollbar { display: none; }
+
+.tabs-line__inner {
+ position: relative;
+ display: flex;
+ align-items: flex-end;
+ gap: var(--space-5);
+ width: 100%;
+ min-width: max-content;
+ max-width: 2000px;
+ margin: 0 auto;
+}
+
+@media (min-width: 2001px) {
+ .tabs-line__inner {
+ justify-content: center;
+ }
+}
+
+.tabs-line .tab {
+ position: relative;
+ display: inline-flex;
+ align-items: center;
+ gap: var(--space-2);
+ padding: var(--space-3) var(--space-5);
+ font-size: var(--font-size-sm);
+ font-weight: var(--font-weight-medium);
+ color: var(--color-text-muted);
+ background: transparent;
+ border: none;
+ cursor: pointer;
+ white-space: nowrap;
+ transition: color var(--transition-fast);
+ user-select: none;
+ font-family: var(--font-family-base);
+}
+.tabs-line .tab:hover { color: var(--color-text-secondary); }
+.tabs-line .tab.active { color: var(--color-primary); }
+
+/* Sliding indicator — position set via JS */
+.tabs-indicator {
+ position: absolute;
+ bottom: -1px;
+ height: 2px;
+ background: var(--gradient-primary);
+ border-radius: var(--radius-full) var(--radius-full) 0 0;
+ transition: left var(--transition-base), width var(--transition-base);
+ box-shadow: 0 0 10px rgba(192, 193, 255, 0.45);
+ pointer-events: none;
+}
+
+/* — Pill variant — */
+.tabs-pill {
+ display: inline-flex;
+}
+
+.tabs-pill__inner {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--space-4);
+ background: var(--color-bg-abyss);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-lg);
+ padding: var(--space-1);
+}
+
+.tabs-pill .tab {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--space-2);
+ padding: var(--space-2) var(--space-5);
+ font-size: var(--font-size-sm);
+ font-weight: var(--font-weight-medium);
+ color: var(--color-text-muted);
+ background: transparent;
+ border: none;
+ border-radius: var(--radius-md);
+ cursor: pointer;
+ white-space: nowrap;
+ transition: background var(--transition-fast), color var(--transition-fast);
+ user-select: none;
+ font-family: var(--font-family-base);
+}
+.tabs-pill .tab:hover {
+ color: var(--color-text-secondary);
+ background: var(--color-bg-elevated);
+}
+.tabs-pill .tab.active {
+ background: var(--color-bg-island);
+ color: var(--color-primary);
+}
+
+.tab-icon {
+ display: flex;
+ align-items: center;
+ flex-shrink: 0;
+}
+
+/* — Badge counts on tabs — */
+.tab-badge {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 18px;
+ height: 18px;
+ padding: 0 var(--space-1);
+ border-radius: var(--radius-full);
+ font-size: var(--font-size-xs);
+ font-weight: var(--font-weight-semibold);
+ background: var(--color-bg-island);
+ color: var(--color-text-muted);
+ line-height: 1;
+}
+.tab.active .tab-badge {
+ background: var(--color-primary-subtle);
+ color: var(--color-primary);
+}
+
+/* ==========================================================================
+ TOAST (.toast-*)
+ ========================================================================== */
+
+.toast-stack {
+ position: fixed;
+ bottom: var(--space-6);
+ right: var(--space-6);
+ z-index: var(--z-toast);
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2);
+ pointer-events: none;
+ width: 340px;
+ max-width: calc(100vw - var(--space-8));
+}
+
+.toast {
+ pointer-events: all;
+ display: flex;
+ align-items: flex-start;
+ gap: var(--space-3);
+ padding: var(--space-3) var(--space-4);
+ background: var(--color-bg-elevated);
+ border: 1px solid var(--color-border-strong);
+ border-radius: var(--radius-lg);
+ box-shadow: var(--shadow-lg);
+ position: relative;
+ overflow: hidden;
+ animation: toast-in var(--transition-base) cubic-bezier(0.21, 1.02, 0.73, 1) forwards;
+}
+
+.toast.exiting {
+ animation: toast-out var(--transition-slow) ease-in forwards;
+}
+
+/* Left accent stripe */
+.toast::before {
+ content: '';
+ position: absolute;
+ left: 0;
+ top: 0;
+ bottom: 0;
+ width: 3px;
+}
+
+/* Auto-dismiss progress bar */
+.toast-progress {
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ height: 2px;
+ transform-origin: left;
+ animation: toast-progress linear forwards;
+ opacity: 0.45;
+}
+
+.toast-icon {
+ flex-shrink: 0;
+ margin-top: 1px;
+ display: flex;
+ align-items: center;
+}
+
+.toast-content {
+ flex: 1;
+ min-width: 0;
+}
+
+.toast-title {
+ font-size: var(--font-size-sm);
+ font-weight: var(--font-weight-semibold);
+ color: var(--color-text-primary);
+ line-height: var(--line-height-tight);
+}
+
+.toast-message {
+ font-size: var(--font-size-sm);
+ color: var(--color-text-muted);
+ line-height: var(--line-height-base);
+ margin-top: 2px;
+}
+
+.toast-close {
+ flex-shrink: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 20px;
+ height: 20px;
+ border-radius: var(--radius-sm);
+ background: transparent;
+ border: none;
+ color: var(--color-text-muted);
+ cursor: pointer;
+ transition: background var(--transition-fast), color var(--transition-fast);
+ margin-top: 1px;
+}
+.toast-close:hover {
+ background: var(--color-bg-island);
+ color: var(--color-text-primary);
+}
+
+/* Variant colouring */
+.toast-success::before { background: var(--color-success); }
+.toast-success .toast-icon { color: var(--color-success); }
+.toast-success .toast-progress { background: var(--color-success); }
+
+.toast-warning::before { background: var(--color-warning); }
+.toast-warning .toast-icon { color: var(--color-warning); }
+.toast-warning .toast-progress { background: var(--color-warning); }
+
+.toast-danger::before { background: var(--color-danger); }
+.toast-danger .toast-icon { color: var(--color-danger); }
+.toast-danger .toast-progress { background: var(--color-danger); }
+
+.toast-info::before { background: var(--color-info); }
+.toast-info .toast-icon { color: var(--color-info); }
+.toast-info .toast-progress { background: var(--color-info); }
+
+/* ==========================================================================
+ SEARCH BAR (.searchbar)
+ ========================================================================== */
+
+.searchbar {
+ position: relative;
+ display: flex;
+ align-items: center;
+ width: 100%;
+}
+
+.searchbar-icon {
+ position: absolute;
+ left: var(--space-3);
+ display: flex;
+ align-items: center;
+ color: var(--color-text-muted);
+ pointer-events: none;
+ transition: color var(--transition-fast);
+ z-index: 1;
+}
+
+.searchbar-input {
+ width: 100%;
+ background: var(--color-bg-abyss);
+ color: var(--color-text-primary);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-md);
+ padding: var(--space-3) var(--space-10) var(--space-3) var(--space-8);
+ font-family: var(--font-family-base);
+ font-size: var(--font-size-base);
+ line-height: var(--line-height-base);
+ outline: none;
+ box-shadow:
+ inset 0 2px 5px rgba(0, 0, 0, 0.55),
+ inset 0 1px 10px rgba(0, 0, 0, 0.30),
+ inset 0 -1px 0 rgba(192, 193, 255, 0.04);
+ transition:
+ box-shadow var(--transition-fast),
+ border-color var(--transition-fast),
+ background var(--transition-fast);
+}
+
+.searchbar-input::placeholder { color: var(--color-text-muted); }
+.searchbar-input::-webkit-search-cancel-button,
+.searchbar-input::-webkit-search-decoration { display: none; appearance: none; }
+
+.searchbar-input:focus {
+ background: var(--color-bg-base);
+ border-color: var(--color-border-focus);
+ box-shadow:
+ inset 0 2px 5px rgba(0, 0, 0, 0.60),
+ inset 0 1px 10px rgba(0, 0, 0, 0.35),
+ inset 0 -1px 0 rgba(192, 193, 255, 0.06),
+ 0 0 0 2px var(--color-border-focus);
+}
+
+.searchbar:focus-within .searchbar-icon {
+ color: var(--color-primary);
+}
+
+.searchbar-clear {
+ position: absolute;
+ right: var(--space-2);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 24px;
+ height: 24px;
+ border-radius: var(--radius-sm);
+ background: var(--color-bg-island);
+ border: none;
+ color: var(--color-text-muted);
+ cursor: pointer;
+ transition: background var(--transition-fast), color var(--transition-fast);
+}
+.searchbar-clear:hover {
+ background: var(--color-bg-float);
+ color: var(--color-text-primary);
+}
+
+/* ==========================================================================
+ CONFIRM DIALOG (.confirm-*)
+ ========================================================================== */
+
+.confirm-body {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ text-align: center;
+ gap: var(--space-4);
+ padding: var(--space-4) 0;
+}
+
+.confirm-icon-wrap {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 52px;
+ height: 52px;
+ border-radius: var(--radius-full);
+ flex-shrink: 0;
+}
+
+.confirm-icon-wrap-danger {
+ background: var(--color-danger-bg);
+ color: var(--color-danger);
+ border: 1px solid rgba(255, 92, 92, 0.20);
+}
+
+.confirm-icon-wrap-primary {
+ background: var(--color-primary-subtle);
+ color: var(--color-primary);
+ border: 1px solid rgba(192, 193, 255, 0.20);
+}
+
+.confirm-message {
+ font-size: var(--font-size-base);
+ color: var(--color-text-secondary);
+ line-height: var(--line-height-relaxed);
+ max-width: 320px;
+}
+
+/* ==========================================================================
+ LAYOUT — Sidebar-first fixed layout
+ Sidebar : position fixed, full-height left column (224px).
+ Header : position fixed, top-right only (left: sidebar-width).
+ Content : offset by sidebar margin-left + header padding-top.
+ ========================================================================== */
+
+/* Sidebar: fixed, full height — glass treatment */
+.sidebar {
+ position: fixed;
+ left: 0;
+ top: 0;
+ height: 100dvh;
+ width: var(--sidebar-width);
+ z-index: var(--z-sticky);
+ display: flex;
+ flex-direction: column;
+ background-color: rgba(28, 32, 38, 0.40);
+ backdrop-filter: var(--blur-modal);
+ -webkit-backdrop-filter: var(--blur-modal);
+ border-right: 1px solid var(--color-border);
+ overflow-y: auto;
+ overflow-x: hidden;
+ box-shadow: var(--shadow-lg);
+}
+
+/* Main area: offset from fixed sidebar — transparent so bg shows through */
+.main-area {
+ margin-left: var(--sidebar-width);
+ flex: 1;
+ min-width: 0;
+ height: 100dvh;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+}
+
+/* Content: fills remaining space, scrolls internally — transparent so bg shows through */
+.main-content {
+ flex: 1;
+ padding-top: var(--header-height);
+ overflow-y: auto;
+ overflow-x: hidden;
+ min-width: 0;
+}
+
+/* Sidebar brand header */
+.sidebar-brand {
+ padding: var(--space-5) var(--space-6);
+ flex-shrink: 0;
+}
+
+/* Sidebar scrollable nav area */
+.sidebar-nav {
+ flex: 1;
+ padding: var(--space-3) 0 var(--space-4);
+ overflow-y: auto;
+ overflow-x: hidden;
+}
+
+/* Settings footer — pinned at bottom */
+.sidebar-footer {
+ border-top: 1px solid var(--color-border);
+ padding: var(--space-2) 0;
+ flex-shrink: 0;
+}
+
+/* Section separator labels — text + soft rule to the right */
+.nav-sep {
+ padding: var(--space-5) var(--space-6) var(--space-1);
+ display: flex;
+ align-items: center;
+ gap: var(--space-3);
+}
+.nav-sep--first {
+ padding-top: var(--space-3);
+}
+.nav-sep::after {
+ content: '';
+ flex: 1;
+ height: 1px;
+ background: var(--color-sidebar-text-more-muted);
+}
+.nav-sep-label {
+ flex-shrink: 0;
+ display: block;
+ font-family: var(--font-family-sidebar);
+ font-size: var(--font-size-2xs);
+ font-weight: var(--font-weight-semibold);
+ color: var(--color-sidebar-text-muted);
+ letter-spacing: var(--tracking-snug);
+ text-transform: uppercase;
+ user-select: none;
+}
+
+/* Top-level nav link
+ Scoped under .sidebar to beat .app a { color } specificity (0,1,1) → this gives (0,2,1) */
+.sidebar .nav-link {
+ display: flex;
+ align-items: center;
+ gap: var(--space-3);
+ padding: var(--space-2) var(--space-6);
+ text-decoration: none;
+ font-family: var(--font-family-sidebar);
+ font-size: var(--font-size-sm);
+ color: var(--color-sidebar-text);
+ font-weight: var(--font-weight-medium);
+ background: transparent;
+ box-shadow: inset 3px 0 0 transparent;
+ transition: color var(--transition-fast), background var(--transition-fast);
+}
+.sidebar .nav-link:not(.active):hover {
+ background: var(--color-sidebar-hover-bg);
+ color: var(--color-text-primary);
+}
+.sidebar .nav-link.active {
+ color: var(--color-sidebar-active-text);
+ font-weight: var(--font-weight-medium);
+ background: var(--color-primary-subtle);
+ box-shadow: inset 3px 0 0 var(--color-sidebar-active-bar);
+}
+
+/* Group toggle button */
+.nav-group-btn {
+ width: 100%;
+ display: flex;
+ align-items: center;
+ gap: var(--space-3);
+ padding: var(--space-2) var(--space-6);
+ border: none;
+ background: transparent;
+ cursor: pointer;
+ color: var(--color-sidebar-text);
+ font-family: var(--font-family-sidebar);
+ font-size: var(--font-size-sm);
+ font-weight: var(--font-weight-medium);
+ text-align: left;
+ box-shadow: inset 3px 0 0 transparent;
+ transition: color var(--transition-fast), background var(--transition-fast);
+}
+.nav-group-btn:hover {
+ background: var(--color-sidebar-hover-bg);
+ color: var(--color-text-primary);
+}
+.nav-group-btn--active {
+ color: var(--color-sidebar-text);
+ font-weight: var(--font-weight-medium);
+}
+.nav-group-btn--active:hover {
+ background: var(--color-sidebar-hover-bg);
+}
+.nav-group-btn:disabled {
+ opacity: 0.38;
+ cursor: not-allowed;
+}
+.nav-group-btn:focus-visible {
+ outline: 2px solid var(--color-border-focus);
+ outline-offset: -2px;
+}
+
+/* Chevron rotation */
+.nav-chevron {
+ display: inline-flex;
+ color: var(--color-text-muted);
+ transition: transform var(--transition-fast);
+ flex-shrink: 0;
+ margin-left: auto;
+}
+.nav-chevron--open {
+ transform: rotate(90deg);
+}
+
+/* Children container — vertical guide line under parent icon */
+.nav-children {
+ position: relative;
+ padding: var(--space-1) 0;
+}
+.nav-children::before {
+ content: '';
+ position: absolute;
+ left: calc(var(--space-6) + 7px); /* centre of parent icon (24px padding + 7px ≈ icon midpoint) */
+ top: 0;
+ bottom: 0;
+ width: 1px;
+ background: var(--color-border-strong);
+ pointer-events: none;
+}
+
+/* Child nav links — indented to the right of the guide line
+ Scoped under .sidebar to beat .app a { color } specificity */
+.sidebar .nav-child-link {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ padding: var(--space-2) var(--space-6) var(--space-2) calc(var(--space-6) + 16px + var(--space-3));
+ text-decoration: none;
+ font-family: var(--font-family-sidebar);
+ font-size: var(--font-size-sm);
+ color: var(--color-sidebar-text);
+ font-weight: var(--font-weight-medium);
+ box-shadow: inset 3px 0 0 transparent;
+ transition: color var(--transition-fast), background var(--transition-fast);
+}
+.sidebar .nav-child-link:not(.active):hover {
+ color: var(--color-text-primary);
+ background: var(--color-sidebar-hover-bg);
+}
+.sidebar .nav-child-link.active {
+ color: var(--color-sidebar-active-text);
+ font-weight: var(--font-weight-medium);
+ background: var(--color-primary-subtle);
+ box-shadow: inset 3px 0 0 var(--color-sidebar-active-bar);
+}
+.nav-child-link:focus-visible {
+ outline: 2px solid var(--color-border-focus);
+ outline-offset: -2px;
+}
+
+/* Placeholder items */
+.nav-placeholder {
+ display: flex;
+ align-items: center;
+ gap: var(--space-3);
+ opacity: 0.38;
+ cursor: not-allowed;
+ user-select: none;
+ color: var(--color-sidebar-text);
+ font-family: var(--font-family-sidebar);
+ font-size: var(--font-size-sm);
+ font-weight: var(--font-weight-medium);
+}
+.nav-placeholder--root {
+ padding: var(--space-2) var(--space-6);
+ border-left: 3px solid transparent;
+}
+.nav-placeholder--child {
+ padding: var(--space-2) var(--space-6) var(--space-2) calc(var(--space-6) + 16px + var(--space-3));
+ font-size: var(--font-size-sm);
+ color: var(--color-sidebar-text);
+}
+.nav-soon-badge {
+ margin-left: auto;
+ font-size: 9px;
+ font-weight: var(--font-weight-bold);
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ color: var(--color-text-muted);
+ background-color: var(--color-bg-elevated);
+ padding: 1px 5px;
+ border-radius: var(--radius-sm);
+ flex-shrink: 0;
+}
+
+/* ─── HeaderSearch — top-bar only, pill-shaped, no border/shadow ─────────────
+ Use this instead of when placing search in the fixed header.
+ Background: --color-bg-surface. Fully rounded. Completely borderless.
+ ─────────────────────────────────────────────────────────────────────────── */
+
+.topbar-search {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ background: var(--color-bg-elevated);
+ border-radius: var(--radius-full);
+ padding: var(--space-1) var(--space-4) var(--space-1) var(--space-3);
+ width: 100%;
+ transition: background var(--transition-fast);
+}
+.topbar-search:focus-within {
+ background: var(--color-bg-island);
+ outline: none;
+ box-shadow: none;
+}
+.topbar-search-icon {
+ display: flex;
+ align-items: center;
+ flex-shrink: 0;
+ color: var(--color-text-muted);
+}
+.topbar-search-input {
+ flex: 1;
+ min-width: 0;
+ background: transparent;
+ border: none;
+ outline: none;
+ box-shadow: none;
+ color: var(--color-text-primary);
+ font-family: var(--font-family-base);
+ font-size: var(--font-size-sm);
+ font-weight: var(--font-weight-normal);
+ padding: 0;
+ line-height: 1;
+}
+.topbar-search .topbar-search-input:focus,
+.topbar-search .topbar-search-input:focus-visible {
+ outline: none;
+ border: none;
+ box-shadow: none;
+ --tw-ring-shadow: 0 0 #0000;
+ --tw-ring-offset-shadow: 0 0 #0000;
+ --tw-shadow: 0 0 #0000;
+}
+.topbar-search-input::placeholder {
+ color: var(--color-text-muted);
+}
+/* Strip browser chrome from type="search" */
+.topbar-search-input::-webkit-search-decoration,
+.topbar-search-input::-webkit-search-cancel-button,
+.topbar-search-input::-webkit-search-results-button,
+.topbar-search-input::-webkit-search-results-decoration {
+ display: none;
+}
+
+/* Header: fixed, covers content area only (not sidebar) — glass treatment */
+.header {
+ position: fixed;
+ top: 0;
+ left: var(--sidebar-width);
+ right: 0;
+ height: var(--header-height);
+ z-index: var(--z-sticky);
+ background-color: rgba(28, 32, 38, 0.30);
+ backdrop-filter: var(--blur-modal);
+ -webkit-backdrop-filter: var(--blur-modal);
+ border-bottom: 1px solid rgba(70, 69, 84, 0.12);
+ display: flex;
+ align-items: center;
+ padding: 0 var(--space-6);
+ gap: var(--space-3);
+}
+
+/* Pushes right controls to the far right */
+.header-spacer {
+ flex: 1;
+}
+
+/* Right control group */
+.header-right {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ flex-shrink: 0;
+}
+
+/* Mobile hamburger */
+.header-hamburger {
+ display: none;
+ align-items: center;
+ justify-content: center;
+ width: 32px;
+ height: 32px;
+ border-radius: var(--radius-md);
+ background: transparent;
+ border: none;
+ cursor: pointer;
+ color: var(--color-text-muted);
+ flex-shrink: 0;
+ padding: 0;
+ transition: background var(--transition-fast), color var(--transition-fast);
+}
+.header-hamburger:hover {
+ background: var(--color-bg-elevated);
+ color: var(--color-text-primary);
+}
+
+/* Breadcrumb trail */
+.header-breadcrumb-nav {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ font-size: var(--font-size-sm);
+ min-width: 0;
+ overflow: hidden;
+}
+.header-breadcrumb-link {
+ color: var(--color-text-muted);
+ text-decoration: none;
+ white-space: nowrap;
+ font-weight: var(--font-weight-medium);
+ transition: color var(--transition-fast);
+}
+.header-breadcrumb-link:hover {
+ color: var(--color-text-secondary);
+}
+.header-breadcrumb-current {
+ color: var(--color-text-secondary);
+ font-weight: var(--font-weight-semibold);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ max-width: 220px;
+}
+.header-breadcrumb-sep {
+ display: flex;
+ align-items: center;
+ color: var(--color-text-muted);
+ opacity: 0.45;
+ flex-shrink: 0;
+}
+
+/* Global search wrapper — constrained width */
+.header-search {
+ width: 220px;
+ flex-shrink: 0;
+}
+
+/* Pill-shaped top-bar search — wrapper */
+.v2-topbar-search {
+ position: relative;
+ display: flex;
+ align-items: center;
+ width: 100%;
+ border-radius: var(--radius-full);
+ background: var(--color-bg-elevated);
+ transition: background var(--transition-fast);
+}
+.v2-topbar-search:focus-within {
+ background: var(--color-bg-island);
+}
+
+/* Search icon — sits in normal flow, left of the input */
+.v2-topbar-search-icon {
+ display: flex;
+ align-items: center;
+ flex-shrink: 0;
+ padding-left: var(--space-3);
+ padding-right: var(--space-1);
+ color: var(--color-text-muted);
+ pointer-events: none;
+}
+
+/* The actual input */
+.v2-topbar-search-input {
+ flex: 1;
+ height: 32px;
+ padding: 0 var(--space-3) 0 var(--space-2);
+ background: transparent;
+ border: none;
+ font-size: var(--font-size-sm);
+ color: var(--color-text-primary);
+ outline: none;
+ min-width: 0;
+ /* Remove browser default search input styling */
+ -webkit-appearance: none;
+ appearance: none;
+}
+.v2-topbar-search-input::placeholder {
+ color: var(--color-text-muted);
+}
+/* Hide native clear button */
+.v2-topbar-search-input::-webkit-search-cancel-button,
+.v2-topbar-search-input::-webkit-search-decoration {
+ -webkit-appearance: none;
+ appearance: none;
+}
+
+/* Icon buttons: bell, gear */
+.header-icon-btn {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 32px;
+ height: 32px;
+ border-radius: var(--radius-md);
+ color: var(--color-text-muted);
+ background: transparent;
+ border: none;
+ cursor: pointer;
+ padding: 0;
+ flex-shrink: 0;
+ transition: background var(--transition-fast), color var(--transition-fast);
+}
+.header-icon-btn:hover {
+ background: var(--color-bg-elevated);
+ color: var(--color-text-primary);
+}
+.header-icon-btn:focus-visible {
+ outline: 2px solid var(--color-border-focus);
+ outline-offset: 2px;
+}
+
+/* Vertical separator between icons and profile */
+.header-vsep {
+ width: 1px;
+ height: 18px;
+ background: var(--color-border-strong);
+ flex-shrink: 0;
+ margin: 0 var(--space-1);
+}
+
+/* Profile wrapper — relative so dropdown anchors to it */
+.header-profile-wrap {
+ position: relative;
+}
+
+/* Profile button */
+.header-profile {
+ display: flex;
+ align-items: center;
+ gap: var(--space-3);
+ padding: var(--space-1) var(--space-2);
+ border-radius: var(--radius-md);
+ background: transparent;
+ border: none;
+ cursor: pointer;
+ transition: background var(--transition-fast);
+}
+.header-profile:hover {
+ background: var(--color-bg-elevated);
+}
+.header-profile:focus-visible {
+ outline: 2px solid var(--color-border-focus);
+ outline-offset: 2px;
+}
+
+/* Profile info — right-aligned, stacked name + role */
+.header-profile-info {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-end;
+ gap: 2px;
+}
+.header-profile-name {
+ font-size: var(--font-size-sm);
+ font-weight: var(--font-weight-semibold);
+ color: var(--color-text-primary);
+ white-space: nowrap;
+ line-height: 1;
+}
+.header-profile-role {
+ font-size: var(--font-size-xs);
+ font-weight: var(--font-weight-medium);
+ color: var(--color-text-muted);
+ text-transform: uppercase;
+ letter-spacing: var(--tracking-snug);
+ line-height: 1;
+}
+
+/* Avatar — rounded box (radius-lg), not a circle */
+.header-avatar {
+ width: 34px;
+ height: 34px;
+ border-radius: var(--radius-lg);
+ background: var(--color-primary-subtle);
+ border: 1px solid var(--color-border-focus);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: var(--font-size-xs);
+ font-weight: var(--font-weight-bold);
+ color: var(--color-primary);
+ flex-shrink: 0;
+ font-family: var(--font-family-base);
+ letter-spacing: 0;
+}
+
+/* Profile dropdown menu */
+.profile-menu {
+ position: absolute;
+ top: calc(100% + var(--space-2));
+ right: 0;
+ min-width: 180px;
+ background: var(--color-bg-float);
+ backdrop-filter: var(--blur-modal);
+ -webkit-backdrop-filter: var(--blur-modal);
+ border: 1px solid var(--color-border-strong);
+ border-radius: var(--radius-lg);
+ box-shadow: var(--shadow-lg);
+ padding: var(--space-1);
+ z-index: var(--z-dropdown);
+}
+.profile-menu-item {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ width: 100%;
+ padding: var(--space-2) var(--space-3);
+ border-radius: var(--radius-md);
+ border: none;
+ background: transparent;
+ cursor: pointer;
+ font-family: var(--font-family-base);
+ font-size: var(--font-size-sm);
+ font-weight: var(--font-weight-medium);
+ color: var(--color-text-secondary);
+ text-align: left;
+ transition: background var(--transition-fast), color var(--transition-fast);
+}
+.profile-menu-item:hover {
+ background: var(--color-bg-elevated);
+}
+.profile-menu-item--danger {
+ color: var(--color-danger);
+}
+.profile-menu-item--danger:hover {
+ background: var(--color-danger-bg);
+ color: var(--color-danger);
+}
+
+/* Mobile drawer */
+.drawer-scrim {
+ position: fixed;
+ inset: 0;
+ z-index: var(--z-overlay);
+ background-color: rgba(10, 14, 20, 0.72);
+ backdrop-filter: blur(3px);
+ -webkit-backdrop-filter: blur(3px);
+ animation: fade-in var(--transition-base) forwards;
+}
+
+.drawer-panel {
+ position: fixed;
+ top: 0;
+ left: 0;
+ bottom: 0;
+ z-index: calc(var(--z-overlay) + 1);
+ width: var(--sidebar-width);
+ display: flex;
+ flex-direction: column;
+ background-color: var(--color-sidebar-bg);
+ border-right: 1px solid var(--color-border);
+ box-shadow: var(--shadow-lg);
+ animation: drawer-slide-in var(--transition-slow) cubic-bezier(0.16, 1, 0.3, 1) forwards;
+}
+
+.drawer-titlebar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: var(--space-3) var(--space-4);
+ border-bottom: 1px solid var(--color-border);
+ flex-shrink: 0;
+}
+
+.drawer-label {
+ font-size: var(--font-size-xs);
+ font-weight: var(--font-weight-bold);
+ color: var(--color-text-muted);
+ letter-spacing: var(--tracking-wide);
+ text-transform: uppercase;
+}
+
+.drawer-close {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 30px;
+ height: 30px;
+ border-radius: var(--radius-md);
+ border: none;
+ background: transparent;
+ cursor: pointer;
+ color: var(--color-text-muted);
+ transition: background var(--transition-fast), color var(--transition-fast);
+}
+.drawer-close:hover {
+ background: var(--color-bg-elevated);
+ color: var(--color-text-primary);
+}
+
+.drawer-body {
+ flex: 1;
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+}
+
+@keyframes drawer-slide-in {
+ from { transform: translateX(-100%); opacity: 0.7; }
+ to { transform: translateX(0); opacity: 1; }
+}
+
+/* Responsive breakpoints */
+@media (max-width: 767px) {
+ .sidebar { display: none; }
+ .main-area { margin-left: 0; }
+ .header { left: 0; }
+ .header-hamburger { display: flex !important; }
+ .header-breadcrumb-nav { display: none; }
+ .header-profile-info { display: none; }
+}
+
+@media (min-width: 768px) {
+ .header-hamburger { display: none !important; }
+}
+
+/* ==========================================================================
+ COMPOSE EMAIL MODAL — Quill.js overrides
+ Themes the Quill Snow editor to match the v2 design system.
+ ========================================================================== */
+
+/* Toolbar */
+.quill-compose-wrapper .ql-toolbar.ql-snow {
+ background-color: var(--color-bg-elevated);
+ border: none;
+ border-bottom: 1px solid var(--color-border) !important;
+}
+
+/* Editor area */
+.quill-compose-wrapper .ql-container.ql-snow {
+ border: none !important;
+ background-color: var(--color-bg-surface);
+ font-family: inherit;
+ font-size: 13px;
+}
+
+.quill-compose-wrapper .ql-editor {
+ color: var(--color-text-primary);
+ line-height: 1.6;
+ outline: none !important;
+ border: none !important;
+ box-shadow: none !important;
+}
+
+.quill-compose-wrapper .ql-editor:focus {
+ outline: none !important;
+ border: none !important;
+ box-shadow: none !important;
+}
+
+.quill-compose-wrapper .ql-editor.ql-blank::before {
+ color: var(--color-text-muted);
+ font-style: normal;
+}
+
+/* Toolbar icons — stroke/fill */
+.quill-compose-wrapper .ql-toolbar button .ql-stroke,
+.quill-compose-wrapper .ql-toolbar button .ql-stroke-miter,
+.quill-compose-wrapper .ql-toolbar .ql-picker-label .ql-stroke {
+ stroke: var(--color-text-secondary) !important;
+}
+
+.quill-compose-wrapper .ql-toolbar button .ql-fill,
+.quill-compose-wrapper .ql-toolbar .ql-picker-label .ql-fill {
+ fill: var(--color-text-secondary) !important;
+}
+
+.quill-compose-wrapper .ql-toolbar button:hover .ql-stroke,
+.quill-compose-wrapper .ql-toolbar button:hover .ql-stroke-miter {
+ stroke: var(--color-text-primary) !important;
+}
+
+.quill-compose-wrapper .ql-toolbar button.ql-active .ql-stroke,
+.quill-compose-wrapper .ql-toolbar button.ql-active .ql-stroke-miter {
+ stroke: var(--color-primary) !important;
+}
+
+.quill-compose-wrapper .ql-toolbar button.ql-active .ql-fill {
+ fill: var(--color-primary) !important;
+}
+
+/* Picker labels */
+.quill-compose-wrapper .ql-toolbar .ql-picker-label {
+ color: var(--color-text-secondary);
+ border: none !important;
+ position: relative;
+}
+
+.quill-compose-wrapper .ql-toolbar .ql-picker-label:hover {
+ color: var(--color-text-primary);
+}
+
+/* Picker dropdowns */
+.quill-compose-wrapper .ql-toolbar .ql-picker-options {
+ background-color: var(--color-bg-float);
+ border: 1px solid var(--color-border-strong) !important;
+ border-radius: 6px;
+ box-shadow: var(--shadow-lg);
+ z-index: 9999;
+}
+
+.quill-compose-wrapper .ql-toolbar .ql-picker-item {
+ color: var(--color-text-secondary);
+}
+
+.quill-compose-wrapper .ql-toolbar .ql-picker-item:hover,
+.quill-compose-wrapper .ql-toolbar .ql-picker-item.ql-selected {
+ color: var(--color-text-primary);
+ background-color: var(--color-bg-elevated);
+}
+
+/* Color swatches in picker */
+.quill-compose-wrapper .ql-toolbar .ql-color-picker .ql-picker-options {
+ padding: 6px;
+ width: auto;
+}
+
+/* Blockquote */
+.quill-compose-wrapper .ql-editor blockquote {
+ border-left: 3px solid var(--color-border-strong);
+ color: var(--color-text-secondary);
+ padding-left: 12px;
+ margin-left: 0;
+}
+
+/* Code block */
+.quill-compose-wrapper .ql-editor pre.ql-syntax {
+ background-color: var(--color-bg-elevated);
+ color: var(--color-text-primary);
+ border: 1px solid var(--color-border);
+ border-radius: 6px;
+}
+
+/* Links */
+.quill-compose-wrapper .ql-editor a {
+ color: var(--color-primary);
+}
+
+/* Tooltip */
+.quill-compose-wrapper .ql-tooltip {
+ background-color: var(--color-bg-surface);
+ border: 1px solid var(--color-border-strong);
+ border-radius: 6px;
+ color: var(--color-text-primary);
+ box-shadow: var(--shadow-lg);
+ z-index: 9999;
+}
+
+.quill-compose-wrapper .ql-tooltip input[type=text] {
+ background-color: var(--color-bg-elevated);
+ border: 1px solid var(--color-border-strong);
+ border-radius: 4px;
+ color: var(--color-text-primary);
+ outline: none;
+}
+
+.quill-compose-wrapper .ql-tooltip a.ql-action,
+.quill-compose-wrapper .ql-tooltip a.ql-remove {
+ color: var(--color-primary);
+}
+
+/* Inline images (Ctrl+V paste) */
+.quill-compose-wrapper .ql-editor img {
+ max-width: 100%;
+ border-radius: 4px;
+}
+
+/* ==========================================================================
+ DATE TIME PICKER (.dtp-*)
+ Two-column popover: left = calendar, right = Cupertino drum dials.
+ ========================================================================== */
+
+/* ── Trigger field ─────────────────────────────────────────────────────────── */
+
+.dtp-trigger {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--space-2);
+ cursor: pointer;
+ text-align: left;
+}
+.dtp-trigger:disabled { cursor: not-allowed; }
+.dtp-trigger-value {
+ color: var(--color-text-primary);
+ font-size: var(--font-size-base);
+ font-family: var(--font-family-mono);
+ flex: 1;
+ min-width: 0;
+}
+.dtp-trigger-placeholder {
+ color: var(--color-text-muted);
+ font-size: var(--font-size-base);
+ flex: 1;
+ min-width: 0;
+}
+.dtp-trigger-icon {
+ color: var(--color-text-muted);
+ display: flex;
+ align-items: center;
+ flex-shrink: 0;
+ transition: color var(--transition-fast);
+}
+.dtp-trigger:hover .dtp-trigger-icon,
+.dtp-trigger[aria-expanded="true"] .dtp-trigger-icon { color: var(--color-primary); }
+
+/* ── Popover shell ─────────────────────────────────────────────────────────── */
+
+.dtp-popover {
+ position: fixed;
+ z-index: var(--z-toast); /* above --z-modal (400) */
+ background: var(--color-bg-surface);
+ border: 1px solid var(--color-border-strong);
+ border-radius: var(--radius-xl);
+ box-shadow:
+ 0 4px 6px -1px rgba(0, 0, 0, 0.45),
+ 0 20px 48px -4px rgba(0, 0, 0, 0.60),
+ inset 0 1px 0 rgba(255, 255, 255, 0.04);
+ overflow: hidden;
+ width: 520px;
+ animation: dtp-open 0.14s ease-out both;
+ user-select: none;
+}
+
+@keyframes dtp-open {
+ from { opacity: 0; transform: translateY(-6px) scale(0.97); }
+ to { opacity: 1; transform: translateY(0) scale(1); }
+}
+
+/* ── Two-column body ───────────────────────────────────────────────────────── */
+
+.dtp-body {
+ display: flex;
+ height: 274px;
+}
+
+/* Left — calendar */
+.dtp-left {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ padding: var(--space-5) var(--space-4) var(--space-4);
+ min-width: 0;
+}
+
+/* Vertical divider */
+.dtp-divider {
+ width: 1px;
+ background: var(--color-border);
+ flex-shrink: 0;
+ margin: var(--space-4) 0;
+}
+
+/* Right — time dials */
+.dtp-right {
+ width: 172px;
+ flex-shrink: 0;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: flex-start;
+ padding: var(--space-5) var(--space-3) var(--space-4);
+ gap: 0;
+}
+
+/* ── Calendar nav ──────────────────────────────────────────────────────────── */
+
+.dtp-cal-nav {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: var(--space-3);
+ flex-shrink: 0;
+}
+
+.dtp-nav-btn {
+ background: transparent;
+ border: none;
+ color: var(--color-text-muted);
+ padding: var(--space-1) var(--space-2);
+ border-radius: var(--radius-sm);
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: color var(--transition-fast), background var(--transition-fast);
+}
+.dtp-nav-btn:hover {
+ color: var(--color-text-primary);
+ background: var(--color-bg-island);
+}
+
+/* Month/year label — clickable to toggle year mode */
+.dtp-month-label {
+ background: transparent;
+ border: none;
+ display: flex;
+ align-items: center;
+ gap: var(--space-1);
+ font-size: var(--font-size-sm);
+ font-weight: var(--font-weight-semibold);
+ font-family: var(--font-family-base);
+ color: var(--color-text-primary);
+ letter-spacing: var(--tracking-snug);
+ cursor: pointer;
+ padding: var(--space-1) var(--space-2);
+ border-radius: var(--radius-sm);
+ transition: color var(--transition-fast), background var(--transition-fast);
+}
+.dtp-month-label:hover { background: var(--color-bg-island); }
+.dtp-month-label--active { color: var(--color-primary); }
+.dtp-month-caret {
+ font-size: 8px;
+ opacity: 0.5;
+}
+
+/* ── Calendar grid ─────────────────────────────────────────────────────────── */
+
+.dtp-grid {
+ display: grid;
+ grid-template-columns: repeat(7, 1fr);
+ gap: 0px;
+ flex: 1;
+ align-content: start;
+}
+
+.dtp-dow {
+ text-align: center;
+ font-size: 9px;
+ font-weight: var(--font-weight-semibold);
+ color: var(--color-text-muted);
+ padding: var(--space-1) 0 var(--space-2);
+ letter-spacing: var(--tracking-wide);
+ text-transform: uppercase;
+}
+
+.dtp-day {
+ width: 28px;
+ height: 28px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: var(--font-size-sm);
+ color: var(--color-text-primary);
+ border-radius: var(--radius-sm);
+ border: none;
+ background: transparent;
+ cursor: pointer;
+ transition: background var(--transition-fast), color var(--transition-fast);
+ font-family: var(--font-family-base);
+ font-weight: var(--font-weight-normal);
+}
+.dtp-day:hover { background: var(--color-bg-island); }
+.dtp-day--other { color: var(--color-text-muted); opacity: 0.35; }
+.dtp-day--today {
+ color: var(--color-primary);
+ font-weight: var(--font-weight-semibold);
+ box-shadow: inset 0 0 0 1px var(--color-primary);
+}
+.dtp-day--selected {
+ background: var(--gradient-primary);
+ color: var(--color-text-inverse);
+ font-weight: var(--font-weight-semibold);
+ box-shadow: var(--shadow-primary-glow);
+}
+.dtp-day--selected:hover { filter: brightness(1.08); }
+
+/* ── Year scroll list ──────────────────────────────────────────────────────── */
+
+.dtp-year-list {
+ flex: 1;
+ overflow-y: auto;
+ display: flex;
+ flex-direction: column;
+ gap: 1px;
+ padding-right: var(--space-1);
+ /* slim scrollbar */
+ scrollbar-width: thin;
+ scrollbar-color: var(--color-border-raw) transparent;
+}
+.dtp-year-list::-webkit-scrollbar { width: 4px; }
+.dtp-year-list::-webkit-scrollbar-thumb { background: var(--color-border-raw); border-radius: 2px; }
+.dtp-year-list::-webkit-scrollbar-track { background: transparent; }
+
+.dtp-year-item {
+ width: 100%;
+ background: transparent;
+ border: none;
+ font-family: var(--font-family-base);
+ font-size: var(--font-size-sm);
+ color: var(--color-text-secondary);
+ padding: var(--space-2) var(--space-3);
+ border-radius: var(--radius-sm);
+ cursor: pointer;
+ text-align: left;
+ transition: background var(--transition-fast), color var(--transition-fast);
+}
+.dtp-year-item:hover { background: var(--color-bg-island); color: var(--color-text-primary); }
+.dtp-year-item--selected {
+ background: var(--color-primary-subtle);
+ color: var(--color-primary);
+ font-weight: var(--font-weight-semibold);
+}
+
+/* ── Time section labels ───────────────────────────────────────────────────── */
+
+.dtp-time-label-row {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ flex-shrink: 0;
+ /* Match height of .dtp-cal-nav so drums align with .dtp-dow row */
+ height: 30px;
+ justify-content: center;
+ margin-bottom: var(--space-3);
+}
+.dtp-section-label {
+ font-size: var(--font-size-sm);
+ font-weight: var(--font-weight-semibold);
+ color: var(--color-text-primary);
+}
+
+/* ── Drum dials container ──────────────────────────────────────────────────── */
+
+.dtp-drums-wrap {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ flex-shrink: 0;
+}
+
+.dtp-drums-sep {
+ font-size: 1.4rem;
+ font-weight: var(--font-weight-bold);
+ font-family: var(--font-family-mono);
+ color: var(--color-primary);
+ line-height: 1;
+ margin-bottom: 4px;
+}
+
+/* ── Single drum dial ──────────────────────────────────────────────────────── */
+
+.dtp-drum {
+ position: relative;
+ width: 60px;
+ overflow: hidden;
+ cursor: ns-resize;
+ border-radius: var(--radius-md);
+ background: var(--color-bg-abyss);
+ border: 1px solid var(--color-border);
+ flex-shrink: 0;
+}
+
+/* Selection highlight band behind the centre item */
+.dtp-drum-band {
+ position: absolute;
+ left: 0;
+ right: 0;
+ background: var(--color-primary-subtle);
+ border-top: 1px solid rgba(192, 193, 255, 0.20);
+ border-bottom: 1px solid rgba(192, 193, 255, 0.20);
+ pointer-events: none;
+ z-index: 1;
+}
+
+/* Individual drum item row */
+.dtp-drum-item {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-family: var(--font-family-mono);
+ color: var(--color-text-primary);
+ position: relative;
+ z-index: 2;
+ transition: opacity var(--transition-fast);
+ cursor: pointer;
+ user-select: none;
+}
+.dtp-drum-item--center {
+ cursor: text;
+ color: var(--color-primary);
+}
+
+/* Inline edit input that appears when clicking centre */
+.dtp-drum-edit {
+ width: 48px;
+ text-align: center;
+ background: transparent;
+ border: none;
+ outline: none;
+ font-family: var(--font-family-mono);
+ font-size: 1.1rem;
+ font-weight: 700;
+ color: var(--color-primary);
+ caret-color: var(--color-primary);
+ padding: 0;
+ /* Remove spinners */
+ -moz-appearance: textfield;
+}
+.dtp-drum-edit::-webkit-inner-spin-button,
+.dtp-drum-edit::-webkit-outer-spin-button { display: none; }
+
+/* Top/bottom fade masks — give the drum a natural roll-off */
+.dtp-drum-fade {
+ position: absolute;
+ left: 0;
+ right: 0;
+ height: 40%;
+ pointer-events: none;
+ z-index: 3;
+}
+.dtp-drum-fade--top {
+ top: 0;
+ background: linear-gradient(to bottom, var(--color-bg-abyss) 0%, transparent 100%);
+}
+.dtp-drum-fade--bottom {
+ bottom: 0;
+ background: linear-gradient(to top, var(--color-bg-abyss) 0%, transparent 100%);
+}
+
+/* ── Now button (inside right panel) ──────────────────────────────────────── */
+
+.dtp-now-btn {
+ display: flex;
+ align-items: center;
+ gap: var(--space-1);
+ background: var(--color-bg-island);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-sm);
+ color: var(--color-text-secondary);
+ font-size: var(--font-size-xs);
+ font-family: var(--font-family-base);
+ font-weight: var(--font-weight-semibold);
+ padding: var(--space-1) var(--space-3);
+ cursor: pointer;
+ text-transform: uppercase;
+ letter-spacing: var(--tracking-snug);
+ transition: background var(--transition-fast), color var(--transition-fast), border-color var(--transition-fast);
+ flex-shrink: 0;
+}
+.dtp-now-btn:hover {
+ background: var(--color-primary-subtle);
+ color: var(--color-primary);
+ border-color: rgba(192, 193, 255, 0.25);
+}
+
+/* ── Footer ────────────────────────────────────────────────────────────────── */
+
+.dtp-footer {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--space-3);
+ padding: var(--space-3) var(--space-5);
+ border-top: 1px solid var(--color-border);
+ background: var(--color-bg-void);
+}
+.dtp-footer-left {
+ display: flex;
+ align-items: center;
+ gap: var(--space-3);
+ min-width: 0;
+ flex: 1;
+}
+.dtp-footer-date,
+.dtp-footer-time {
+ font-family: var(--font-family-mono);
+ font-size: var(--font-size-sm);
+ font-weight: var(--font-weight-semibold);
+ color: var(--color-text-secondary);
+ flex-shrink: 0;
+}
+.dtp-footer-actions {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ flex-shrink: 0;
+}
+
+/* "Set to Now" footer button — light blue style */
+.dtp-now-btn {
+ display: flex;
+ align-items: center;
+ gap: var(--space-1);
+ background: var(--color-primary-subtle);
+ border: 1px solid rgba(192, 193, 255, 0.20);
+ border-radius: var(--radius-md);
+ color: var(--color-primary);
+ font-size: var(--font-size-sm);
+ font-family: var(--font-family-base);
+ font-weight: var(--font-weight-semibold);
+ padding: var(--space-2) var(--space-4);
+ cursor: pointer;
+ transition: background var(--transition-fast), color var(--transition-fast), border-color var(--transition-fast), box-shadow var(--transition-fast);
+ flex-shrink: 0;
+}
+.dtp-now-btn:hover {
+ background: color-mix(in srgb, var(--color-primary-subtle) 80%, var(--color-primary) 20%);
+ border-color: rgba(192, 193, 255, 0.40);
+ box-shadow: 0 0 0 1px rgba(192, 193, 255, 0.15);
+}
+
+.dtp-done-btn {
+ background: var(--gradient-primary);
+ border: none;
+ border-radius: var(--radius-md);
+ color: var(--color-text-inverse);
+ font-size: var(--font-size-sm);
+ font-family: var(--font-family-base);
+ font-weight: var(--font-weight-semibold);
+ padding: var(--space-2) var(--space-5);
+ cursor: pointer;
+ transition: filter var(--transition-fast), box-shadow var(--transition-fast);
+ flex-shrink: 0;
+}
+.dtp-done-btn:hover {
+ filter: brightness(1.06);
+ box-shadow: var(--shadow-primary-glow);
+}
diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css
new file mode 100644
index 0000000..1e655f3
--- /dev/null
+++ b/frontend/src/styles/global.css
@@ -0,0 +1,368 @@
+/*
+ * BellSystems Control Panel v2 — Global Base Styles
+ *
+ * SCOPING STRATEGY:
+ * All rules that could affect v1 are scoped under .app
+ * The .app wrapper div is placed in V2Router, wrapping all v2 output.
+ * Only truly safe globals (box-sizing, CSS tokens) are unscoped.
+ */
+
+@import "tailwindcss";
+@import './tokens.css';
+@import './components.css';
+
+/* ==========================================================================
+ SAFE GLOBALS — these do not affect v1 visually
+ ========================================================================== */
+
+/* Box sizing — safe, v1 likely already has this */
+*,
+*::before,
+*::after {
+ box-sizing: border-box;
+}
+
+/* CSS custom properties on :root — safe, tokens are inert until used */
+/* (tokens.css is imported above and defines all --color-*, --space-*, etc.) */
+
+/* ==========================================================================
+ V2 SCOPED STYLES — only apply inside .app
+ ========================================================================== */
+
+.app {
+ /* Base layout */
+ display: flex;
+ min-height: 100dvh;
+ width: 100%;
+
+ /* Typography */
+ font-family: var(--font-family-base);
+ font-size: var(--font-size-base);
+ font-weight: var(--font-weight-normal);
+ line-height: var(--line-height-base);
+ color: var(--color-text-primary);
+ background-color: var(--color-bg-base);
+
+ /* Full-viewport background — extends behind sidebar and header */
+ background-image: url('@/assets/images/background2.jpg');
+ background-size: cover;
+ background-position: center;
+ background-attachment: fixed;
+ background-repeat: no-repeat;
+
+ /* Rendering */
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ text-rendering: optimizeLegibility;
+ overflow-x: hidden;
+}
+
+/* Typography */
+.app h1,
+.app h2,
+.app h3,
+.app h4,
+.app h5,
+.app h6 {
+ font-family: var(--font-family-base);
+ font-weight: var(--font-weight-semibold);
+ line-height: var(--line-height-tight);
+ color: var(--color-text-primary);
+ margin: 0;
+ padding: 0;
+}
+
+/* Display headings — Barlow Condensed gives an engineered, instrument-panel quality */
+.app h1,
+.app h2 {
+ font-family: var(--font-family-display);
+ font-weight: var(--font-weight-semibold);
+ letter-spacing: var(--tracking-tight);
+}
+
+.app h1 { font-size: var(--font-size-xl); }
+.app h2 { font-size: var(--font-size-xl); }
+.app h3 { font-size: var(--font-size-md); }
+.app h4 { font-size: var(--font-size-base); }
+.app h5 { font-size: var(--font-size-sm); }
+.app h6 { font-size: var(--font-size-xs); }
+
+.app p {
+ color: var(--color-text-primary);
+ line-height: var(--line-height-base);
+ margin: 0;
+ padding: 0;
+}
+
+/* Links */
+.app a {
+ color: var(--color-text-accent);
+ text-decoration: none;
+ transition: color var(--transition-fast), opacity var(--transition-fast);
+}
+
+.app a:hover {
+ color: var(--color-primary-hover);
+}
+
+.app a:focus-visible {
+ outline: 2px solid var(--color-border-focus);
+ outline-offset: 2px;
+ border-radius: var(--radius-sm);
+}
+
+/* Lists */
+.app ul,
+.app ol {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+
+/* Media */
+.app img,
+.app video,
+.app svg {
+ display: block;
+ max-width: 100%;
+}
+
+/* Form elements — exclude named component classes so their styles aren't overridden */
+.app button:not(.btn):not(.select-trigger):not(.nav-group-btn):not(.header-hamburger):not(.header-signout):not(.drawer-close):not(.header-icon-btn):not(.header-profile):not(.profile-menu-item),
+.app input:not(.input):not(.searchbar-input),
+.app select:not(.input),
+.app textarea:not(.input) {
+ font-family: inherit;
+ font-size: inherit;
+ color: inherit;
+ background: transparent;
+ border: none;
+ outline: none;
+ margin: 0;
+ padding: 0;
+}
+
+.app button:not(.btn):not(.select-trigger):not(.nav-group-btn):not(.header-hamburger):not(.header-signout):not(.drawer-close):not(.header-icon-btn):not(.header-profile):not(.profile-menu-item) {
+ cursor: pointer;
+ line-height: inherit;
+}
+
+.app button:not(.btn):not(.select-trigger):not(.nav-group-btn):not(.header-hamburger):not(.header-signout):not(.drawer-close):not(.header-icon-btn):not(.header-profile):not(.profile-menu-item):disabled {
+ cursor: not-allowed;
+ opacity: 0.5;
+}
+
+.app textarea {
+ resize: vertical;
+}
+
+.app select {
+ appearance: none;
+ -webkit-appearance: none;
+}
+
+/* Autofill */
+.app input:-webkit-autofill,
+.app input:-webkit-autofill:hover,
+.app input:-webkit-autofill:focus {
+ -webkit-box-shadow: 0 0 0px 1000px var(--color-bg-abyss) inset;
+ -webkit-text-fill-color: var(--color-text-primary);
+ caret-color: var(--color-text-primary);
+ transition: background-color 5000s ease-in-out 0s;
+}
+
+/* Tables */
+.app table {
+ border-collapse: collapse;
+ width: 100%;
+}
+
+/* Code */
+.app code,
+.app kbd,
+.app samp,
+.app pre {
+ font-family: var(--font-family-mono);
+ font-size: var(--font-size-sm);
+}
+
+.app code {
+ background-color: var(--color-bg-elevated);
+ color: var(--color-primary);
+ padding: 1px var(--space-1);
+ border-radius: var(--radius-sm);
+}
+
+.app pre {
+ background-color: var(--color-bg-abyss);
+ color: var(--color-text-primary);
+ padding: var(--space-4);
+ border-radius: var(--radius-lg);
+ overflow-x: auto;
+}
+
+.app pre code {
+ background: none;
+ padding: 0;
+ color: inherit;
+}
+
+/* Focus */
+.app :focus {
+ outline: none;
+}
+
+.app :focus-visible {
+ outline: 2px solid var(--color-border-focus);
+ outline-offset: 2px;
+ border-radius: var(--radius-sm);
+}
+
+/* Selection */
+.app ::selection {
+ background-color: rgba(192, 193, 255, 0.20);
+ color: var(--color-text-primary);
+}
+
+/* HR */
+.app hr {
+ border: none;
+ border-top: 1px solid var(--color-border);
+ margin: var(--space-6) 0;
+}
+
+/* Page wrapper utility */
+.app .page-wrapper {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ padding: var(--space-8);
+ gap: var(--space-6);
+ min-width: 0;
+}
+
+@media (max-width: 768px) {
+ .app .page-wrapper {
+ padding: var(--space-8);
+ gap: var(--space-4);
+ }
+}
+
+/*
+ * Centered content mode
+ * Use on pages where content has a bounded width and should be centered in
+ * the available area regardless of viewport size (e.g. settings forms,
+ * login, simple detail pages).
+ *
+ * Usage:
+ *
+ *
+ * By default centers at --content-max-width-sm (640px).
+ * Override by adding a custom max-width inline only when truly needed:
+ * style={{ '--page-content-max-width': 'var(--content-max-width-md)' }}
+ */
+.app .page-wrapper--centered {
+ align-items: center;
+}
+
+.app .page-wrapper--centered > * {
+ width: 100%;
+ max-width: var(--page-content-max-width, var(--content-max-width-sm));
+}
+
+/*
+ * Masonry column layout
+ * Sections flow top-to-bottom then left-to-right, automatically filling
+ * the shortest column first (true CSS column masonry).
+ *
+ * Usage — wrap sections inside .page-wrapper with:
+ *
← 2 columns
+ *
← 3 columns
+ *
+ * Direct children must opt out of column breaks:
+ * Every Card / panel inside a .masonry-grid must have break-inside: avoid.
+ * The Card component already sets this via the .v2-card rule in components.css.
+ *
+ * On mobile (< 768px) all grids collapse to a single column automatically.
+ *
+ * Rules:
+ * - Use this for ALL content pages with multiple variable-height sections
+ * - Do NOT use fixed grid (grid-template-columns) for variable-height sections
+ * - Do NOT use this for DataTable pages — tables should span full width
+ */
+.app .masonry-grid {
+ columns: 1;
+ column-gap: var(--space-6);
+}
+
+.app .masonry-grid > * {
+ break-inside: avoid;
+ margin-bottom: var(--space-6);
+}
+
+.app .masonry-grid--2 { columns: 2; }
+.app .masonry-grid--3 { columns: 3; }
+.app .masonry-grid--4 { columns: 4; }
+
+@media (max-width: 1024px) {
+ .app .masonry-grid--4 { columns: 3; }
+ .app .masonry-grid--3 { columns: 2; }
+}
+
+@media (max-width: 768px) {
+ .app .masonry-grid--2,
+ .app .masonry-grid--3,
+ .app .masonry-grid--4 { columns: 1; }
+}
+
+/*
+ * Ultrawide cap mode
+ * Prevents content from spanning absurdly wide on 2000px+ displays.
+ * The wrapper itself is capped and centered — children fill it normally.
+ *
+ * Usage:
+ *
+ *
+ * The cap is token-driven — change --content-max-width-ultrawide in tokens.css
+ * to adjust for all capped pages at once.
+ */
+.app .page-wrapper--capped {
+ max-width: var(--content-max-width-ultrawide);
+ margin-inline: auto;
+ width: 100%;
+}
+
+/* Scrollbars — scoped to app */
+.app {
+ scrollbar-width: thin;
+ scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track);
+}
+
+.app ::-webkit-scrollbar {
+ width: var(--scrollbar-width);
+ height: var(--scrollbar-width);
+}
+
+.app ::-webkit-scrollbar-track {
+ background: var(--scrollbar-track);
+}
+
+.app ::-webkit-scrollbar-thumb {
+ background-color: var(--scrollbar-thumb);
+ border-radius: var(--scrollbar-radius);
+}
+
+.app ::-webkit-scrollbar-thumb:hover {
+ background-color: var(--color-border-strong);
+}
+
+/* Reduced motion */
+@media (prefers-reduced-motion: reduce) {
+ .app *,
+ .app *::before,
+ .app *::after {
+ animation-duration: 0.01ms !important;
+ animation-iteration-count: 1 !important;
+ transition-duration: 0.01ms !important;
+ }
+}
diff --git a/frontend/src/styles/themes/dark.css b/frontend/src/styles/themes/dark.css
new file mode 100644
index 0000000..36a0c98
--- /dev/null
+++ b/frontend/src/styles/themes/dark.css
@@ -0,0 +1 @@
+/* Dark theme token overrides TODO */
diff --git a/frontend/src/styles/themes/light.css b/frontend/src/styles/themes/light.css
new file mode 100644
index 0000000..63cdd31
--- /dev/null
+++ b/frontend/src/styles/themes/light.css
@@ -0,0 +1 @@
+/* Light theme token overrides TODO */
diff --git a/frontend/src/styles/tokens.css b/frontend/src/styles/tokens.css
new file mode 100644
index 0000000..008f64e
--- /dev/null
+++ b/frontend/src/styles/tokens.css
@@ -0,0 +1,436 @@
+/*
+ * BellSystems Control Panel v2 — Design Tokens
+ * Source of truth: .stitch/DESIGN.md + DESIGN.md
+ *
+ * This is a dark-first design system. :root defines the dark (default) theme.
+ * [data-theme="light"] overrides are placeholders for a future light variant.
+ *
+ * NEVER write raw hex, rgb, or pixel values in component files.
+ * ALWAYS use var(--token-name).
+ */
+
+/* ==========================================================================
+ IMPORT
+ ========================================================================== */
+
+@import url('https://fonts.googleapis.com/css2?family=Barlow+Condensed:wght@500;600;700&family=Inter:wght@400;500;600&family=Onest:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap');
+
+/* ==========================================================================
+ ROOT — Dark Theme (Default)
+ The entire design system is dark-first. :root = dark = default.
+ ========================================================================== */
+
+:root {
+
+ /* --------------------------------------------------------------------------
+ BACKGROUND SURFACES
+ 7-step tonal ladder from deepest well to frosted glass.
+ Hierarchy is achieved through surface tone — never borders.
+ -------------------------------------------------------------------------- */
+
+ --color-bg-abyss: #0a0e14; /* Deepest: nested content, code blocks */
+ --color-bg-base: #10141a; /* Midnight: main viewport / page background */
+ --color-bg-void: #181c22; /* Sidebar, header, secondary nav surfaces */
+ --color-bg-surface: #1c2026; /* Deep Slate: default card / panel background */
+ --color-bg-elevated: #262a31; /* Elevated Slate: raised cards, hovered rows */
+ --color-bg-island: #31353c; /* Island: active states, selected rows */
+ --color-bg-float: rgba(53, 57, 64, 0.80); /* Frosted Glass: modals/dropdowns + blur(12px) */
+
+ /* --------------------------------------------------------------------------
+ BRAND / PRIMARY ACCENT
+ Indigo Glow → Violet Pulse gradient pair is the signature of the system.
+ -------------------------------------------------------------------------- */
+
+ --color-primary: #c0c1ff; /* Indigo Glow: CTAs, active nav, key metrics */
+ --color-primary-hover: #d2bbff; /* Violet Pulse: hover, gradient endpoint */
+ --color-primary-container:#8083ff; /* Lavender Soft: container fills */
+ --color-primary-subtle: rgba(128, 131, 255, 0.12); /* Faint tint for hover bg */
+ --color-primary-deep: #6001d1; /* Deep Indigo: secondary container fills */
+ --color-primary-inverse: #494bd6; /* Royal Indigo: on light-over-dark contexts */
+
+ /* Gradient — used for primary button fills and accent decorations */
+ --gradient-primary: linear-gradient(135deg, #c0c1ff 0%, #d2bbff 100%);
+
+ /* --------------------------------------------------------------------------
+ TERTIARY / INFO ACCENT
+ Aqua Sky: data viz, device status indicators
+ -------------------------------------------------------------------------- */
+
+ --color-tertiary: #7bd0ff; /* Aqua Sky */
+ --color-tertiary-container:#009bd1; /* Ocean: tertiary container */
+
+ /* --------------------------------------------------------------------------
+ SEMANTIC / STATE COLORS
+ Never bright/saturated — always "glowing ink" style on dark surfaces.
+ -------------------------------------------------------------------------- */
+
+ --color-success: #4ade80; /* Emerald: online / active device status */
+ --color-success-bg: rgba(74, 222, 128, 0.12); /* Soft emerald glow bg */
+
+ --color-warning: #fbbf24; /* Amber: pending / warning status */
+ --color-warning-bg: rgba(251, 191, 36, 0.12); /* Soft amber glow bg */
+
+ --color-danger: #ff5c5c; /* Vivid Coral: error text, destructive labels */
+ --color-danger-bg: rgba(255, 92, 92, 0.12); /* Coral at low opacity */
+ --color-danger-container: #c0392b; /* Rich Red: error container backgrounds */
+
+ --color-info: #7bd0ff; /* Aqua Sky: informational state */
+ --color-info-bg: rgba(123, 208, 255, 0.12);
+
+ /* --------------------------------------------------------------------------
+ TEXT COLORS
+ 4-step text hierarchy. Body copy is never pure white.
+ -------------------------------------------------------------------------- */
+
+ --color-text-primary: #dfe2eb; /* Cloud: body copy, data values, headings */
+ --color-text-secondary: #c7c4d7; /* Mist: labels, metadata, inactive nav */
+ --color-text-muted: #908fa0; /* Ghost: placeholders, disabled, category headers */
+ --color-text-more-muted: #908fa08e;
+ --color-text-inverse: #10141a; /* Midnight: text on primary/accent backgrounds */
+ --color-text-accent: #c0c1ff; /* Indigo Glow: active nav, links, highlights */
+
+ /* --------------------------------------------------------------------------
+ BORDERS
+ The "No Raw Border" rule: visible boundaries use Boundary color at ≤20% opacity.
+ Hard full-opacity borders are forbidden.
+ -------------------------------------------------------------------------- */
+
+ --color-border: rgba(70, 69, 84, 0.20); /* Ghost border: resting inputs, outlines */
+ --color-border-strong: rgba(70, 69, 84, 0.45); /* Slightly stronger: secondary buttons */
+ --color-border-focus: rgba(192, 193, 255, 0.40); /* Indigo Glow 40%: focus ring halo */
+ --color-border-raw: #464554; /* Boundary raw: scrollbar thumb, reference only */
+
+ /* --------------------------------------------------------------------------
+ SIDEBAR
+ 224px wide, Void Navy background. Active state = 3px Indigo bar, no bg fill.
+ -------------------------------------------------------------------------- */
+
+ --color-sidebar-bg: #181c22; /* Void Navy */
+ --color-sidebar-text: #94a3b8; /* Slate-400: nav item default text */
+ --color-sidebar-text-muted: rgba(100, 116, 139, 0.75); /* Slate-500 at 75%: section labels */
+ --color-sidebar-text-more-muted: rgba(100, 116, 139, 0.30); /* Slate-500 at 50%: disabled nav items */
+ --color-sidebar-active-bg: transparent; /* No fill — left bar IS the indicator */
+ --color-sidebar-active-text: #ffffff; /* Pure white: selected nav item text */
+ --color-sidebar-active-bar: #c0c1ff; /* 3px left-edge active indicator */
+ --color-sidebar-hover-bg: rgba(49, 53, 60, 0.50); /* Island at 50% */
+
+ /* Row tint — alternating table rows: a faint indigo-tinted veil */
+ --color-tint-row: rgba(192, 193, 255, 0.045);
+
+ --sidebar-width: 224px;
+ --sidebar-width-collapsed: 56px;
+
+ /* --------------------------------------------------------------------------
+ HEADER
+ -------------------------------------------------------------------------- */
+
+ --color-header-bg: #181c22; /* Void Navy — matches sidebar */
+ --header-height: 56px;
+
+ /* ==========================================================================
+ TYPOGRAPHY
+ Two-font system: Onest (body/UI) + Barlow Condensed (display/headings).
+ JetBrains Mono for all code, data, and terminal content.
+ ========================================================================== */
+
+ /*
+ * Onest — Ukrainian geometric grotesque. Distinctive numerics, confident
+ * letterforms, excellent at 14px. Not the usual Inter/Space Grotesk rut.
+ *
+ * Barlow Condensed — Industrial condensed grotesque. Engineered feeling at
+ * display sizes. Instantly distinguishes headings from body without relying
+ * on weight alone. Rarely used in SaaS admin tools.
+ */
+
+ --font-family-base: 'Onest', system-ui, sans-serif;
+ --font-family-display: 'Barlow Condensed', system-ui, sans-serif;
+ --font-family-mono: 'JetBrains Mono', 'Fira Code', 'Consolas', 'Menlo', monospace;
+ --font-family-sidebar: 'Inter', system-ui, sans-serif; /* Sidebar nav — clean geometric grotesque */
+
+ /* Font sizes — mapped to the 5 Stitch type levels + intermediate steps */
+ --font-size-2xs: 0.625rem; /* 10px — Sidebar section headers, micro labels */
+ --font-size-xs: 0.6875rem; /* 11px — Label: sidebar category, all-caps chips */
+ --font-size-sm: 0.75rem; /* 12px — Captions, helper text */
+ --font-size-base: 0.875rem; /* 14px — Body: default text, table rows */
+ --font-size-md: 1rem; /* 16px — Title: card titles, module headers */
+ --font-size-lg: 1.125rem; /* 18px — Subheadings, section intros */
+ --font-size-xl: 1.5rem; /* 24px — Headline: page titles, major headings */
+ --font-size-2xl: 3.5rem; /* 56px — Display: hero KPI metrics, total counts */
+
+ /* Font weights */
+ --font-weight-normal: 400;
+ --font-weight-medium: 500;
+ --font-weight-semibold: 600;
+ --font-weight-bold: 700;
+
+ /* Line heights */
+ --line-height-tight: 1.2; /* Tighter for Barlow Condensed headings */
+ --line-height-base: 1.5;
+ --line-height-relaxed: 1.75;
+
+ /* Letter spacing */
+ --tracking-normal: 0em;
+ --tracking-snug: 0.04em; /* Sidebar section labels — uppercase, tight but readable */
+ --tracking-wide: 0.08em; /* Chip labels, all-caps badges */
+ --tracking-tight: -0.01em; /* Barlow Condensed headings — tighten at display size */
+ --tracking-display: -0.02em; /* Hero KPI numbers */
+
+ /* ==========================================================================
+ SPACING
+ 4px base unit. All spacing must use tokens — no arbitrary values.
+ ========================================================================== */
+
+ --space-1: 4px;
+ --space-2: 8px;
+ --space-3: 12px;
+ --space-4: 16px;
+ --space-5: 20px;
+ --space-6: 24px;
+ --space-8: 32px;
+ --space-10: 40px;
+ --space-12: 48px;
+ --space-16: 64px;
+ --space-32: 128px;
+
+ /* ==========================================================================
+ CONTENT WIDTH
+ Used by .page-wrapper--centered to constrain and center narrow-content pages.
+ Full-width pages (.page-wrapper default) ignore these.
+ ========================================================================== */
+
+ --content-max-width-xs: 480px; /* Tiny forms, login, auth pages */
+ --content-max-width-sm: 640px; /* Small forms, simple settings pages */
+ --content-max-width-md: 800px; /* Medium forms, detail-light pages */
+ --content-max-width-lg: 1024px; /* Moderate-width pages */
+ --content-max-width-ultrawide: 2000px; /* Ultrawide cap — page-wrapper--capped */
+
+ /* ==========================================================================
+ BORDER RADIUS
+ Minimal rounding — purposeful, not playful.
+ ========================================================================== */
+
+ --radius-sm: 4px; /* Subtle: small tags, inline chips */
+ --radius-md: 6px; /* Default: buttons, inputs, table cells */
+ --radius-lg: 8px; /* Cards, panels — the signature "just softened" corners */
+ --radius-xl: 12px; /* Modals, large containers */
+ --radius-full: 9999px; /* Pills: status badges, avatars — "glowing ink" shape */
+
+ /* ==========================================================================
+ SHADOWS & ELEVATION
+ No drop shadows on standard cards. Hierarchy = surface tone steps.
+ Card glow mimics ceiling light reflecting off glass surface.
+ ========================================================================== */
+
+ /* Card inner glow — the top-edge "glass ceiling" reflection */
+ --shadow-card: inset 0px 1px 0px rgba(192, 193, 255, 0.05);
+
+ /* Subtle outer lift — barely perceptible */
+ --shadow-sm: 0px 2px 8px rgba(10, 14, 20, 0.40);
+
+ /* Cards with elevation */
+ --shadow-md: 0px 4px 16px rgba(10, 14, 20, 0.50);
+
+ /* Modals, dropdowns — navy-tinted, never pure black */
+ --shadow-lg: 0px 8px 24px rgba(13, 17, 23, 0.60);
+
+ /* Focus ring glow — used on interactive elements */
+ --shadow-focus: 0px 0px 0px 3px rgba(192, 193, 255, 0.20);
+
+ /* Button hover glows — each variant has a colour-matched halo */
+ --shadow-primary-glow: 0px 4px 16px rgba(192, 193, 255, 0.28);
+ --shadow-danger-glow: 0px 4px 16px rgba(255, 92, 92, 0.40);
+ --shadow-success-glow: 0px 4px 16px rgba(74, 222, 128, 0.35);
+
+ /* Glassmorphism backdrop */
+ --blur-modal: blur(12px);
+
+ /* ==========================================================================
+ TRANSITIONS
+ ========================================================================== */
+
+ --transition-fast: 150ms ease;
+ --transition-base: 200ms ease;
+ --transition-slow: 300ms ease;
+
+ /* ==========================================================================
+ RESPONSIVE BREAKPOINTS
+ ========================================================================== */
+
+ --breakpoint-sm: 640px;
+ --breakpoint-md: 768px;
+ --breakpoint-lg: 1024px;
+ --breakpoint-xl: 1280px;
+ --breakpoint-2xl: 1536px;
+
+ /* ==========================================================================
+ Z-INDEX STACK
+ ========================================================================== */
+
+ --z-below: -1;
+ --z-base: 0;
+ --z-raised: 10;
+ --z-dropdown: 100;
+ --z-sticky: 200;
+ --z-overlay: 300;
+ --z-modal: 400;
+ --z-toast: 500;
+ --z-tooltip: 600;
+
+ /* ==========================================================================
+ MAIL PAGE — Segmented filter color palette
+ Soft pastels used for the SegmentedControl active states on the mail
+ filter toolbar. Named by semantic role so they read clearly in code.
+ Each token is a pair: -bg (active background fill) and -text (label).
+ Edit these values to retheme the mail filter buttons globally.
+ ========================================================================== */
+
+ /* Direction: Inbox / Sent */
+ --mail-filter-inbox-bg: rgba(74, 222, 128, 0.16); /* Pastel green */
+ --mail-filter-inbox-text: #6ee7a0;
+ --mail-filter-sent-bg: rgba(123, 208, 255, 0.16); /* Pastel teal/blue */
+ --mail-filter-sent-text: #7bd0ff;
+
+ /* Audience: All Messages / Clients Only */
+ --mail-filter-all-bg: rgba(251, 191, 36, 0.15); /* Pastel yellow */
+ --mail-filter-all-text: #fcd34d;
+ --mail-filter-clients-bg: rgba(74, 222, 128, 0.16); /* Pastel green */
+ --mail-filter-clients-text:#6ee7a0;
+
+ /* Mailbox: Sales / Support / Both (All) */
+ --mail-filter-sales-bg: rgba(251, 146, 60, 0.16); /* Pastel orange */
+ --mail-filter-sales-text: #fdba74;
+ --mail-filter-support-bg: rgba(255, 92, 92, 0.16); /* Pastel red/coral */
+ --mail-filter-support-text:#ff8a8a;
+ --mail-filter-both-bg: rgba(74, 222, 128, 0.16); /* Pastel green */
+ --mail-filter-both-text: #6ee7a0;
+
+ /* Read state: All / Unread / Read / Bookmarked */
+ --mail-filter-unread-bg: rgba(74, 222, 128, 0.16); /* Pastel green */
+ --mail-filter-unread-text: #6ee7a0;
+ --mail-filter-read-bg: rgba(123, 208, 255, 0.16); /* Pastel light blue */
+ --mail-filter-read-text: #7bd0ff;
+ --mail-filter-bookmarked-bg: rgba(255, 92, 92, 0.16); /* Pastel red/coral */
+ --mail-filter-bookmarked-text:#ff8a8a;
+
+ /* ==========================================================================
+ SCROLLBAR
+ 4px slim, near-invisible until sought out.
+ ========================================================================== */
+
+ --scrollbar-width: 4px;
+ --scrollbar-thumb: #464554;
+ --scrollbar-track: transparent;
+ --scrollbar-radius: 2px;
+}
+
+/* ==========================================================================
+ DARK THEME — Explicit override (same as :root, dark is the default)
+ Setting data-theme="dark" on forces dark regardless of system pref.
+ ========================================================================== */
+
+[data-theme="dark"] {
+ --color-bg-abyss: #0a0e14;
+ --color-bg-base: #10141a;
+ --color-bg-void: #181c22;
+ --color-bg-surface: #1c2026;
+ --color-bg-elevated: #262a31;
+ --color-bg-island: #31353c;
+ --color-bg-float: rgba(53, 57, 64, 0.80);
+
+ --color-primary: #c0c1ff;
+ --color-primary-hover: #d2bbff;
+ --color-primary-container:#8083ff;
+ --color-primary-subtle: rgba(128, 131, 255, 0.12);
+ --color-primary-deep: #6001d1;
+ --color-primary-inverse: #494bd6;
+ --gradient-primary: linear-gradient(135deg, #c0c1ff 0%, #d2bbff 100%);
+
+ --color-tertiary: #7bd0ff;
+ --color-tertiary-container:#009bd1;
+
+ --color-success: #4ade80;
+ --color-success-bg: rgba(74, 222, 128, 0.12);
+ --color-warning: #fbbf24;
+ --color-warning-bg: rgba(251, 191, 36, 0.12);
+ --color-danger: #ff5c5c;
+ --color-danger-bg: rgba(255, 92, 92, 0.12);
+ --color-danger-container: #c0392b;
+ --color-info: #7bd0ff;
+ --color-info-bg: rgba(123, 208, 255, 0.12);
+
+ --color-text-primary: #dfe2eb;
+ --color-text-secondary: #c7c4d7;
+ --color-text-muted: #908fa0;
+ --color-text-inverse: #10141a;
+ --color-text-accent: #c0c1ff;
+
+ --color-border: rgba(70, 69, 84, 0.20);
+ --color-border-strong: rgba(70, 69, 84, 0.45);
+ --color-border-focus: rgba(192, 193, 255, 0.40);
+
+ --color-sidebar-bg: #181c22;
+ --color-sidebar-text: #94a3b8;
+ --color-sidebar-text-muted: rgba(100, 116, 139, 0.75);
+ --color-sidebar-active-bg: transparent;
+ --color-sidebar-active-text: #ffffff;
+ --color-sidebar-active-bar: #c0c1ff;
+ --color-sidebar-hover-bg: rgba(49, 53, 60, 0.50);
+
+ --color-header-bg: #181c22;
+}
+
+/* ==========================================================================
+ LIGHT THEME — Placeholder for future light mode
+ TODO: Design a light variant when required.
+ ========================================================================== */
+
+[data-theme="light"] {
+ /* Surface inversions */
+ --color-bg-abyss: #f0f2f7;
+ --color-bg-base: #ffffff;
+ --color-bg-void: #f5f6fa;
+ --color-bg-surface: #ffffff;
+ --color-bg-elevated:#f0f2f7;
+ --color-bg-island: #e4e6ed;
+ --color-bg-float: rgba(255, 255, 255, 0.90);
+
+ /* Keep brand colors — they work on light too */
+ --color-primary: #4a4de0;
+ --color-primary-hover: #6001d1;
+ --color-primary-container:#e8e8ff;
+ --color-primary-subtle: rgba(74, 77, 224, 0.08);
+ --gradient-primary: linear-gradient(135deg, #4a4de0 0%, #6001d1 100%);
+
+ /* Text inversions */
+ --color-text-primary: #0a0e14;
+ --color-text-secondary: #3a3852;
+ --color-text-muted: #6b6880;
+ --color-text-inverse: #ffffff;
+ --color-text-accent: #4a4de0;
+
+ /* Borders */
+ --color-border: rgba(70, 69, 84, 0.15);
+ --color-border-strong: rgba(70, 69, 84, 0.30);
+ --color-border-focus: rgba(74, 77, 224, 0.40);
+
+ /* Sidebar */
+ --color-sidebar-bg: #f5f6fa;
+ --color-sidebar-text: #3a3852;
+ --color-sidebar-text-muted: #6b6880;
+ --color-sidebar-active-bg: transparent;
+ --color-sidebar-active-text: #4a4de0;
+ --color-sidebar-active-bar: #4a4de0;
+ --color-sidebar-hover-bg: rgba(74, 77, 224, 0.06);
+
+ --color-header-bg: #ffffff;
+
+ /* Shadows — lighter for light mode */
+ --shadow-card: 0px 1px 3px rgba(10, 14, 20, 0.08);
+ --shadow-sm: 0px 1px 4px rgba(10, 14, 20, 0.08);
+ --shadow-md: 0px 2px 8px rgba(10, 14, 20, 0.10);
+ --shadow-lg: 0px 8px 24px rgba(10, 14, 20, 0.12);
+ --shadow-focus: 0px 0px 0px 3px rgba(74, 77, 224, 0.20);
+ --shadow-primary-glow: 0px 4px 16px rgba(74, 77, 224, 0.20);
+ --shadow-danger-glow: 0px 4px 16px rgba(255, 92, 92, 0.35);
+ --shadow-success-glow: 0px 4px 16px rgba(74, 222, 128, 0.30);
+}
diff --git a/frontend/vite.config.js b/frontend/vite.config.js
index fab8406..cd37f01 100644
--- a/frontend/vite.config.js
+++ b/frontend/vite.config.js
@@ -2,19 +2,24 @@ import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import svgr from 'vite-plugin-svgr'
+import path from 'path'
export default defineConfig({
plugins: [react(), tailwindcss(), svgr()],
+ resolve: {
+ alias: {
+ '@': path.resolve(__dirname, './src'),
+ },
+ },
server: {
host: '0.0.0.0',
- port: 5173,
- allowedHosts: ['console.bellsystems.net'],
+ port: 5174,
hmr: {
- clientPort: 80,
+ clientPort: 8001,
},
watch: {
usePolling: true,
- interval: 500,
+ interval: 100,
},
proxy: {
'/api': {
diff --git a/nginx/nginx.conf b/nginx/nginx.conf
index 348d4e3..2f3b83a 100644
--- a/nginx/nginx.conf
+++ b/nginx/nginx.conf
@@ -9,12 +9,10 @@ http {
listen 80;
server_name localhost;
- # Use Docker's internal DNS so nginx re-resolves after container restarts
resolver 127.0.0.11 valid=5s;
set $backend_upstream http://backend:8000;
- set $frontend_upstream http://frontend:5173;
+ set $frontend_upstream http://frontend:5174;
- # OTA firmware files — allow browser (esptool-js) to fetch .bin files directly
location /ota/ {
root /srv;
add_header Access-Control-Allow-Origin "*";
@@ -26,7 +24,6 @@ http {
}
}
- # API requests → FastAPI backend
location /api/ {
proxy_pass $backend_upstream$request_uri;
proxy_set_header Host $host;
@@ -35,12 +32,9 @@ http {
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
- # Do not buffer request bodies — stream them directly to backend.
- # Required for large file uploads (client_max_body_size 500m above).
proxy_request_buffering off;
}
- # WebSocket support for MQTT live data
location /api/mqtt/ws {
proxy_pass $backend_upstream$request_uri;
proxy_http_version 1.1;
@@ -49,19 +43,15 @@ http {
proxy_set_header Host $host;
}
- # Everything else → React frontend (Vite dev server)
location / {
proxy_pass $frontend_upstream$request_uri;
proxy_set_header Host $host;
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;
-
- # WebSocket support for Vite HMR
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
-
}
diff --git a/AUTOMATION_ENGINE_STRATEGY.md b/strategies/AUTOMATION_ENGINE_STRATEGY.md
similarity index 100%
rename from AUTOMATION_ENGINE_STRATEGY.md
rename to strategies/AUTOMATION_ENGINE_STRATEGY.md
diff --git a/BellSystems_AdminPanel_Strategy.md b/strategies/BellSystems_AdminPanel_Strategy.md
similarity index 100%
rename from BellSystems_AdminPanel_Strategy.md
rename to strategies/BellSystems_AdminPanel_Strategy.md
diff --git a/strategies/DATABASE_MIGRATION.md b/strategies/DATABASE_MIGRATION.md
new file mode 100644
index 0000000..6d90d3b
--- /dev/null
+++ b/strategies/DATABASE_MIGRATION.md
@@ -0,0 +1,475 @@
+# Database Migration Strategy
+# BellSystems CP v2 — Firestore + SQLite → Postgres
+
+> This is the living plan. Update it as phases complete.
+> Never start a phase without reading the notes from the previous one.
+
+---
+
+## Database Split — Target State
+
+| Data | Target | Source | Flutter uses? |
+|-----------------------------|--------------|--------------|---------------|
+| Devices | Firestore | Firestore | YES — keep |
+| App users (device owners) | Firestore | Firestore | YES — keep |
+| Published melodies | Firestore | Firestore | YES — keep |
+| Draft melodies | Postgres | SQLite | No |
+| Built melodies | Postgres | SQLite | No |
+| CRM customers | Postgres | Firestore | No |
+| CRM products | Postgres | Firestore | No |
+| CRM orders | Postgres | Firestore (subcollection) | No |
+| Console settings | Postgres | Firestore | No |
+| Public features settings | Postgres | Firestore | No |
+| Staff / admin users | Postgres | Firestore | No |
+| Firmware versions | Postgres | Firestore | No |
+| Notes / Issues | Postgres | New (done) | No |
+| Support tickets | Postgres | New (done) | No |
+| CRM comms log | Postgres | SQLite | No |
+| CRM media references | Postgres | SQLite | No |
+| CRM sync state | Postgres | SQLite | No |
+| CRM quotations + items | Postgres | SQLite | No |
+| Mfg audit log | Postgres | SQLite | No |
+| Device alerts | Postgres | SQLite | No |
+| MQTT commands | Postgres | SQLite | No |
+| MQTT heartbeats | Postgres | SQLite | No |
+| Device logs | Postgres (partitioned) | SQLite | No |
+| Staff audit log | Postgres | New | No |
+
+**Rule:** Everything that FlutterFlow touches directly stays in Firestore forever.
+The Console backend continues to write to those Firestore collections exactly as today.
+We only stop *reading* from Firestore in the Console — never stop writing to it.
+
+---
+
+## Deployment Context — Critical
+
+**This project runs in two environments:**
+
+| Environment | SQLite data | Firestore data | Where migrations run |
+|-------------|-------------|----------------|----------------------|
+| Local (Windows + Docker for Desktop) | Empty / stale test data | Live (correct) | Development & testing only |
+| VPS (production Docker) | Live correct data | Live (correct) | **All Phase 1 migrations run here** |
+
+**What this means for each phase:**
+
+- **Phase 0 (schema):** Alembic migrations can be developed and tested locally, then the same migrations are run on the VPS via `docker compose exec backend alembic upgrade head`. The VPS is authoritative.
+- **Phase 1 (SQLite → Postgres):** Migration scripts must be run **on the VPS only**. The local SQLite is not a valid source. Do not run Phase 1 migration scripts locally and assume they reflect real data.
+- **Phase 2 (Firestore → Postgres):** Can be run on either environment (Firestore is the same), but the VPS run is the one that matters. Run locally first to verify the scripts work, then run on the VPS.
+- **Phase 3–5:** All service cutover and testing happens on the VPS.
+
+**The deployment workflow:**
+1. Develop and test code locally
+2. Push code to VPS (git pull or equivalent)
+3. Run `docker compose exec backend alembic upgrade head` on the VPS to apply schema changes
+4. Run migration scripts on the VPS when Phase 1 begins
+5. Verify everything on the VPS before marking a phase complete
+
+---
+
+## Non-negotiable Safety Rules
+
+1. **Never touch a Firestore collection** — only read from it during migration. Never delete, update, or rename documents until you have personally verified the Postgres data is complete and correct.
+2. **Every migration script runs in a transaction** — if any row fails, the entire script rolls back cleanly.
+3. **Idempotent scripts** — every script uses `ON CONFLICT DO NOTHING` or equivalent. Safe to run twice.
+4. **Count verification before commit** — each script prints `Source: N docs/rows → Postgres: N rows ✓` and aborts if counts don't match.
+5. **Migration run log** — a `_migration_runs` table in Postgres records what ran, when, how many rows, and success/failure. Check it after each script.
+6. **One domain at a time** — complete and verify a full domain (schema + migration script + service cutover + smoke test) before starting the next.
+7. **No data loss = no rushing** — downtime during migration is acceptable. Data loss is not.
+
+---
+
+## Phase 0 — Schema Foundation
+**Status: COMPLETE** — Alembic revision `b1c2d3e4f5a6` applied locally. Apply on VPS with `docker compose exec backend alembic upgrade head` before starting Phase 1.
+
+### What exists already in Postgres
+- `entries` + `entry_links` (notes/issues module)
+- `support_tickets` + `ticket_messages` (tickets module)
+- Alembic version history in `alembic_version`
+
+### What Phase 0 adds
+Add the `_migration_runs` tracking table and all new table definitions via Alembic before any data moves.
+
+New tables to create in this phase (schema only, no data yet):
+- `_migration_runs` — tracks what migration scripts have run
+- `crm_products` — flat columns, no JSONB needed
+- `crm_customers` — core columns + JSONB for `contacts`, `notes`, `owned_items`, `location`, `tags`, `technical_issues`, `install_support`, `transaction_history`, `crm_summary`
+- `crm_orders` — core columns + JSONB for `items`, `discount`, `shipping`, `payment_status`, `timeline`
+- `staff` — replaces `admin_users` Firestore collection
+- `console_settings` — key/value or typed columns, replaces Firestore `settings` doc
+- `public_features` — typed columns, replaces Firestore `public_features` doc
+- `crm_comms_log` — mirrors current SQLite schema, adds proper TIMESTAMPTZ columns
+- `crm_media` — mirrors current SQLite schema
+- `crm_sync_state` — key/value
+- `crm_quotations` + `crm_quotation_items` — mirrors current SQLite schema
+- `mfg_audit_log` — mirrors current SQLite schema
+- `device_alerts` — mirrors current SQLite schema
+- `commands` — mirrors current SQLite schema
+- `heartbeats` — mirrors current SQLite schema
+- `melody_drafts` — mirrors current SQLite schema
+- `built_melodies` — mirrors current SQLite schema
+- `device_logs` — **partitioned by month** on `received_at`
+- `audit_log` — new staff action audit system (see schema below)
+
+### Key schema decisions
+
+#### `device_logs` — monthly partitioning
+```sql
+CREATE TABLE device_logs (
+ id BIGSERIAL,
+ device_serial TEXT NOT NULL,
+ level TEXT NOT NULL,
+ message TEXT NOT NULL,
+ device_timestamp BIGINT,
+ received_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ PRIMARY KEY (id, received_at)
+) PARTITION BY RANGE (received_at);
+
+-- Partitions created monthly by a background job or manually:
+CREATE TABLE device_logs_2025_01 PARTITION OF device_logs
+ FOR VALUES FROM ('2025-01-01') TO ('2025-02-01');
+-- etc.
+
+CREATE INDEX idx_device_logs_serial_time ON device_logs(device_serial, received_at DESC);
+CREATE INDEX idx_device_logs_level ON device_logs(level, received_at DESC);
+```
+Dropping a partition to purge old data: `DROP TABLE device_logs_2024_06;` — instant, no DELETE scan.
+
+#### `crm_customers` — JSONB for flexible arrays
+```sql
+CREATE TABLE crm_customers (
+ id TEXT PRIMARY KEY, -- keep Firestore UUID as-is
+ firestore_id TEXT UNIQUE, -- same value during transition, null-able later
+ title TEXT,
+ name TEXT NOT NULL,
+ surname TEXT,
+ organization TEXT,
+ religion TEXT,
+ language TEXT NOT NULL DEFAULT 'el',
+ folder_id TEXT UNIQUE NOT NULL,
+ relationship_status TEXT NOT NULL DEFAULT 'lead',
+ nextcloud_folder TEXT,
+ contacts JSONB NOT NULL DEFAULT '[]',
+ notes JSONB NOT NULL DEFAULT '[]',
+ location JSONB,
+ tags TEXT[] NOT NULL DEFAULT '{}',
+ owned_items JSONB NOT NULL DEFAULT '[]',
+ linked_user_ids TEXT[] NOT NULL DEFAULT '{}',
+ technical_issues JSONB NOT NULL DEFAULT '[]',
+ install_support JSONB NOT NULL DEFAULT '[]',
+ transaction_history JSONB NOT NULL DEFAULT '[]',
+ crm_summary JSONB,
+ created_at TIMESTAMPTZ NOT NULL,
+ updated_at TIMESTAMPTZ NOT NULL
+);
+CREATE INDEX idx_crm_customers_rel_status ON crm_customers(relationship_status);
+CREATE INDEX idx_crm_customers_tags ON crm_customers USING GIN(tags);
+CREATE INDEX idx_crm_customers_name ON crm_customers(name, surname);
+```
+
+#### `crm_orders` — separate table (was Firestore subcollection)
+```sql
+CREATE TABLE crm_orders (
+ id TEXT PRIMARY KEY,
+ customer_id TEXT NOT NULL REFERENCES crm_customers(id) ON DELETE CASCADE,
+ order_number TEXT UNIQUE NOT NULL,
+ title TEXT,
+ created_by TEXT,
+ status TEXT NOT NULL DEFAULT 'negotiating',
+ status_updated_date TIMESTAMPTZ,
+ status_updated_by TEXT,
+ items JSONB NOT NULL DEFAULT '[]',
+ subtotal NUMERIC(12,2) NOT NULL DEFAULT 0,
+ discount JSONB,
+ total_price NUMERIC(12,2) NOT NULL DEFAULT 0,
+ currency TEXT NOT NULL DEFAULT 'EUR',
+ shipping JSONB,
+ payment_status JSONB NOT NULL DEFAULT '{}',
+ invoice_path TEXT,
+ notes TEXT,
+ timeline JSONB NOT NULL DEFAULT '[]',
+ created_at TIMESTAMPTZ NOT NULL,
+ updated_at TIMESTAMPTZ NOT NULL
+);
+CREATE INDEX idx_crm_orders_customer ON crm_orders(customer_id);
+CREATE INDEX idx_crm_orders_status ON crm_orders(status);
+```
+
+#### `staff` — replaces Firestore `admin_users`
+```sql
+CREATE TABLE staff (
+ id TEXT PRIMARY KEY, -- keep Firestore doc ID as-is during transition
+ firestore_id TEXT UNIQUE, -- same as id during transition
+ email TEXT UNIQUE NOT NULL,
+ name TEXT NOT NULL,
+ role TEXT NOT NULL DEFAULT 'staff',
+ permissions JSONB NOT NULL DEFAULT '{}',
+ hashed_password TEXT NOT NULL,
+ is_active BOOLEAN NOT NULL DEFAULT TRUE,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+```
+
+#### `audit_log` — new system, no migration source
+```sql
+CREATE TABLE audit_log (
+ id BIGSERIAL PRIMARY KEY,
+ occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ actor_id TEXT NOT NULL,
+ actor_name TEXT NOT NULL,
+ action TEXT NOT NULL, -- CREATE | UPDATE | DELETE | COMMAND | LOGIN | LOGOUT | etc.
+ entity_type TEXT NOT NULL, -- customer | order | device | melody | product | staff | ticket | note | quotation | etc.
+ entity_id TEXT NOT NULL,
+ entity_label TEXT, -- denormalized human name: "Church of St. George", "SN-0042", etc.
+ changes JSONB, -- {"field": {"old": x, "new": y}, ...} — null for CREATE/DELETE/COMMAND
+ meta JSONB -- extra context: ip_address, command_name, etc.
+);
+-- Indexes covering the exact filter combos we need:
+CREATE INDEX idx_audit_actor ON audit_log(actor_id, occurred_at DESC);
+CREATE INDEX idx_audit_entity ON audit_log(entity_type, entity_id, occurred_at DESC);
+CREATE INDEX idx_audit_action ON audit_log(action, occurred_at DESC);
+CREATE INDEX idx_audit_occurred ON audit_log(occurred_at DESC);
+```
+
+---
+
+## Phase 1 — SQLite → Postgres (Data Migration)
+**Status: NOT STARTED**
+**Prerequisite:** Phase 0 complete (all tables exist in Postgres)
+
+No downtime required — SQLite is local, can read it while the app is running.
+After migration is verified, services are switched to read from Postgres.
+
+### Migration order (least dependencies first)
+
+| Step | Table | Script |
+|------|-------|--------|
+| 1.1 | `melody_drafts` | `migration/migrate_melody_drafts.py` |
+| 1.2 | `built_melodies` | `migration/migrate_built_melodies.py` |
+| 1.3 | `mfg_audit_log` | `migration/migrate_mfg_audit_log.py` |
+| 1.4 | `device_alerts` | `migration/migrate_device_alerts.py` |
+| 1.5 | `crm_sync_state` | `migration/migrate_crm_sync_state.py` |
+| 1.6 | `crm_quotations` | `migration/migrate_crm_quotations.py` |
+| 1.7 | `crm_quotation_items` | `migration/migrate_crm_quotation_items.py` |
+| 1.8 | `crm_media` | `migration/migrate_crm_media.py` |
+| 1.9 | `crm_comms_log` | `migration/migrate_crm_comms_log.py` |
+| 1.10 | `commands` | `migration/migrate_commands.py` |
+| 1.11 | `heartbeats` | `migration/migrate_heartbeats.py` |
+| 1.12 | `device_logs` | `migration/migrate_device_logs.py` (largest — batched) |
+
+### Per-script pattern
+```python
+# Every script follows this structure
+async def run():
+ sqlite_rows = await read_all_from_sqlite("table_name")
+ source_count = len(sqlite_rows)
+ print(f"Source: {source_count} rows")
+
+ async with pg_session() as session:
+ async with session.begin():
+ await session.execute(
+ insert(PgModel).values(rows).on_conflict_do_nothing()
+ )
+ pg_count = await session.scalar(select(func.count()).select_from(PgModel))
+
+ if pg_count < source_count:
+ raise RuntimeError(f"Count mismatch: source={source_count} pg={pg_count}")
+ print(f"Postgres: {pg_count} rows ✓")
+ await log_migration_run("table_name", source_count, pg_count)
+```
+
+### Service cutover per domain
+After each group is migrated and verified:
+1. Update service to import from `database.postgres` instead of `database.core`
+2. Replace `aiosqlite` queries with SQLAlchemy async queries
+3. Smoke test via the Console UI — verify the page loads correctly
+4. Leave SQLite file untouched for 48h as a fallback
+
+---
+
+## Phase 2 — Firestore → Postgres (Data Migration)
+**Status: NOT STARTED**
+**Prerequisite:** Phase 1 complete
+
+Requires `shared.firebase.get_db()` to read from Firestore.
+Scripts run with Firebase Admin SDK — same SDK already initialized in the backend.
+
+### Migration order
+
+| Step | Collection | Script | Notes |
+|------|-----------|--------|-------|
+| 2.1 | `settings` (doc) | `migration/migrate_settings.py` | Single document |
+| 2.2 | `public_features` (doc) | `migration/migrate_public_features.py` | Single document |
+| 2.3 | `crm_products` | `migration/migrate_crm_products.py` | No dependencies |
+| 2.4 | `crm_customers` | `migration/migrate_crm_customers.py` | Strip legacy `negotiating`/`has_problem` fields |
+| 2.5 | `orders` (subcollection) | `migration/migrate_crm_orders.py` | Uses `collection_group("orders")` |
+
+### Converting Firestore types
+Use the existing `_convert_firestore_value` helpers in `devices/service.py` — copy into a shared `migration/utils.py`. Key conversions:
+- `DatetimeWithNanoseconds` → `.isoformat()` string
+- `GeoPoint` → `{"lat": x, "lng": y}` dict
+- `DocumentReference` → `.id` string (just the doc ID, no path)
+
+### Cutover
+After each Firestore collection is migrated and verified:
+1. Switch service to read/write Postgres
+2. **Keep all Firestore write calls** — continue writing to Firestore on every mutation so the data stays current there for any emergency rollback
+3. After 48h of stable operation, remove the redundant Firestore writes (one service at a time)
+
+---
+
+## Phase 3 — Staff Auth Cutover
+**Status: NOT STARTED**
+**Prerequisite:** Phase 2 step 2.5 complete, staff table verified
+
+This is the highest-risk phase because auth affects every request.
+
+### Steps
+1. Migrate `admin_users` Firestore collection → `staff` Postgres table (script: `migration/migrate_staff.py`)
+2. Verify: compare email list, role list, permission maps between Firestore and Postgres
+3. Update `auth/dependencies.py` to query Postgres `staff` table instead of Firestore
+4. Update `staff/service.py` to read/write Postgres
+5. Update `seed_admin.py` to write to Postgres (keep old Firestore version as `seed_admin_firestore_legacy.py`)
+6. Test: log in as each role, verify permissions work
+7. Only after 24h stable — remove Firestore reads from auth
+
+### Rollback plan
+The JWT token payload doesn't change — it still contains `sub` (staff ID) and `permissions`.
+Rolling back is just reverting the two files (`auth/dependencies.py` and `staff/service.py`).
+
+---
+
+## Phase 4 — Audit Log System
+**Status: NOT STARTED**
+**Prerequisite:** Phase 0 (`audit_log` table created)
+
+The audit log system can be built and wired in incrementally — it doesn't block other phases.
+Wire it into each service as that service is cut over to Postgres.
+
+### The logging utility
+`backend/shared/audit.py` — a single async function all services call:
+
+```python
+async def log_action(
+ db: AsyncSession,
+ actor_id: str,
+ actor_name: str,
+ action: str, # "CREATE" | "UPDATE" | "DELETE" | "COMMAND" | ...
+ entity_type: str, # "customer" | "order" | "device" | ...
+ entity_id: str,
+ entity_label: str | None = None,
+ changes: dict | None = None, # {"field": {"old": x, "new": y}}
+ meta: dict | None = None, # {"ip": ..., "command_name": ...}
+) -> None
+```
+
+### How to capture diffs
+In service update functions:
+```python
+old_data = existing_record.to_dict() # before
+await session.execute(update_stmt)
+new_data = updated_record.to_dict() # after
+changes = {
+ k: {"old": old_data[k], "new": new_data[k]}
+ for k in new_data
+ if old_data.get(k) != new_data.get(k)
+}
+await log_action(db, actor_id, actor_name, "UPDATE", "customer", id, label, changes)
+```
+
+### Action types
+| Action | When |
+|--------|------|
+| `CREATE` | Any new record created |
+| `UPDATE` | Any field changed |
+| `DELETE` | Any record deleted |
+| `COMMAND` | MQTT command sent to device |
+| `PUBLISH` | Melody published to Firestore |
+| `UNPUBLISH` | Melody unpublished |
+| `LOGIN` | Staff login |
+| `LOGOUT` | Staff logout |
+| `PERMISSION_CHANGE` | Staff permissions updated |
+| `STATUS_CHANGE` | Order/customer/ticket status changed (convenience — also captured as UPDATE) |
+
+### API endpoint
+`GET /api/audit-log` with query params:
+- `actor_id` — filter by staff member
+- `entity_type` + `entity_id` — filter by a specific record
+- `action` — filter by action type
+- `from_date` / `to_date` — date range
+- `limit` / `offset` — pagination (default limit: 50, max: 200)
+
+---
+
+## Phase 5 — MQTT Live Data Cutover
+**Status: NOT STARTED**
+**Prerequisite:** Phase 1 complete (device_logs in Postgres)
+
+This phase switches the **live MQTT ingestion** from SQLite to Postgres.
+
+### Steps
+1. Update `database/core.py` `insert_log`, `insert_heartbeat`, `insert_command` to write to Postgres
+2. Update read functions (`get_logs`, `get_heartbeats`, etc.) similarly
+3. The partition management background job: each month, at startup or via a cron, ensure next month's partition exists:
+```python
+async def ensure_current_partitions(db: AsyncSession):
+ for month_offset in [0, 1]: # current + next month
+ d = date.today().replace(day=1) + relativedelta(months=month_offset)
+ partition_name = f"device_logs_{d.strftime('%Y_%m')}"
+ start = d.isoformat()
+ end = (d + relativedelta(months=1)).isoformat()
+ await db.execute(text(f"""
+ CREATE TABLE IF NOT EXISTS {partition_name}
+ PARTITION OF device_logs
+ FOR VALUES FROM ('{start}') TO ('{end}')
+ """))
+```
+
+### Log retention
+- Keep last 6 months of partitions
+- Cron job runs monthly: checks for partitions older than 6 months and drops them
+- Dropping a partition = `DROP TABLE device_logs_2024_09;` — instantaneous, no row-by-row delete
+
+---
+
+## Verification Checklist (run after each phase)
+
+- [ ] `SELECT COUNT(*)` in Postgres matches source count for every migrated table
+- [ ] Sample 10 random records — compare field by field against source
+- [ ] Timestamps are stored as TIMESTAMPTZ, not TEXT strings
+- [ ] All JSONB columns parse correctly (no `null` where arrays expected)
+- [ ] Relevant Console pages load without errors
+- [ ] API endpoints return correct data
+- [ ] `_migration_runs` table shows success for all scripts
+
+---
+
+## Files & Locations
+
+```
+backend/
+├── migration/ ← all migration scripts live here
+│ ├── utils.py ← shared helpers (Firestore type converters, PG connection, etc.)
+│ ├── migrate_melody_drafts.py
+│ ├── migrate_crm_customers.py
+│ ├── migrate_crm_orders.py
+│ └── ... (one file per table)
+├── shared/
+│ └── audit.py ← audit log utility (Phase 4)
+└── alembic/versions/ ← never edit by hand
+```
+
+---
+
+## Current Status Summary
+
+| Phase | Description | Status |
+|-------|-------------|--------|
+| 0 | Schema foundation (all tables in Postgres) | **COMPLETE** (local) — run `alembic upgrade head` on VPS |
+| 1 | SQLite → Postgres (data migration) | NOT STARTED |
+| 2 | Firestore → Postgres (data migration) | NOT STARTED |
+| 3 | Staff auth cutover | NOT STARTED |
+| 4 | Audit log system | NOT STARTED |
+| 5 | MQTT live data cutover | NOT STARTED |
+
+Update this table as each phase completes.
diff --git a/strategies/NOTES_ISSUES_TICKETS_BUILD.md b/strategies/NOTES_ISSUES_TICKETS_BUILD.md
new file mode 100644
index 0000000..d05c121
--- /dev/null
+++ b/strategies/NOTES_ISSUES_TICKETS_BUILD.md
@@ -0,0 +1,114 @@
+# Notes, Issues & Support Tickets — UI Remaining Work
+
+> Backend is fully built and deployed. Alembic migrations applied.
+> This file tracks ONLY the remaining frontend work.
+> Migration strategy has moved to DATABASE_MIGRATION.md.
+
+## What's done
+
+- [x] `backend/notes/` — ORM, models, service, router (mounted at `/api/notes`)
+- [x] `backend/tickets/` — ORM, models, service, router (mounted at `/api/tickets`)
+- [x] Alembic migrations applied: `entries`, `entry_links`, `support_tickets`, `ticket_messages`
+- [x] `frontend/src/pages/crm/comms/helpdesk/TicketsTab.jsx` — tickets tab on comms page
+- [x] `frontend/src/modals/crm/helpdesk/CreateTicketModal.jsx`
+
+---
+
+## What's remaining
+
+### 7.1 Global Notes & Issues page
+
+Page at `frontend/src/pages/crm/notes/NotesPage.jsx`, route `/crm/notes`.
+
+Features:
+- `
` with "Notes" and "Issues" tabs — filters `type` on `GET /api/notes`
+- `` columns: title, type badge, status badge, severity badge, linked entities (chips), author, date
+- Create button → opens ``
+- Click row → opens ``
+- Filters: status, severity
+- ``
+
+### 7.2 Entry form modal
+
+Modal at `frontend/src/modals/crm/notes/EntryFormModal.jsx`.
+
+Fields:
+- Type selector (note / issue) — conditionally shows status + severity when type = issue
+- Title (``)
+- Body (``)
+- Status selector (issue only, ``)
+- Severity selector (issue only, ``)
+- Linked entities: searchable selects for devices, app users, customers
+
+### 7.3 Device detail page — Notes & Issues tab
+
+Add a "Notes & Issues" tab to the existing device detail page.
+Calls `GET /api/notes/by-entity/device/:deviceId`.
+Shows compact entry list. Inline "Add note" / "Add issue" buttons that pre-fill the device link.
+
+### 7.4 Customer detail page — Notes, Issues & Tickets tabs
+
+**Notes & Issues tab:** calls `GET /api/notes/by-entity/customer/:customerId`
+
+**Support Tickets tab:**
+- Calls `GET /api/tickets/by-customer/:customerId`
+- Ticket list with status + priority badges
+- Click row → opens ``
+
+### 7.5 Global Support Tickets page
+
+Page at `frontend/src/pages/crm/tickets/TicketsPage.jsx`, route `/crm/tickets`.
+
+Features:
+- `` columns: subject, customer name, device serial, status badge, priority badge, opened via, last updated
+- Click row → opens ticket thread view
+- Create ticket button
+- Filters: status, priority, customer, device
+- ``
+
+### 7.6 Ticket thread modal
+
+Modal at `frontend/src/modals/crm/tickets/TicketThreadModal.jsx`.
+
+- Ticket subject, customer, device, status, priority at the top
+- Message thread — chronological, oldest first
+- Internal notes visually distinct (different background, lock icon) — never shown to customers
+- Reply form at the bottom: toggle between "Reply to customer" and "Internal note"
+- Status change accessible from the thread view
+- "Escalate to issue" button — links to an existing issue entry
+
+---
+
+## API quick reference
+
+```
+GET /api/notes list notes/issues (type, status, severity, page, limit)
+GET /api/notes/by-entity/:type/:id notes for a device or customer
+POST /api/notes create note or issue
+PATCH /api/notes/:id update
+PATCH /api/notes/:id/links replace entity links
+DELETE /api/notes/:id
+
+GET /api/tickets list tickets (status, priority, customer_id, page, limit)
+GET /api/tickets/by-customer/:id
+GET /api/tickets/by-device/:id
+POST /api/tickets
+PATCH /api/tickets/:id
+POST /api/tickets/:id/messages
+POST /api/tickets/:id/escalate
+```
+
+## Entry type rules
+
+| Field | Note | Issue |
+|------------|--------------|--------------------------|
+| `type` | `'note'` | `'issue'` |
+| `status` | always null | required: open / in_progress / resolved |
+| `severity` | always null | optional: low / medium / high / critical |
+
+## Ticket status flow
+
+```
+open → waiting_on_customer → waiting_on_staff → resolved → closed
+ (staff replied) (customer replied)
+```
diff --git a/strategies/crm_full_erd.html b/strategies/crm_full_erd.html
new file mode 100644
index 0000000..400c4c3
--- /dev/null
+++ b/strategies/crm_full_erd.html
@@ -0,0 +1,127 @@
+
+
+
+