diff --git a/backend/devices/models.py b/backend/devices/models.py index b53c56e..8ded275 100644 --- a/backend/devices/models.py +++ b/backend/devices/models.py @@ -31,11 +31,11 @@ class DeviceTiers(str, Enum): class DeviceNetworkSettings(BaseModel): hostname: str = "" useStaticIP: bool = False - ipAddress: List[str] = [] - gateway: List[str] = [] - subnet: List[str] = [] - dns1: List[str] = [] - dns2: List[str] = [] + ipAddress: Any = [] + gateway: Any = [] + subnet: Any = [] + dns1: Any = [] + dns2: Any = [] class DeviceClockSettings(BaseModel): diff --git a/docker-compose.yml b/docker-compose.yml index d1e5f34..15f2b12 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,7 +6,7 @@ services: volumes: - ./backend:/app # Persistent data - lives outside the container - - ./data/database.db:/app/data/database.db + - ./data:/app/data - ./data/built_melodies:/app/storage/built_melodies - ./data/firmware:/app/storage/firmware - ./data/firebase-service-account.json:/app/firebase-service-account.json:ro diff --git a/frontend/src/components/SearchBar.jsx b/frontend/src/components/SearchBar.jsx index ccab05e..f9883cc 100644 --- a/frontend/src/components/SearchBar.jsx +++ b/frontend/src/components/SearchBar.jsx @@ -1,11 +1,11 @@ import { useState } from "react"; -export default function SearchBar({ onSearch, placeholder = "Search..." }) { +export default function SearchBar({ onSearch, placeholder = "Search...", style }) { const [value, setValue] = useState(""); - const handleSubmit = (e) => { - e.preventDefault(); - onSearch(value); + const handleChange = (e) => { + setValue(e.target.value); + onSearch(e.target.value); }; const handleClear = () => { @@ -14,36 +14,29 @@ export default function SearchBar({ onSearch, placeholder = "Search..." }) { }; return ( -
-
- setValue(e.target.value)} - placeholder={placeholder} - className="w-full px-3 py-2 rounded-md text-sm border" - /> - {value && ( - - )} -
- -
+ /> + {value && ( + + )} + ); } diff --git a/frontend/src/crm/customers/CustomerDetail.jsx b/frontend/src/crm/customers/CustomerDetail.jsx index ea10f9b..09a817d 100644 --- a/frontend/src/crm/customers/CustomerDetail.jsx +++ b/frontend/src/crm/customers/CustomerDetail.jsx @@ -1290,15 +1290,15 @@ export default function CustomerDetail() { > -
-

+

+

{entry.subject || {COMM_TYPE_LABELS[entry.type] || entry.type}}

{entry.body && ( -

{entry.body}

+

{entry.body}

)}
- + {formatCommDate(entry.occurred_at)}
@@ -1717,15 +1717,15 @@ export default function CustomerDetail() { )} {/* Header row - order: direction icon, subject */} -
+
{entry.subject && ( - + {entry.subject} )} -
- +
+ {formatRelativeTime(entry.occurred_at)}
@@ -1740,7 +1740,7 @@ export default function CustomerDetail() { style={{ color: "var(--text-primary)", display: "-webkit-box", - WebkitLineClamp: isExpanded ? "unset" : 2, + WebkitLineClamp: isExpanded ? "unset" : 3, WebkitBoxOrient: "vertical", overflow: isExpanded ? "visible" : "hidden", whiteSpace: "pre-wrap", diff --git a/frontend/src/crm/customers/CustomerList.jsx b/frontend/src/crm/customers/CustomerList.jsx index b3a168c..05f322a 100644 --- a/frontend/src/crm/customers/CustomerList.jsx +++ b/frontend/src/crm/customers/CustomerList.jsx @@ -44,15 +44,16 @@ function resolveLanguage(val) { } const ALL_COLUMNS = [ - { id: "name", label: "Name", default: true }, - { id: "organization", label: "Organization", default: true }, - { id: "address", label: "Full Address", default: true }, - { id: "location", label: "Location", default: true }, - { id: "email", label: "Email", default: true }, - { id: "phone", label: "Phone", default: true }, - { id: "tags", label: "Tags", default: true }, - { id: "religion", label: "Religion", default: false }, - { id: "language", label: "Language", default: false }, + { id: "name", label: "Name", default: true, locked: true }, + { id: "status", label: "Status", default: true }, + { id: "organization", label: "Organization", default: true }, + { id: "address", label: "Full Address", default: true }, + { id: "location", label: "Location", default: true }, + { id: "email", label: "Email", default: true }, + { id: "phone", label: "Phone", default: true }, + { id: "tags", label: "Tags", default: true }, + { id: "religion", label: "Religion", default: false }, + { id: "language", label: "Language", default: false }, ]; const SORT_OPTIONS = [ @@ -62,8 +63,9 @@ const SORT_OPTIONS = [ { value: "latest_comm", label: "Latest Communication" }, ]; -const COL_STORAGE_KEY = "crm_customers_columns"; -const COL_ORDER_KEY = "crm_customers_col_order"; +const COL_STORAGE_KEY = "crm_customers_columns"; +const COL_ORDER_KEY = "crm_customers_col_order"; +const NOTES_MODE_KEY = "crm_customers_notes_mode"; function loadColumnPrefs() { try { @@ -75,6 +77,10 @@ function loadColumnPrefs() { if (!orderedIds.includes(c.id)) orderedIds.push(c.id); } const filtered = orderedIds.filter(id => ALL_COLUMNS.find(c => c.id === id)); + // Always force locked columns visible + for (const c of ALL_COLUMNS) { + if (c.locked) visible[c.id] = true; + } return { visible, orderedIds: filtered }; } catch { return { @@ -119,46 +125,76 @@ function IconImportant({ style, className }) { ); } -// ── Status icons next to customer name ────────────────────────────────────── -// direction: "inbound" = client sent last, "outbound" = we sent last, null = unknown +// ── Status icons helpers ───────────────────────────────────────────────────── -function CustomerStatusIcons({ customer, direction }) { +function statusColors(direction) { + const pendingOurReply = direction === "inbound"; + // negotiations: yellow if we sent last, orange if client sent last + // issues: yellow if we sent last, red if client sent last + const negColor = pendingOurReply ? "var(--crm-status-alert)" : "#e8a504"; + const issColor = pendingOurReply ? "var(--crm-status-danger)" : "#e03535"; + return { negColor, issColor, pendingOurReply }; +} + +// ── Quick mode status cell ─────────────────────────────────────────────────── +// Icon sizes — edit these to adjust icon dimensions per mode: +// Quick mode icons: QUICK_ICON_SIZE (negotiations slightly larger than issues) +const QUICK_NEG_ICON_SIZE = 25; // px — negotiations icon in Quick mode +const QUICK_ISS_ICON_SIZE = 20; // px — issues icon in Quick mode +const QUICK_IMP_ICON_SIZE = 17; // px — exclamation icon in Quick mode +// Expanded sub-row icons: +const EXP_NEG_ICON_SIZE = 22; // px — negotiations icon in Expanded sub-rows +const EXP_ISS_ICON_SIZE = 16; // px — issues icon in Expanded sub-rows +const EXP_IMP_ICON_SIZE = 12; // px — exclamation icon in Expanded sub-rows + +function StatusIconsCell({ customer, direction }) { const hasNeg = customer.negotiating; const hasIssue = customer.has_problem; - // "important" = we have an open issue or negotiation AND we sent the last message - // (pending reply from client) — shown as breathing exclamation - const pendingOurReply = direction === "inbound"; + if (!hasNeg && !hasIssue) return ; - if (!hasNeg && !hasIssue) return null; - - // Color logic: - // negotiations: yellow (#f08c00) if outbound (we sent last), orange (#f76707) if inbound (client sent) - // issues: yellow (#f08c00) if outbound, red (#f34b4b) if inbound - const negColor = pendingOurReply ? "var(--crm-status-alert)" : "var(--crm-status-warn)"; - const issColor = pendingOurReply ? "var(--crm-status-danger)" : "var(--crm-status-warn)"; - const iconSize = { width: 13, height: 13, display: "inline-block", flexShrink: 0 }; + const { negColor, issColor, pendingOurReply } = statusColors(direction); return ( - - {hasNeg && ( - - - - )} - {hasIssue && ( - - - - )} - {(hasNeg || hasIssue) && pendingOurReply && ( - - - - )} - + +
+ {hasNeg && ( + + + + )} + {hasIssue && ( + + + + )} + {(hasNeg || hasIssue) && pendingOurReply && ( + + + + )} +
+ ); } +// ── Original inline icons (small, in name cell) ────────────────────────────── + +function relDays(dateStr) { + if (!dateStr) return null; + const d = new Date(dateStr); + if (isNaN(d)) return null; + const days = Math.floor((Date.now() - d.getTime()) / 86400000); + if (days === 0) return "today"; + if (days === 1) return "yesterday"; + return `${days} days ago`; +} + // ── Column toggle ──────────────────────────────────────────────────────────── function ColumnToggle({ visible, orderedIds, onChange, onReorder }) { @@ -206,31 +242,36 @@ function ColumnToggle({ visible, orderedIds, onChange, onReorder }) { {orderedIds.map((id) => { const col = ALL_COLUMNS.find((c) => c.id === id); if (!col) return null; + const isLocked = !!col.locked; return (
setDragging(id)} + draggable={!isLocked} + onDragStart={() => !isLocked && setDragging(id)} onDragOver={(e) => handleDragOver(e, id)} onDragEnd={() => setDragging(null)} - onClick={() => onChange(id, !visible[id])} + onClick={() => !isLocked && onChange(id, !visible[id])} style={{ display: "flex", alignItems: "center", gap: 8, padding: "6px 8px", borderRadius: 6, - cursor: "pointer", userSelect: "none", + cursor: isLocked ? "default" : "pointer", userSelect: "none", backgroundColor: dragging === id ? "var(--bg-card-hover)" : "transparent", + opacity: isLocked ? 0.5 : 1, }} - onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = "var(--bg-card-hover)"; }} + onMouseEnter={(e) => { if (!isLocked) e.currentTarget.style.backgroundColor = "var(--bg-card-hover)"; }} onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = dragging === id ? "var(--bg-card-hover)" : "transparent"; }} > - +
- {visible[id] && } + {(isLocked || visible[id]) && }
- {col.label} + + {col.label}{isLocked ? " (locked)" : ""} +
); })} @@ -389,6 +430,38 @@ function SortDropdown({ value, onChange }) { ); } +// ── Notes mode toggle ──────────────────────────────────────────────────────── + +function NotesModeToggle({ value, onChange }) { + const isExpanded = value === "expanded"; + return ( + + ); +} + // ── Helpers ────────────────────────────────────────────────────────────────── function primaryContact(customer, type) { @@ -512,10 +585,22 @@ export default function CustomerList() { const [colPrefs, setColPrefs] = useState(loadColumnPrefs); // Map of customer_id → "inbound" | "outbound" | null const [commDirections, setCommDirections] = useState({}); + // Map of customer_id → ISO date string of last comm + const [lastCommDates, setLastCommDates] = useState({}); + const [notesMode, setNotesMode] = useState( + () => localStorage.getItem(NOTES_MODE_KEY) || "quick" + ); + const [pageSize, setPageSize] = useState(20); + const [page, setPage] = useState(1); const navigate = useNavigate(); const { hasPermission } = useAuth(); const canEdit = hasPermission("crm", "edit"); + const handleNotesModeChange = (mode) => { + setNotesMode(mode); + localStorage.setItem(NOTES_MODE_KEY, mode); + }; + const fetchCustomers = async () => { setLoading(true); setError(""); @@ -541,23 +626,28 @@ export default function CustomerList() { const results = await Promise.allSettled( flagged.map(c => api.get(`/crm/customers/${c.id}/last-comm-direction`) - .then(r => [c.id, r.direction]) - .catch(() => [c.id, null]) + .then(r => [c.id, r.direction, r.occurred_at || r.date || null]) + .catch(() => [c.id, null, null]) ) ); - const map = {}; + const dirMap = {}; + const dateMap = {}; for (const r of results) { if (r.status === "fulfilled") { - const [id, dir] = r.value; - map[id] = dir; + const [id, dir, date] = r.value; + dirMap[id] = dir; + if (date) dateMap[id] = date; } } - setCommDirections(prev => ({ ...prev, ...map })); + setCommDirections(prev => ({ ...prev, ...dirMap })); + setLastCommDates(prev => ({ ...prev, ...dateMap })); }; useEffect(() => { fetchCustomers(); }, [search, sort]); const updateColVisible = (id, vis) => { + const col = ALL_COLUMNS.find(c => c.id === id); + if (col?.locked) return; // can't toggle locked columns const next = { ...colPrefs.visible, [id]: vis }; setColPrefs((p) => ({ ...p, visible: next })); saveColumnPrefs(next, colPrefs.orderedIds); @@ -577,28 +667,36 @@ export default function CustomerList() { (!activeFilters.has("has_problem") || c.has_problem) ); + const totalPages = pageSize > 0 ? Math.ceil(filteredCustomers.length / pageSize) : 1; + const safePage = Math.min(page, Math.max(1, totalPages)); + const pagedCustomers = pageSize > 0 + ? filteredCustomers.slice((safePage - 1) * pageSize, safePage * pageSize) + : filteredCustomers; + const handleCustomerUpdate = (updated) => { setCustomers(prev => prev.map(c => c.id === updated.id ? updated : c)); // Refresh direction for this customer if it now has/lost a flag if (updated.negotiating || updated.has_problem) { api.get(`/crm/customers/${updated.id}/last-comm-direction`) - .then(r => setCommDirections(prev => ({ ...prev, [updated.id]: r.direction }))) + .then(r => { + setCommDirections(prev => ({ ...prev, [updated.id]: r.direction })); + if (r.occurred_at || r.date) setLastCommDates(prev => ({ ...prev, [updated.id]: r.occurred_at || r.date })); + }) .catch(() => {}); } }; - const renderCell = (col, c) => { + const renderCell = (col, c, direction) => { const loc = c.location || {}; switch (col.id) { case "name": return ( -
- {[TITLE_SHORT[c.title], c.name, c.surname].filter(Boolean).join(" ")} - -
+ {[TITLE_SHORT[c.title], c.name, c.surname].filter(Boolean).join(" ")} ); + case "status": + return ; case "organization": return {c.organization || "—"}; case "address": { @@ -638,6 +736,26 @@ export default function CustomerList() { } }; + // In expanded mode, hide the status column — info is shown as sub-rows instead + const visibleColsForMode = notesMode === "expanded" + ? visibleCols.filter(c => c.id !== "status") + : visibleCols; + + // Total column count for colSpan on expanded sub-rows + const totalCols = visibleColsForMode.length + (canEdit ? 1 : 0); + + // Row gradient background for customers with active status flags + function rowGradient(customer, direction) { + const hasNeg = customer.negotiating; + const hasIssue = customer.has_problem; + if (!hasNeg && !hasIssue) return undefined; + const pendingOurReply = direction === "inbound"; + const color = hasIssue + ? (pendingOurReply ? "rgba(224, 53, 53, 0.07)" : "rgba(224, 53, 53, 0.05)") + : (pendingOurReply ? "rgba(247, 103, 7, 0.07)" : "rgba(232, 165, 4, 0.05)"); + return `linear-gradient(to right, ${color} 0%, transparent 70%)`; + } + return (
@@ -663,7 +781,19 @@ export default function CustomerList() { style={inputStyle} /> + + - {visibleCols.map((col) => ( - + {visibleColsForMode.map((col) => ( + {col.label} ))} @@ -702,29 +832,208 @@ export default function CustomerList() { - {filteredCustomers.map((c, index) => ( - navigate(`/crm/customers/${c.id}`)} - className="cursor-pointer" - style={{ - borderBottom: index < filteredCustomers.length - 1 ? "1px solid var(--border-secondary)" : "none", - backgroundColor: hoveredRow === c.id ? "var(--bg-card-hover)" : "transparent", - }} - onMouseEnter={() => setHoveredRow(c.id)} - onMouseLeave={() => setHoveredRow(null)} - > - {visibleCols.map((col) => renderCell(col, c))} - {canEdit && ( - - - - )} - - ))} + {pagedCustomers.map((c, index) => { + const direction = commDirections[c.id] ?? null; + const lastDate = lastCommDates[c.id] ?? null; + const hasStatus = c.negotiating || c.has_problem; + const isLast = index === pagedCustomers.length - 1; + const gradient = rowGradient(c, direction); + const rowBg = hoveredRow === c.id ? "var(--bg-card-hover)" : undefined; + const rowStyle = { + borderBottom: (!isLast && !(notesMode === "expanded" && hasStatus)) + ? "1px solid var(--border-secondary)" + : "none", + background: rowBg + ? rowBg + : gradient || "transparent", + }; + + const mainRow = ( + navigate(`/crm/customers/${c.id}`)} + className="cursor-pointer" + style={rowStyle} + onMouseEnter={() => setHoveredRow(c.id)} + onMouseLeave={() => setHoveredRow(null)} + > + {visibleColsForMode.map((col) => renderCell(col, c, direction))} + {canEdit && ( + + + + )} + + ); + + if (notesMode === "expanded" && hasStatus) { + const { negColor, issColor, pendingOurReply } = statusColors(direction); + const when = relDays(lastDate); + const subRowBg = hoveredRow === c.id ? "var(--bg-card-hover)" : undefined; + + const subRows = []; + + if (c.negotiating) { + let text; + if (pendingOurReply) { + text = when + ? `Undergoing negotiations — client last contacted us ${when}. Reply needed.` + : "Undergoing negotiations — client is awaiting our reply."; + } else { + text = when + ? `Undergoing negotiations — we last reached out ${when}.` + : "Undergoing negotiations."; + } + subRows.push( + navigate(`/crm/customers/${c.id}`)} + style={{ + borderBottom: "none", + background: subRowBg || gradient || "transparent", + }} + onMouseEnter={() => setHoveredRow(c.id)} + onMouseLeave={() => setHoveredRow(null)} + > + +
+ + + + {pendingOurReply && ( + + + + )} + {text} +
+ + + ); + } + + if (c.has_problem) { + let text; + if (pendingOurReply) { + text = when + ? `Open issue — client reached out ${when} and is awaiting our response.` + : "Open issue — client is awaiting our response."; + } else { + text = when + ? `Open issue — we last contacted the client ${when}.` + : "Open issue — under investigation."; + } + subRows.push( + navigate(`/crm/customers/${c.id}`)} + style={{ + borderBottom: "none", + background: subRowBg || gradient || "transparent", + }} + onMouseEnter={() => setHoveredRow(c.id)} + onMouseLeave={() => setHoveredRow(null)} + > + +
+ + + + {pendingOurReply && ( + + + + )} + {text} +
+ + + ); + } + + if (!isLast) { + subRows.push( + + + + ); + } + + return [mainRow, ...subRows].filter(Boolean); + } + + return ( + navigate(`/crm/customers/${c.id}`)} + className="cursor-pointer" + style={{ + borderBottom: !isLast ? "1px solid var(--border-secondary)" : "none", + background: rowBg + ? rowBg + : gradient || "transparent", + }} + onMouseEnter={() => setHoveredRow(c.id)} + onMouseLeave={() => setHoveredRow(null)} + > + {visibleColsForMode.map((col) => renderCell(col, c, direction))} + {canEdit && ( + + + + )} + + ); + })}
+ {pageSize > 0 && totalPages > 1 && ( +
+ + Page {safePage} of {totalPages} — {filteredCustomers.length} total + +
+ {[ + { label: "«", onClick: () => setPage(1), disabled: safePage === 1 }, + { label: "‹", onClick: () => setPage((p) => Math.max(1, p - 1)), disabled: safePage === 1 }, + ].map(({ label, onClick, disabled }) => ( + + ))} + {Array.from({ length: totalPages }, (_, i) => i + 1) + .filter((p) => p === 1 || p === totalPages || Math.abs(p - safePage) <= 2) + .reduce((acc, p, idx, arr) => { + if (idx > 0 && p - arr[idx - 1] > 1) acc.push("…"); + acc.push(p); + return acc; + }, []) + .map((p, idx) => + p === "…" ? ( + + ) : ( + + ) + )} + {[ + { label: "›", onClick: () => setPage((p) => Math.min(totalPages, p + 1)), disabled: safePage === totalPages }, + { label: "»", onClick: () => setPage(totalPages), disabled: safePage === totalPages }, + ].map(({ label, onClick, disabled }) => ( + + ))} +
+
+ )}
)}
diff --git a/frontend/src/crm/inbox/CommsPage.jsx b/frontend/src/crm/inbox/CommsPage.jsx index 51e0213..2b08ebc 100644 --- a/frontend/src/crm/inbox/CommsPage.jsx +++ b/frontend/src/crm/inbox/CommsPage.jsx @@ -57,12 +57,11 @@ function formatFullDateTime(value) { const selectStyle = { backgroundColor: "var(--bg-input)", - borderColor: "var(--border-primary)", color: "var(--text-primary)", fontSize: 13, padding: "6px 10px", borderRadius: 6, - border: "1px solid", + border: "1px solid var(--border-primary)", cursor: "pointer", }; @@ -167,6 +166,9 @@ export default function CommsPage() { const [editForm, setEditForm] = useState({}); const [editSaving, setEditSaving] = useState(false); + const [pageSize, setPageSize] = useState(20); + const [page, setPage] = useState(1); + // Modals const [viewEntry, setViewEntry] = useState(null); const [composeOpen, setComposeOpen] = useState(false); @@ -279,6 +281,12 @@ export default function CommsPage() { return String(b?.id || "").localeCompare(String(a?.id || "")); }); + const totalPages = pageSize > 0 ? Math.ceil(sortedFiltered.length / pageSize) : 1; + const safePage = Math.min(page, Math.max(1, totalPages)); + const pagedEntries = pageSize > 0 + ? sortedFiltered.slice((safePage - 1) * pageSize, safePage * pageSize) + : sortedFiltered; + const customerOptions = Object.values(customers).sort((a, b) => (a.name || "").localeCompare(b.name || "") ); @@ -297,7 +305,56 @@ export default function CommsPage() { All customer communications across all channels

-
+
+ + {/* Filters + Actions */} +
+ + + + {/* Customer picker button */} + + + + + {(typeFilter || dirFilter || custFilter) && ( + + )} + +
{syncResult && ( {syncResult.error @@ -326,43 +383,6 @@ export default function CommsPage() {
- {/* Filters */} -
- - - - {/* Customer picker button */} - - - {(typeFilter || dirFilter || custFilter) && ( - - )} -
- {error && (
{error} @@ -379,6 +399,7 @@ export default function CommsPage() {
{sortedFiltered.length} entr{sortedFiltered.length !== 1 ? "ies" : "y"} + {pageSize > 0 && totalPages > 1 && ` — page ${safePage} of ${totalPages}`}
@@ -389,7 +410,7 @@ export default function CommsPage() { }} />
- {sortedFiltered.map((entry) => { + {pagedEntries.map((entry) => { const customer = customers[entry.customer_id]; const isExpanded = expandedId === entry.id; const isEmail = entry.type === "email"; @@ -485,13 +506,13 @@ export default function CommsPage() { )} {entry.subject && ( - + {entry.subject} )} -
- +
+ {formatRelativeTime(entry.occurred_at)}
@@ -505,7 +526,7 @@ export default function CommsPage() { style={{ color: "var(--text-primary)", display: "-webkit-box", - WebkitLineClamp: isExpanded ? "unset" : 2, + WebkitLineClamp: isExpanded ? "unset" : 3, WebkitBoxOrient: "vertical", overflow: isExpanded ? "visible" : "hidden", whiteSpace: "pre-wrap", @@ -612,6 +633,50 @@ export default function CommsPage() { })}
+ {pageSize > 0 && totalPages > 1 && ( +
+ + Page {safePage} of {totalPages} + +
+ {[ + { label: "«", onClick: () => setPage(1), disabled: safePage === 1 }, + { label: "‹", onClick: () => setPage((p) => Math.max(1, p - 1)), disabled: safePage === 1 }, + ].map(({ label, onClick, disabled }) => ( + + ))} + {Array.from({ length: totalPages }, (_, i) => i + 1) + .filter((p) => p === 1 || p === totalPages || Math.abs(p - safePage) <= 2) + .reduce((acc, p, idx, arr) => { + if (idx > 0 && p - arr[idx - 1] > 1) acc.push("…"); + acc.push(p); + return acc; + }, []) + .map((p, idx) => + p === "…" ? ( + + ) : ( + + ) + )} + {[ + { label: "›", onClick: () => setPage((p) => Math.min(totalPages, p + 1)), disabled: safePage === totalPages }, + { label: "»", onClick: () => setPage(totalPages), disabled: safePage === totalPages }, + ].map(({ label, onClick, disabled }) => ( + + ))} +
+
+ )}
)} diff --git a/frontend/src/dashboard/DashboardPage.jsx b/frontend/src/dashboard/DashboardPage.jsx index e8e4bc1..83795bc 100644 --- a/frontend/src/dashboard/DashboardPage.jsx +++ b/frontend/src/dashboard/DashboardPage.jsx @@ -3,72 +3,199 @@ import { useNavigate } from "react-router-dom"; import { useAuth } from "../auth/AuthContext"; import api from "../api/client"; -const STATUS_STYLES = { - manufactured: { bg: "var(--bg-card-hover)", color: "var(--text-muted)" }, - flashed: { bg: "var(--badge-blue-bg)", color: "var(--badge-blue-text)" }, - provisioned: { bg: "#0a2e2a", color: "#4dd6c8" }, - sold: { bg: "#1e1036", color: "#c084fc" }, - claimed: { bg: "#2e1a00", color: "#fb923c" }, - decommissioned: { bg: "var(--danger-bg)", color: "var(--danger-text)" }, +// ─── Status config ──────────────────────────────────────────────────────────── +const STATUS_CFG = { + manufactured: { color: "#9ca3af", glow: "rgba(156,163,175,0.15)", label: "Manufactured" }, + flashed: { color: "#63b3ed", glow: "rgba(99,179,237,0.15)", label: "Flashed" }, + provisioned: { color: "#4dd6c8", glow: "rgba(77,214,200,0.15)", label: "Provisioned" }, + sold: { color: "#c084fc", glow: "rgba(192,132,252,0.15)", label: "Sold" }, + claimed: { color: "#fb923c", glow: "rgba(251,146,60,0.15)", label: "Claimed" }, + decommissioned: { color: "#f34b4b", glow: "rgba(243,75,75,0.15)", label: "Decommissioned" }, }; const STATUS_ORDER = ["manufactured", "flashed", "provisioned", "sold", "claimed", "decommissioned"]; const ACTION_LABELS = { - batch_created: "Batch created", - device_flashed: "NVS downloaded", - device_assigned: "Device assigned", - status_updated: "Status updated", + batch_created: "Batch Created", + device_flashed: "NVS Downloaded", + device_assigned: "Device Assigned", + status_updated: "Status Updated", }; -function StatusBadge({ status }) { - const style = STATUS_STYLES[status] || STATUS_STYLES.manufactured; +// ─── Module nav tiles ───────────────────────────────────────────────────────── +const MODULE_TILES = [ + { + key: "devices", + label: "Fleet", + sublabel: "Deployed Devices", + to: "/devices", + permission: "devices", + icon: ( + + + + + + ), + accent: "#4dd6c8", + }, + { + key: "melodies", + label: "Melodies", + sublabel: "Library & Composer", + to: "/melodies", + permission: "melodies", + icon: ( + + + + + + ), + accent: "#c084fc", + }, + { + key: "crm", + label: "Customers", + sublabel: "CRM & Orders", + to: "/crm/customers", + permission: "crm", + icon: ( + + + + + + ), + accent: "#fb923c", + }, + { + key: "manufacturing", + label: "Manufacturing", + sublabel: "Inventory & Provisioning", + to: "/manufacturing", + permission: "manufacturing", + icon: ( + + + + ), + accent: "#74b816", + }, + { + key: "firmware", + label: "Firmware", + sublabel: "OTA Manager", + to: "/firmware", + permission: "manufacturing", + icon: ( + + + + + + ), + accent: "#63b3ed", + }, + { + key: "mqtt", + label: "Command Center", + sublabel: "MQTT & Control", + to: "/mqtt/commands", + permission: "mqtt", + icon: ( + + + + + ), + accent: "#f34b4b", + }, +]; + +// ─── Utility: format timestamp ──────────────────────────────────────────────── +function formatTs(ts) { + if (!ts) return "—"; + try { + const d = new Date(ts); + const now = new Date(); + const diff = now - d; + if (diff < 60_000) return "just now"; + if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`; + if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`; + return d.toLocaleDateString("en-US", { month: "short", day: "numeric" }); + } catch { + return ts; + } +} + +// ─── Animated counter ───────────────────────────────────────────────────────── +function AnimatedCount({ value }) { + const [display, setDisplay] = useState(0); + useEffect(() => { + if (value === 0) { setDisplay(0); return; } + const duration = 600; + const start = Date.now(); + const tick = () => { + const elapsed = Date.now() - start; + const progress = Math.min(elapsed / duration, 1); + const eased = 1 - Math.pow(1 - progress, 3); + setDisplay(Math.round(eased * value)); + if (progress < 1) requestAnimationFrame(tick); + }; + requestAnimationFrame(tick); + }, [value]); + return <>{display}; +} + +// ─── Status Pill ────────────────────────────────────────────────────────────── +function StatusPill({ status }) { + const cfg = STATUS_CFG[status]; + if (!cfg) return {status}; return ( - {status} + {cfg.label} ); } -function StatCard({ label, count, status, onClick }) { - const style = STATUS_STYLES[status] || STATUS_STYLES.manufactured; - return ( - - ); -} +// ─── Background grid texture ────────────────────────────────────────────────── +const BG_GRID = `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='40' height='40'%3E%3Cpath d='M 40 0 L 0 0 0 40' fill='none' stroke='%23374151' stroke-width='0.4' opacity='0.5'/%3E%3C/svg%3E")`; +// ─── Main Dashboard ─────────────────────────────────────────────────────────── export default function DashboardPage() { const { user, hasPermission } = useAuth(); const navigate = useNavigate(); + const canViewMfg = hasPermission("manufacturing", "view"); - const [stats, setStats] = useState(null); - const [auditLog, setAuditLog] = useState([]); + const [stats, setStats] = useState(null); + const [auditLog, setAuditLog] = useState([]); const [loadingStats, setLoadingStats] = useState(false); const [loadingAudit, setLoadingAudit] = useState(false); + const [now, setNow] = useState(new Date()); + + // Live clock + useEffect(() => { + const id = setInterval(() => setNow(new Date()), 1000); + return () => clearInterval(id); + }, []); useEffect(() => { if (!canViewMfg) return; - setLoadingStats(true); api.get("/manufacturing/stats") .then(setStats) @@ -76,191 +203,605 @@ export default function DashboardPage() { .finally(() => setLoadingStats(false)); setLoadingAudit(true); - api.get("/manufacturing/audit-log?limit=20") + api.get("/manufacturing/audit-log?limit=12") .then((data) => setAuditLog(data.entries || [])) .catch(() => {}) .finally(() => setLoadingAudit(false)); }, [canViewMfg]); - const formatTs = (ts) => { - if (!ts) return "—"; - try { - return new Date(ts).toLocaleString("en-US", { - month: "short", day: "numeric", - hour: "2-digit", minute: "2-digit", - }); - } catch { - return ts; - } - }; + const totalDevices = stats + ? STATUS_ORDER.reduce((s, k) => s + (stats.counts[k] ?? 0), 0) + : null; + + const accessibleModules = MODULE_TILES.filter((m) => + !m.permission || hasPermission(m.permission, "view") + ); + + const clockStr = now.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false }); + const dateStr = now.toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric", year: "numeric" }); return ( -
-

- Dashboard -

-

- Welcome, {user?.name}.{" "} - {user?.role} -

+
+ {/* Subtle grid background */} +
- {canViewMfg && ( - <> - {/* Device Status Summary */} -
-

- Device Inventory -

- -
+
- {loadingStats ? ( -
Loading…
- ) : stats ? ( -
- {STATUS_ORDER.map((s) => ( - navigate(`/manufacturing?status=${s}`)} - /> - ))} -
- ) : null} - - {/* Recent Activity */} - {stats?.recent_activity?.length > 0 && ( -
-

- Recent Activity -

-
- - - - - - - - - - - {stats.recent_activity.map((item, i) => ( - navigate(`/manufacturing/devices/${item.serial_number}`)} - onMouseEnter={(e) => (e.currentTarget.style.backgroundColor = "var(--bg-card-hover)")} - onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = "")} - > - - - - - - ))} - -
Serial NumberStatusOwnerDate
- {item.serial_number} - - - - {item.owner || "—"} - - {formatTs(item.updated_at)} -
-
-
- )} - - {/* Audit Log */} + {/* ── Hero header ─────────────────────────────────────────────────────── */} +
-

- Audit Log -

- {loadingAudit ? ( -
Loading…
- ) : auditLog.length === 0 ? ( -
No audit entries yet.
- ) : ( -
- - - - - - - - - - - - {auditLog.map((entry) => ( - - - - - - - - ))} - -
TimeAdminActionDeviceDetail
- {formatTs(entry.timestamp)} - - {entry.admin_user} - - {ACTION_LABELS[entry.action] || entry.action} - - {entry.serial_number - ? ( - - ) - : "—"} - - {entry.detail - ? (() => { - try { - const d = JSON.parse(entry.detail); - return Object.entries(d) - .filter(([, v]) => v !== null && v !== undefined) - .map(([k, v]) => `${k}: ${v}`) - .join(", "); - } catch { - return entry.detail; - } - })() - : "—"} -
-
- )} +
+ BellSystems · Control Panel +
+

+ Good {getGreeting()},{" "} + {user?.name?.split(" ")[0]}. +

+

+ + {user?.role} + + {dateStr} +

- - )} - {!canViewMfg && ( -

- Select a section from the sidebar to get started. -

+ {/* Live clock */} +
+
+ {clockStr} +
+
+ LOCAL TIME +
+
+
+ + {/* ── Device inventory stats (mfg permission) ──────────────────────────── */} + {canViewMfg && ( +
+ navigate("/manufacturing")} /> + + {loadingStats ? ( + + ) : stats ? ( + <> + {/* Big total + per-status bars */} +
+ {/* Total */} +
+
+ +
+
+ Total Units +
+
+ + {/* Per-status grid */} +
+ {STATUS_ORDER.map((s) => { + const cfg = STATUS_CFG[s]; + const count = stats.counts[s] ?? 0; + const pct = totalDevices ? Math.round((count / totalDevices) * 100) : 0; + return ( + + ); + })} +
+
+ + ) : null} +
+ )} + + {/* ── Module navigation tiles ───────────────────────────────────────────── */} +
+ +
+ {accessibleModules.map((m) => ( + navigate(m.to)} /> + ))} +
+
+ + {/* ── Bottom two-column: Recent Activity + Audit Log ────────────────────── */} + {canViewMfg && ( +
+ {/* Recent Activity */} +
+ navigate("/manufacturing")} /> +
+ {!stats?.recent_activity?.length ? ( + + ) : ( + + + + + + + + + + + {stats.recent_activity.map((item, i) => ( + navigate(`/manufacturing/devices/${item.serial_number}`)} + /> + ))} + +
SerialStatusOwnerWhen
+ )} +
+
+ + {/* Audit Log */} +
+ +
+ {loadingAudit ? ( + + ) : !auditLog.length ? ( + + ) : ( + + + + + + + + + + + {auditLog.map((entry) => ( + navigate(`/manufacturing/devices/${sn}`)} + /> + ))} + +
TimeUserActionDevice
+ )} +
+
+
+ )} + + {!canViewMfg && ( +
+ Select a module above to get started. +
+ )} +
+
+ ); +} + +// ─── Sub-components ─────────────────────────────────────────────────────────── + +function getGreeting() { + const h = new Date().getHours(); + if (h < 12) return "Good morning"; + if (h < 17) return "Good afternoon"; + return "Good evening"; +} + +function SectionLabel({ label, action, onAction }) { + return ( +
+
+
+ + {label} + +
+ {action && ( + )}
); } + +function ModuleTile({ label, sublabel, accent, icon, onClick }) { + const [hovered, setHovered] = useState(false); + return ( + + ); +} + +function TH({ children }) { + return ( + + {children} + + ); +} + +function ActivityRow({ item, onClick }) { + const [hovered, setHovered] = useState(false); + return ( + setHovered(true)} + onMouseLeave={() => setHovered(false)} + style={{ + borderBottom: "1px solid var(--border-secondary)", + cursor: "pointer", + backgroundColor: hovered ? "var(--bg-card-hover)" : "transparent", + transition: "background-color 0.1s ease", + }} + > + + + {item.serial_number} + + + + + + + {item.owner || "—"} + + + {formatTs(item.updated_at)} + + + ); +} + +function AuditRow({ entry, onDeviceClick }) { + const parseDetail = (detail) => { + if (!detail) return "—"; + try { + const d = JSON.parse(detail); + return Object.entries(d) + .filter(([, v]) => v !== null && v !== undefined) + .map(([k, v]) => `${k}: ${v}`) + .join(", "); + } catch { + return detail; + } + }; + + return ( + + + {formatTs(entry.timestamp)} + + + {entry.admin_user} + + + + {ACTION_LABELS[entry.action] || entry.action} + + + + {entry.serial_number ? ( + + ) : ( + + )} + + + ); +} + +function LoadingRow() { + return ( +
+
+ +
+ ); +} + +function EmptyState({ text }) { + return ( +
+ {text} +
+ ); +} diff --git a/frontend/src/devices/DeviceList.jsx b/frontend/src/devices/DeviceList.jsx index 9fd2845..0eab768 100644 --- a/frontend/src/devices/DeviceList.jsx +++ b/frontend/src/devices/DeviceList.jsx @@ -107,6 +107,8 @@ export default function DeviceList() { const [hasBellsFilter, setHasBellsFilter] = useState(""); const [deleteTarget, setDeleteTarget] = useState(null); const [visibleColumns, setVisibleColumns] = useState(getDefaultVisibleColumns); + const [pageSize, setPageSize] = useState(20); + const [page, setPage] = useState(1); const [showColumnPicker, setShowColumnPicker] = useState(false); const [mqttStatusMap, setMqttStatusMap] = useState({}); const columnPickerRef = useRef(null); @@ -278,6 +280,12 @@ export default function DeviceList() { return true; }); + const totalPages = pageSize > 0 ? Math.ceil(filteredDevices.length / pageSize) : 1; + const safePage = Math.min(page, Math.max(1, totalPages)); + const pagedDevices = pageSize > 0 + ? filteredDevices.slice((safePage - 1) * pageSize, safePage * pageSize) + : filteredDevices; + const activeColumns = ALL_COLUMNS.filter((c) => isVisible(c.key)); const selectClass = "px-3 py-2 rounded-md text-sm cursor-pointer border"; @@ -291,105 +299,117 @@ export default function DeviceList() {

Device Fleet

- {canEdit && ( - - )} +
+ + {filteredDevices.length} {filteredDevices.length === 1 ? "device" : "devices"} + + {canEdit && ( + + )} +
-
+
-
- - - - - - + + + + + + - {/* Column visibility dropdown */} -
- + {showColumnPicker && ( +
- - - - Columns - - {showColumnPicker && ( -
- {ALL_COLUMNS.map((col) => ( - - ))} -
- )} -
- - - {filteredDevices.length} {filteredDevices.length === 1 ? "device" : "devices"} - + {ALL_COLUMNS.map((col) => ( + + ))} +
+ )}
+ +
{error && ( @@ -445,7 +465,7 @@ export default function DeviceList() { - {filteredDevices.map((device) => ( + {pagedDevices.map((device) => ( navigate(`/devices/${device.id}`)} @@ -484,6 +504,62 @@ export default function DeviceList() {
+ {pageSize > 0 && totalPages > 1 && ( +
+ + Page {safePage} of {totalPages} — {filteredDevices.length} total + +
+ + + {Array.from({ length: totalPages }, (_, i) => i + 1) + .filter((p) => p === 1 || p === totalPages || Math.abs(p - safePage) <= 2) + .reduce((acc, p, idx, arr) => { + if (idx > 0 && p - arr[idx - 1] > 1) acc.push("…"); + acc.push(p); + return acc; + }, []) + .map((p, idx) => + p === "…" ? ( + + ) : ( + + ) + )} + + +
+
+ )}
)} diff --git a/frontend/src/melodies/MelodyList.jsx b/frontend/src/melodies/MelodyList.jsx index 95696b6..92779ff 100644 --- a/frontend/src/melodies/MelodyList.jsx +++ b/frontend/src/melodies/MelodyList.jsx @@ -376,6 +376,8 @@ export default function MelodyList() { const [builtInSavingId, setBuiltInSavingId] = useState(null); const [viewRow, setViewRow] = useState(null); const [builtMap, setBuiltMap] = useState({}); + const [pageSize, setPageSize] = useState(20); + const [page, setPage] = useState(1); const creatorPickerRef = useRef(null); // Derived helpers from colPrefs @@ -687,6 +689,12 @@ export default function MelodyList() { }); }, [melodies, createdByFilter, sortBy, sortDir]); // eslint-disable-line react-hooks/exhaustive-deps + const totalPages = pageSize > 0 ? Math.ceil(displayRows.length / pageSize) : 1; + const safePage = Math.min(page, Math.max(1, totalPages)); + const pagedRows = pageSize > 0 + ? displayRows.slice((safePage - 1) * pageSize, safePage * pageSize) + : displayRows; + const offlineTaggedCount = useMemo( () => displayRows.filter((row) => Boolean(row?.information?.available_offline)).length, [displayRows] @@ -1055,155 +1063,163 @@ export default function MelodyList() { return (
-
+

Melody Library

- {canEdit && ( - - )} +
+ + {hasAnyFilter + ? `Filtered — ${displayRows.length} / ${allMelodyCount || melodies.length} | ${offlineTaggedCount} offline-tagged` + : `Showing all (${allMelodyCount || melodies.length}) | ${offlineTaggedCount} tagged for Offline`} + + {canEdit && ( + + )} + {canEdit && ( + + )} +
-
+
-
-
- + - + - + -
- - {showCreatorPicker && ( -
+ + {showCreatorPicker && ( +
+ {allCreators.length === 0 ? ( +

No creators found

+ ) : ( + allCreators.map((creator) => ( + + )) + )} + {createdByFilter.length > 0 && ( + - )} -
+ Clear selection + )}
+ )} +
- {languages.length > 1 && ( - - )} + {languages.length > 1 && ( + + )} -
- -
-
+ -
- - {hasAnyFilter - ? `Filtered - Showing ${displayRows.length} / ${allMelodyCount || melodies.length} Melodies | ${offlineTaggedCount} Offline-tagged` - : `Showing all (${allMelodyCount || melodies.length}) Melodies | ${offlineTaggedCount} Melodies tagged for Offline`} - - {canEdit && ( - - )} -
+
+
@@ -1284,7 +1300,7 @@ export default function MelodyList() { - {displayRows.map((row) => ( + {pagedRows.map((row) => ( navigate(`/melodies/${row.id}`)} @@ -1348,6 +1364,50 @@ export default function MelodyList() {
+ {pageSize > 0 && totalPages > 1 && ( +
+ + Page {safePage} of {totalPages} — {displayRows.length} total + +
+ {[ + { label: "«", onClick: () => setPage(1), disabled: safePage === 1 }, + { label: "‹", onClick: () => setPage((p) => Math.max(1, p - 1)), disabled: safePage === 1 }, + ].map(({ label, onClick, disabled }) => ( + + ))} + {Array.from({ length: totalPages }, (_, i) => i + 1) + .filter((p) => p === 1 || p === totalPages || Math.abs(p - safePage) <= 2) + .reduce((acc, p, idx, arr) => { + if (idx > 0 && p - arr[idx - 1] > 1) acc.push("…"); + acc.push(p); + return acc; + }, []) + .map((p, idx) => + p === "…" ? ( + + ) : ( + + ) + )} + {[ + { label: "›", onClick: () => setPage((p) => Math.min(totalPages, p + 1)), disabled: safePage === totalPages }, + { label: "»", onClick: () => setPage(totalPages), disabled: safePage === totalPages }, + ].map(({ label, onClick, disabled }) => ( + + ))} +
+
+ )}
)} diff --git a/frontend/src/melodies/PlaybackModal.jsx b/frontend/src/melodies/PlaybackModal.jsx index c653ec4..9434861 100644 --- a/frontend/src/melodies/PlaybackModal.jsx +++ b/frontend/src/melodies/PlaybackModal.jsx @@ -214,6 +214,21 @@ export default function PlaybackModal({ open, melody, builtMelody, files, archet setActiveBells(new Set()); }, []); + // Stop & close on Escape key or browser back navigation + useEffect(() => { + if (!open) return; + const handleKeyDown = (e) => { + if (e.key === "Escape") { stopPlayback(); onClose(); } + }; + const handlePopState = () => { stopPlayback(); onClose(); }; + window.addEventListener("keydown", handleKeyDown); + window.addEventListener("popstate", handlePopState); + return () => { + window.removeEventListener("keydown", handleKeyDown); + window.removeEventListener("popstate", handlePopState); + }; + }, [open, stopPlayback, onClose]); + useEffect(() => { if (!open) { stopPlayback(); @@ -344,7 +359,7 @@ export default function PlaybackModal({ open, melody, builtMelody, files, archet
e.target === e.currentTarget && !playing && onClose()} + onClick={(e) => { if (e.target === e.currentTarget) { stopPlayback(); onClose(); } }} >

User Management

- {canEdit && ( - - )} +
+ + {total} {total === 1 ? "user" : "users"} + + {canEdit && ( + + )} +
-
+
-
- - - {total} {total === 1 ? "user" : "users"} - -
+
{error && (