fix: devices list accepts ips as single strings / various ui changes

This commit is contained in:
2026-03-14 10:58:25 +02:00
parent 6f9fd5cba3
commit 15c419b7bf
11 changed files with 1707 additions and 647 deletions

View File

@@ -31,11 +31,11 @@ class DeviceTiers(str, Enum):
class DeviceNetworkSettings(BaseModel): class DeviceNetworkSettings(BaseModel):
hostname: str = "" hostname: str = ""
useStaticIP: bool = False useStaticIP: bool = False
ipAddress: List[str] = [] ipAddress: Any = []
gateway: List[str] = [] gateway: Any = []
subnet: List[str] = [] subnet: Any = []
dns1: List[str] = [] dns1: Any = []
dns2: List[str] = [] dns2: Any = []
class DeviceClockSettings(BaseModel): class DeviceClockSettings(BaseModel):

View File

@@ -6,7 +6,7 @@ services:
volumes: volumes:
- ./backend:/app - ./backend:/app
# Persistent data - lives outside the container # Persistent data - lives outside the container
- ./data/database.db:/app/data/database.db - ./data:/app/data
- ./data/built_melodies:/app/storage/built_melodies - ./data/built_melodies:/app/storage/built_melodies
- ./data/firmware:/app/storage/firmware - ./data/firmware:/app/storage/firmware
- ./data/firebase-service-account.json:/app/firebase-service-account.json:ro - ./data/firebase-service-account.json:/app/firebase-service-account.json:ro

View File

@@ -1,11 +1,11 @@
import { useState } from "react"; import { useState } from "react";
export default function SearchBar({ onSearch, placeholder = "Search..." }) { export default function SearchBar({ onSearch, placeholder = "Search...", style }) {
const [value, setValue] = useState(""); const [value, setValue] = useState("");
const handleSubmit = (e) => { const handleChange = (e) => {
e.preventDefault(); setValue(e.target.value);
onSearch(value); onSearch(e.target.value);
}; };
const handleClear = () => { const handleClear = () => {
@@ -14,36 +14,29 @@ export default function SearchBar({ onSearch, placeholder = "Search..." }) {
}; };
return ( return (
<form onSubmit={handleSubmit} className="flex gap-2"> <div className="relative" style={style}>
<div className="relative flex-1"> <input
<input type="text"
type="text" value={value}
value={value} onChange={handleChange}
onChange={(e) => setValue(e.target.value)} placeholder={placeholder}
placeholder={placeholder} className="w-full px-3 py-2 rounded-md text-sm border"
className="w-full px-3 py-2 rounded-md text-sm border"
/>
{value && (
<button
type="button"
onClick={handleClear}
className="absolute right-2 top-1/2 -translate-y-1/2"
style={{ color: "var(--text-muted)" }}
>
&times;
</button>
)}
</div>
<button
type="submit"
className="px-4 py-2 text-sm rounded-md transition-colors"
style={{ style={{
backgroundColor: "var(--btn-neutral)", backgroundColor: "var(--bg-input)",
color: "var(--text-heading)", borderColor: "var(--border-primary)",
color: "var(--text-primary)",
}} }}
> />
Search {value && (
</button> <button
</form> type="button"
onClick={handleClear}
className="absolute right-2 top-1/2 -translate-y-1/2"
style={{ color: "var(--text-muted)" }}
>
&times;
</button>
)}
</div>
); );
} }

View File

@@ -1290,15 +1290,15 @@ export default function CustomerDetail() {
> >
<CommTypeIconBadge type={entry.type} /> <CommTypeIconBadge type={entry.type} />
<CommDirectionIcon direction={entry.direction} /> <CommDirectionIcon direction={entry.direction} />
<div className="flex-1 min-w-0"> <div style={{ flex: 1, minWidth: 0 }}>
<p className="truncate font-medium" style={{ color: "var(--text-primary)" }}> <p className="font-medium" style={{ color: "var(--text-primary)" }}>
{entry.subject || <span style={{ color: "var(--text-muted)", fontStyle: "italic" }}>{COMM_TYPE_LABELS[entry.type] || entry.type}</span>} {entry.subject || <span style={{ color: "var(--text-muted)", fontStyle: "italic" }}>{COMM_TYPE_LABELS[entry.type] || entry.type}</span>}
</p> </p>
{entry.body && ( {entry.body && (
<p className="truncate text-xs mt-0.5" style={{ color: "var(--text-muted)" }}>{entry.body}</p> <p className="text-xs mt-0.5" style={{ color: "var(--text-muted)", display: "-webkit-box", WebkitLineClamp: 3, WebkitBoxOrient: "vertical", overflow: "hidden", whiteSpace: "pre-wrap" }}>{entry.body}</p>
)} )}
</div> </div>
<span className="text-xs flex-shrink-0" style={{ color: "var(--text-muted)" }}> <span className="text-xs" style={{ color: "var(--text-muted)", flexShrink: 0, marginLeft: 100, whiteSpace: "nowrap", alignSelf: "flex-start" }}>
{formatCommDate(entry.occurred_at)} {formatCommDate(entry.occurred_at)}
</span> </span>
</div> </div>
@@ -1717,15 +1717,15 @@ export default function CustomerDetail() {
)} )}
{/* Header row - order: direction icon, subject */} {/* Header row - order: direction icon, subject */}
<div className="flex items-center gap-2 px-4 py-3 flex-wrap"> <div className="flex items-center gap-2 px-4 py-3">
<CommDirectionIcon direction={entry.direction} /> <CommDirectionIcon direction={entry.direction} />
{entry.subject && ( {entry.subject && (
<span className="text-sm font-medium truncate" style={{ color: "var(--text-heading)", maxWidth: 300 }}> <span className="text-sm font-medium" style={{ color: "var(--text-heading)", flex: 1, minWidth: 0 }}>
{entry.subject} {entry.subject}
</span> </span>
)} )}
<div className="ml-auto flex items-center gap-2"> <div className="flex items-center gap-2" style={{ flexShrink: 0, marginLeft: entry.subject ? 100 : "auto" }}>
<span className="text-xs flex-shrink-0" style={{ color: "var(--text-muted)" }}> <span className="text-xs" style={{ color: "var(--text-muted)", whiteSpace: "nowrap" }}>
{formatRelativeTime(entry.occurred_at)} {formatRelativeTime(entry.occurred_at)}
</span> </span>
</div> </div>
@@ -1740,7 +1740,7 @@ export default function CustomerDetail() {
style={{ style={{
color: "var(--text-primary)", color: "var(--text-primary)",
display: "-webkit-box", display: "-webkit-box",
WebkitLineClamp: isExpanded ? "unset" : 2, WebkitLineClamp: isExpanded ? "unset" : 3,
WebkitBoxOrient: "vertical", WebkitBoxOrient: "vertical",
overflow: isExpanded ? "visible" : "hidden", overflow: isExpanded ? "visible" : "hidden",
whiteSpace: "pre-wrap", whiteSpace: "pre-wrap",

View File

@@ -44,15 +44,16 @@ function resolveLanguage(val) {
} }
const ALL_COLUMNS = [ const ALL_COLUMNS = [
{ id: "name", label: "Name", default: true }, { id: "name", label: "Name", default: true, locked: true },
{ id: "organization", label: "Organization", default: true }, { id: "status", label: "Status", default: true },
{ id: "address", label: "Full Address", default: true }, { id: "organization", label: "Organization", default: true },
{ id: "location", label: "Location", default: true }, { id: "address", label: "Full Address", default: true },
{ id: "email", label: "Email", default: true }, { id: "location", label: "Location", default: true },
{ id: "phone", label: "Phone", default: true }, { id: "email", label: "Email", default: true },
{ id: "tags", label: "Tags", default: true }, { id: "phone", label: "Phone", default: true },
{ id: "religion", label: "Religion", default: false }, { id: "tags", label: "Tags", default: true },
{ id: "language", label: "Language", default: false }, { id: "religion", label: "Religion", default: false },
{ id: "language", label: "Language", default: false },
]; ];
const SORT_OPTIONS = [ const SORT_OPTIONS = [
@@ -62,8 +63,9 @@ const SORT_OPTIONS = [
{ value: "latest_comm", label: "Latest Communication" }, { value: "latest_comm", label: "Latest Communication" },
]; ];
const COL_STORAGE_KEY = "crm_customers_columns"; const COL_STORAGE_KEY = "crm_customers_columns";
const COL_ORDER_KEY = "crm_customers_col_order"; const COL_ORDER_KEY = "crm_customers_col_order";
const NOTES_MODE_KEY = "crm_customers_notes_mode";
function loadColumnPrefs() { function loadColumnPrefs() {
try { try {
@@ -75,6 +77,10 @@ function loadColumnPrefs() {
if (!orderedIds.includes(c.id)) orderedIds.push(c.id); if (!orderedIds.includes(c.id)) orderedIds.push(c.id);
} }
const filtered = orderedIds.filter(id => ALL_COLUMNS.find(c => c.id === 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 }; return { visible, orderedIds: filtered };
} catch { } catch {
return { return {
@@ -119,46 +125,76 @@ function IconImportant({ style, className }) {
); );
} }
// ── Status icons next to customer name ────────────────────────────────────── // ── Status icons helpers ─────────────────────────────────────────────────────
// direction: "inbound" = client sent last, "outbound" = we sent last, null = unknown
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 hasNeg = customer.negotiating;
const hasIssue = customer.has_problem; const hasIssue = customer.has_problem;
// "important" = we have an open issue or negotiation AND we sent the last message if (!hasNeg && !hasIssue) return <td className="px-3 py-3" />;
// (pending reply from client) — shown as breathing exclamation
const pendingOurReply = direction === "inbound";
if (!hasNeg && !hasIssue) return null; const { negColor, issColor, pendingOurReply } = statusColors(direction);
// 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 };
return ( return (
<span style={{ display: "inline-flex", alignItems: "center", gap: 5, marginLeft: 6, verticalAlign: "middle" }}> <td className="px-3 py-3" style={{ textAlign: "center" }}>
{hasNeg && ( <div style={{ display: "inline-flex", alignItems: "center", gap: 6 }}>
<span title="Negotiating" style={{ color: negColor, display: "inline-flex" }}> {hasNeg && (
<IconNegotiations style={iconSize} /> <span
</span> title={pendingOurReply ? "Negotiating — client awaiting our reply" : "Negotiating — we sent last"}
)} style={{ color: negColor, display: "inline-flex" }}
{hasIssue && ( >
<span title="Has issue" style={{ color: issColor, display: "inline-flex" }}> <IconNegotiations style={{ width: QUICK_NEG_ICON_SIZE, height: QUICK_NEG_ICON_SIZE, display: "inline-block", flexShrink: 0 }} />
<IconIssues style={iconSize} /> </span>
</span> )}
)} {hasIssue && (
{(hasNeg || hasIssue) && pendingOurReply && ( <span
<span title="Awaiting our reply" style={{ color: "var(--crm-status-warn)", display: "inline-flex" }}> title={pendingOurReply ? "Open issue — client awaiting our reply" : "Open issue — we last contacted them"}
<IconImportant style={iconSize} className="crm-icon-breathe" /> style={{ color: issColor, display: "inline-flex" }}
</span> >
)} <IconIssues style={{ width: QUICK_ISS_ICON_SIZE, height: QUICK_ISS_ICON_SIZE, display: "inline-block", flexShrink: 0 }} />
</span> </span>
)}
{(hasNeg || hasIssue) && pendingOurReply && (
<span title="Awaiting our reply" style={{ color: "var(--crm-status-alert)", display: "inline-flex" }}>
<IconImportant style={{ width: QUICK_IMP_ICON_SIZE, height: QUICK_IMP_ICON_SIZE, display: "inline-block", flexShrink: 0 }} className="crm-icon-breathe" />
</span>
)}
</div>
</td>
); );
} }
// ── 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 ──────────────────────────────────────────────────────────── // ── Column toggle ────────────────────────────────────────────────────────────
function ColumnToggle({ visible, orderedIds, onChange, onReorder }) { function ColumnToggle({ visible, orderedIds, onChange, onReorder }) {
@@ -206,31 +242,36 @@ function ColumnToggle({ visible, orderedIds, onChange, onReorder }) {
{orderedIds.map((id) => { {orderedIds.map((id) => {
const col = ALL_COLUMNS.find((c) => c.id === id); const col = ALL_COLUMNS.find((c) => c.id === id);
if (!col) return null; if (!col) return null;
const isLocked = !!col.locked;
return ( return (
<div <div
key={id} key={id}
draggable draggable={!isLocked}
onDragStart={() => setDragging(id)} onDragStart={() => !isLocked && setDragging(id)}
onDragOver={(e) => handleDragOver(e, id)} onDragOver={(e) => handleDragOver(e, id)}
onDragEnd={() => setDragging(null)} onDragEnd={() => setDragging(null)}
onClick={() => onChange(id, !visible[id])} onClick={() => !isLocked && onChange(id, !visible[id])}
style={{ style={{
display: "flex", alignItems: "center", gap: 8, padding: "6px 8px", borderRadius: 6, 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", 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"; }} onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = dragging === id ? "var(--bg-card-hover)" : "transparent"; }}
> >
<span style={{ fontSize: 11, color: "var(--text-muted)", cursor: "grab" }}></span> <span style={{ fontSize: 11, color: "var(--text-muted)", cursor: isLocked ? "default" : "grab" }}></span>
<div style={{ <div style={{
width: 14, height: 14, borderRadius: 3, border: `2px solid ${visible[id] ? "var(--accent)" : "var(--border-primary)"}`, width: 14, height: 14, borderRadius: 3,
backgroundColor: visible[id] ? "var(--accent)" : "transparent", border: `2px solid ${isLocked ? "var(--border-primary)" : visible[id] ? "var(--accent)" : "var(--border-primary)"}`,
backgroundColor: isLocked ? "var(--border-primary)" : visible[id] ? "var(--accent)" : "transparent",
display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0, display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0,
}}> }}>
{visible[id] && <span style={{ color: "#fff", fontSize: 9, lineHeight: 1 }}></span>} {(isLocked || visible[id]) && <span style={{ color: "#fff", fontSize: 9, lineHeight: 1 }}></span>}
</div> </div>
<span className="text-xs" style={{ color: "var(--text-primary)" }}>{col.label}</span> <span className="text-xs" style={{ color: "var(--text-primary)" }}>
{col.label}{isLocked ? " (locked)" : ""}
</span>
</div> </div>
); );
})} })}
@@ -389,6 +430,38 @@ function SortDropdown({ value, onChange }) {
); );
} }
// ── Notes mode toggle ────────────────────────────────────────────────────────
function NotesModeToggle({ value, onChange }) {
const isExpanded = value === "expanded";
return (
<button
onClick={() => onChange(isExpanded ? "quick" : "expanded")}
className="flex items-center gap-1.5 px-3 py-2 text-sm rounded-md border hover:opacity-80 cursor-pointer transition-opacity"
title={isExpanded ? "Switch to Quick notes view" : "Switch to Expanded notes view"}
style={{
backgroundColor: isExpanded ? "color-mix(in srgb, var(--accent) 15%, var(--bg-input))" : "var(--bg-input)",
borderColor: isExpanded ? "var(--accent)" : "var(--border-primary)",
color: isExpanded ? "var(--accent)" : "var(--text-secondary)",
whiteSpace: "nowrap",
}}
>
{isExpanded ? (
// Expanded icon: lines with detail
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 10h16M4 14h10M4 18h7" />
</svg>
) : (
// Quick icon: compact list
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
</svg>
)}
Notes: {isExpanded ? "Expanded" : "Quick"}
</button>
);
}
// ── Helpers ────────────────────────────────────────────────────────────────── // ── Helpers ──────────────────────────────────────────────────────────────────
function primaryContact(customer, type) { function primaryContact(customer, type) {
@@ -512,10 +585,22 @@ export default function CustomerList() {
const [colPrefs, setColPrefs] = useState(loadColumnPrefs); const [colPrefs, setColPrefs] = useState(loadColumnPrefs);
// Map of customer_id → "inbound" | "outbound" | null // Map of customer_id → "inbound" | "outbound" | null
const [commDirections, setCommDirections] = useState({}); 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 navigate = useNavigate();
const { hasPermission } = useAuth(); const { hasPermission } = useAuth();
const canEdit = hasPermission("crm", "edit"); const canEdit = hasPermission("crm", "edit");
const handleNotesModeChange = (mode) => {
setNotesMode(mode);
localStorage.setItem(NOTES_MODE_KEY, mode);
};
const fetchCustomers = async () => { const fetchCustomers = async () => {
setLoading(true); setLoading(true);
setError(""); setError("");
@@ -541,23 +626,28 @@ export default function CustomerList() {
const results = await Promise.allSettled( const results = await Promise.allSettled(
flagged.map(c => flagged.map(c =>
api.get(`/crm/customers/${c.id}/last-comm-direction`) api.get(`/crm/customers/${c.id}/last-comm-direction`)
.then(r => [c.id, r.direction]) .then(r => [c.id, r.direction, r.occurred_at || r.date || null])
.catch(() => [c.id, null]) .catch(() => [c.id, null, null])
) )
); );
const map = {}; const dirMap = {};
const dateMap = {};
for (const r of results) { for (const r of results) {
if (r.status === "fulfilled") { if (r.status === "fulfilled") {
const [id, dir] = r.value; const [id, dir, date] = r.value;
map[id] = dir; dirMap[id] = dir;
if (date) dateMap[id] = date;
} }
} }
setCommDirections(prev => ({ ...prev, ...map })); setCommDirections(prev => ({ ...prev, ...dirMap }));
setLastCommDates(prev => ({ ...prev, ...dateMap }));
}; };
useEffect(() => { fetchCustomers(); }, [search, sort]); useEffect(() => { fetchCustomers(); }, [search, sort]);
const updateColVisible = (id, vis) => { 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 }; const next = { ...colPrefs.visible, [id]: vis };
setColPrefs((p) => ({ ...p, visible: next })); setColPrefs((p) => ({ ...p, visible: next }));
saveColumnPrefs(next, colPrefs.orderedIds); saveColumnPrefs(next, colPrefs.orderedIds);
@@ -577,28 +667,36 @@ export default function CustomerList() {
(!activeFilters.has("has_problem") || c.has_problem) (!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) => { const handleCustomerUpdate = (updated) => {
setCustomers(prev => prev.map(c => c.id === updated.id ? updated : c)); setCustomers(prev => prev.map(c => c.id === updated.id ? updated : c));
// Refresh direction for this customer if it now has/lost a flag // Refresh direction for this customer if it now has/lost a flag
if (updated.negotiating || updated.has_problem) { if (updated.negotiating || updated.has_problem) {
api.get(`/crm/customers/${updated.id}/last-comm-direction`) 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(() => {}); .catch(() => {});
} }
}; };
const renderCell = (col, c) => { const renderCell = (col, c, direction) => {
const loc = c.location || {}; const loc = c.location || {};
switch (col.id) { switch (col.id) {
case "name": case "name":
return ( return (
<td key={col.id} className="px-4 py-3 font-medium" style={{ color: "var(--text-heading)" }}> <td key={col.id} className="px-4 py-3 font-medium" style={{ color: "var(--text-heading)" }}>
<div style={{ display: "flex", alignItems: "center", flexWrap: "wrap", gap: 2 }}> <span>{[TITLE_SHORT[c.title], c.name, c.surname].filter(Boolean).join(" ")}</span>
<span>{[TITLE_SHORT[c.title], c.name, c.surname].filter(Boolean).join(" ")}</span>
<CustomerStatusIcons customer={c} direction={commDirections[c.id] ?? null} />
</div>
</td> </td>
); );
case "status":
return <StatusIconsCell key={col.id} customer={c} direction={direction} />;
case "organization": case "organization":
return <td key={col.id} className="px-4 py-3" style={{ color: "var(--text-primary)" }}>{c.organization || "—"}</td>; return <td key={col.id} className="px-4 py-3" style={{ color: "var(--text-primary)" }}>{c.organization || "—"}</td>;
case "address": { 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 ( return (
<div> <div>
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
@@ -663,7 +781,19 @@ export default function CustomerList() {
style={inputStyle} style={inputStyle}
/> />
<SortDropdown value={sort} onChange={setSort} /> <SortDropdown value={sort} onChange={setSort} />
<NotesModeToggle value={notesMode} onChange={handleNotesModeChange} />
<FilterDropdown active={activeFilters} onChange={setActiveFilters} /> <FilterDropdown active={activeFilters} onChange={setActiveFilters} />
<select
value={String(pageSize)}
onChange={(e) => { setPageSize(Number(e.target.value)); setPage(1); }}
className="px-3 py-2 text-sm rounded-md border cursor-pointer"
style={{ backgroundColor: "var(--bg-input)", borderColor: "var(--border-primary)", color: "var(--text-primary)" }}
>
<option value="10">10 / page</option>
<option value="20">20 / page</option>
<option value="50">50 / page</option>
<option value="0">All</option>
</select>
<ColumnToggle <ColumnToggle
visible={colPrefs.visible} visible={colPrefs.visible}
orderedIds={colPrefs.orderedIds} orderedIds={colPrefs.orderedIds}
@@ -693,8 +823,8 @@ export default function CustomerList() {
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr style={{ backgroundColor: "var(--bg-primary)", borderBottom: "1px solid var(--border-primary)" }}> <tr style={{ backgroundColor: "var(--bg-primary)", borderBottom: "1px solid var(--border-primary)" }}>
{visibleCols.map((col) => ( {visibleColsForMode.map((col) => (
<th key={col.id} className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-secondary)" }}> <th key={col.id} className="px-4 py-3 font-medium" style={{ color: "var(--text-secondary)", textAlign: col.id === "status" ? "center" : "left", ...(col.id === "status" ? { width: 90 } : {}) }}>
{col.label} {col.label}
</th> </th>
))} ))}
@@ -702,29 +832,208 @@ export default function CustomerList() {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{filteredCustomers.map((c, index) => ( {pagedCustomers.map((c, index) => {
<tr const direction = commDirections[c.id] ?? null;
key={c.id} const lastDate = lastCommDates[c.id] ?? null;
onClick={() => navigate(`/crm/customers/${c.id}`)} const hasStatus = c.negotiating || c.has_problem;
className="cursor-pointer" const isLast = index === pagedCustomers.length - 1;
style={{ const gradient = rowGradient(c, direction);
borderBottom: index < filteredCustomers.length - 1 ? "1px solid var(--border-secondary)" : "none", const rowBg = hoveredRow === c.id ? "var(--bg-card-hover)" : undefined;
backgroundColor: hoveredRow === c.id ? "var(--bg-card-hover)" : "transparent", const rowStyle = {
}} borderBottom: (!isLast && !(notesMode === "expanded" && hasStatus))
onMouseEnter={() => setHoveredRow(c.id)} ? "1px solid var(--border-secondary)"
onMouseLeave={() => setHoveredRow(null)} : "none",
> background: rowBg
{visibleCols.map((col) => renderCell(col, c))} ? rowBg
{canEdit && ( : gradient || "transparent",
<td className="px-4 py-3 text-right"> };
<ActionsDropdown customer={c} onUpdate={handleCustomerUpdate} />
</td> const mainRow = (
)} <tr
</tr> key={`${c.id}-main`}
))} onClick={() => 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 && (
<td className="px-4 py-3 text-right">
<ActionsDropdown customer={c} onUpdate={handleCustomerUpdate} />
</td>
)}
</tr>
);
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(
<tr
key={`${c.id}-neg`}
className="cursor-pointer"
onClick={() => navigate(`/crm/customers/${c.id}`)}
style={{
borderBottom: "none",
background: subRowBg || gradient || "transparent",
}}
onMouseEnter={() => setHoveredRow(c.id)}
onMouseLeave={() => setHoveredRow(null)}
>
<td colSpan={totalCols} style={{ padding: "0px 16px 5px 16px" }}>
<div style={{ display: "flex", alignItems: "center", gap: 7 }}>
<span style={{ color: negColor, display: "inline-flex" }}>
<IconNegotiations style={{ width: EXP_NEG_ICON_SIZE, height: EXP_NEG_ICON_SIZE, flexShrink: 0 }} />
</span>
{pendingOurReply && (
<span style={{ color: "var(--crm-status-alert)", display: "inline-flex" }}>
<IconImportant style={{ width: EXP_IMP_ICON_SIZE, height: EXP_IMP_ICON_SIZE, flexShrink: 0 }} className="crm-icon-breathe" />
</span>
)}
<span style={{ fontSize: 11.5, color: negColor, fontWeight: 500 }}>{text}</span>
</div>
</td>
</tr>
);
}
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(
<tr
key={`${c.id}-iss`}
className="cursor-pointer"
onClick={() => navigate(`/crm/customers/${c.id}`)}
style={{
borderBottom: "none",
background: subRowBg || gradient || "transparent",
}}
onMouseEnter={() => setHoveredRow(c.id)}
onMouseLeave={() => setHoveredRow(null)}
>
<td colSpan={totalCols} style={{ padding: "0px 16px 5px 16px" }}>
<div style={{ display: "flex", alignItems: "center", gap: 7 }}>
<span style={{ color: issColor, display: "inline-flex" }}>
<IconIssues style={{ width: EXP_ISS_ICON_SIZE, height: EXP_ISS_ICON_SIZE, flexShrink: 0 }} />
</span>
{pendingOurReply && (
<span style={{ color: "var(--crm-status-danger)", display: "inline-flex" }}>
<IconImportant style={{ width: EXP_IMP_ICON_SIZE, height: EXP_IMP_ICON_SIZE, flexShrink: 0 }} className="crm-icon-breathe" />
</span>
)}
<span style={{ fontSize: 11.5, color: issColor, fontWeight: 500 }}>{text}</span>
</div>
</td>
</tr>
);
}
if (!isLast) {
subRows.push(
<tr key={`${c.id}-gap`} style={{ borderBottom: "1px solid var(--border-secondary)" }}>
<td colSpan={totalCols} style={{ padding: 0 }} />
</tr>
);
}
return [mainRow, ...subRows].filter(Boolean);
}
return (
<tr
key={c.id}
onClick={() => 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 && (
<td className="px-4 py-3 text-right">
<ActionsDropdown customer={c} onUpdate={handleCustomerUpdate} />
</td>
)}
</tr>
);
})}
</tbody> </tbody>
</table> </table>
</div> </div>
{pageSize > 0 && totalPages > 1 && (
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "10px 16px", borderTop: "1px solid var(--border-primary)" }}>
<span style={{ fontSize: 12, color: "var(--text-muted)" }}>
Page {safePage} of {totalPages} {filteredCustomers.length} total
</span>
<div style={{ display: "flex", alignItems: "center", gap: 4 }}>
{[
{ label: "«", onClick: () => setPage(1), disabled: safePage === 1 },
{ label: "", onClick: () => setPage((p) => Math.max(1, p - 1)), disabled: safePage === 1 },
].map(({ label, onClick, disabled }) => (
<button key={label} onClick={onClick} disabled={disabled}
style={{ padding: "3px 8px", fontSize: 12, borderRadius: 4, cursor: disabled ? "default" : "pointer", opacity: disabled ? 0.4 : 1, backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)", border: "none" }}>
{label}
</button>
))}
{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 === "…" ? (
<span key={`e-${idx}`} style={{ padding: "0 4px", fontSize: 12, color: "var(--text-muted)" }}></span>
) : (
<button key={p} onClick={() => setPage(p)}
style={{ padding: "3px 8px", fontSize: 12, borderRadius: 4, cursor: "pointer", fontWeight: p === safePage ? 700 : 400, backgroundColor: p === safePage ? "var(--accent)" : "var(--bg-card-hover)", color: p === safePage ? "var(--bg-primary)" : "var(--text-secondary)", border: "none" }}>
{p}
</button>
)
)}
{[
{ label: "", onClick: () => setPage((p) => Math.min(totalPages, p + 1)), disabled: safePage === totalPages },
{ label: "»", onClick: () => setPage(totalPages), disabled: safePage === totalPages },
].map(({ label, onClick, disabled }) => (
<button key={label} onClick={onClick} disabled={disabled}
style={{ padding: "3px 8px", fontSize: 12, borderRadius: 4, cursor: disabled ? "default" : "pointer", opacity: disabled ? 0.4 : 1, backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)", border: "none" }}>
{label}
</button>
))}
</div>
</div>
)}
</div> </div>
)} )}
</div> </div>

View File

@@ -57,12 +57,11 @@ function formatFullDateTime(value) {
const selectStyle = { const selectStyle = {
backgroundColor: "var(--bg-input)", backgroundColor: "var(--bg-input)",
borderColor: "var(--border-primary)",
color: "var(--text-primary)", color: "var(--text-primary)",
fontSize: 13, fontSize: 13,
padding: "6px 10px", padding: "6px 10px",
borderRadius: 6, borderRadius: 6,
border: "1px solid", border: "1px solid var(--border-primary)",
cursor: "pointer", cursor: "pointer",
}; };
@@ -167,6 +166,9 @@ export default function CommsPage() {
const [editForm, setEditForm] = useState({}); const [editForm, setEditForm] = useState({});
const [editSaving, setEditSaving] = useState(false); const [editSaving, setEditSaving] = useState(false);
const [pageSize, setPageSize] = useState(20);
const [page, setPage] = useState(1);
// Modals // Modals
const [viewEntry, setViewEntry] = useState(null); const [viewEntry, setViewEntry] = useState(null);
const [composeOpen, setComposeOpen] = useState(false); const [composeOpen, setComposeOpen] = useState(false);
@@ -279,6 +281,12 @@ export default function CommsPage() {
return String(b?.id || "").localeCompare(String(a?.id || "")); 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) => const customerOptions = Object.values(customers).sort((a, b) =>
(a.name || "").localeCompare(b.name || "") (a.name || "").localeCompare(b.name || "")
); );
@@ -297,7 +305,56 @@ export default function CommsPage() {
All customer communications across all channels All customer communications across all channels
</p> </p>
</div> </div>
<div className="flex items-center gap-2"> </div>
{/* Filters + Actions */}
<div className="flex flex-wrap gap-3 mb-5 items-center">
<select value={typeFilter} onChange={(e) => setTypeFilter(e.target.value)} style={selectStyle}>
<option value="">All types</option>
{COMMS_TYPES.map((t) => <option key={t} value={t}>{TYPE_LABELS[t] || t}</option>)}
</select>
<select value={dirFilter} onChange={(e) => setDirFilter(e.target.value)} style={selectStyle}>
<option value="">All directions</option>
{DIRECTIONS.map((d) => <option key={d} value={d}>{d}</option>)}
</select>
{/* Customer picker button */}
<button
type="button"
onClick={() => setCustPickerOpen(true)}
style={{
...selectStyle,
minWidth: 180,
textAlign: "left",
color: custFilter ? "var(--accent)" : "var(--text-primary)",
fontWeight: custFilter ? 600 : 400,
}}
>
{selectedCustomerLabel}
</button>
<select
value={String(pageSize)}
onChange={(e) => { setPageSize(Number(e.target.value)); setPage(1); }}
style={{ ...selectStyle }}
>
<option value="10">10 / page</option>
<option value="20">20 / page</option>
<option value="50">50 / page</option>
<option value="0">All</option>
</select>
{(typeFilter || dirFilter || custFilter) && (
<button
onClick={() => { setTypeFilter(""); setDirFilter(""); setCustFilter(""); }}
className="px-3 py-1.5 text-sm rounded-md cursor-pointer hover:opacity-80"
style={{ color: "var(--danger-text)", backgroundColor: "var(--danger-bg)", borderRadius: 6 }}
>
Clear filters
</button>
)}
<div className="flex items-center gap-2 ml-auto">
{syncResult && ( {syncResult && (
<span className="text-xs" style={{ color: syncResult.error ? "var(--danger-text)" : "var(--success-text)" }}> <span className="text-xs" style={{ color: syncResult.error ? "var(--danger-text)" : "var(--success-text)" }}>
{syncResult.error {syncResult.error
@@ -326,43 +383,6 @@ export default function CommsPage() {
</div> </div>
</div> </div>
{/* Filters */}
<div className="flex flex-wrap gap-3 mb-5">
<select value={typeFilter} onChange={(e) => setTypeFilter(e.target.value)} style={selectStyle}>
<option value="">All types</option>
{COMMS_TYPES.map((t) => <option key={t} value={t}>{TYPE_LABELS[t] || t}</option>)}
</select>
<select value={dirFilter} onChange={(e) => setDirFilter(e.target.value)} style={selectStyle}>
<option value="">All directions</option>
{DIRECTIONS.map((d) => <option key={d} value={d}>{d}</option>)}
</select>
{/* Customer picker button */}
<button
type="button"
onClick={() => setCustPickerOpen(true)}
style={{
...selectStyle,
minWidth: 180,
textAlign: "left",
color: custFilter ? "var(--accent)" : "var(--text-primary)",
fontWeight: custFilter ? 600 : 400,
}}
>
{selectedCustomerLabel}
</button>
{(typeFilter || dirFilter || custFilter) && (
<button
onClick={() => { setTypeFilter(""); setDirFilter(""); setCustFilter(""); }}
className="px-3 py-1.5 text-sm rounded-md cursor-pointer hover:opacity-80"
style={{ color: "var(--danger-text)", backgroundColor: "var(--danger-bg)", borderRadius: 6 }}
>
Clear filters
</button>
)}
</div>
{error && ( {error && (
<div className="text-sm rounded-md p-3 mb-4 border" style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}> <div className="text-sm rounded-md p-3 mb-4 border" style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}>
{error} {error}
@@ -379,6 +399,7 @@ export default function CommsPage() {
<div> <div>
<div className="text-xs mb-3" style={{ color: "var(--text-muted)" }}> <div className="text-xs mb-3" style={{ color: "var(--text-muted)" }}>
{sortedFiltered.length} entr{sortedFiltered.length !== 1 ? "ies" : "y"} {sortedFiltered.length} entr{sortedFiltered.length !== 1 ? "ies" : "y"}
{pageSize > 0 && totalPages > 1 && ` — page ${safePage} of ${totalPages}`}
</div> </div>
<div style={{ position: "relative" }}> <div style={{ position: "relative" }}>
@@ -389,7 +410,7 @@ export default function CommsPage() {
}} /> }} />
<div className="space-y-2"> <div className="space-y-2">
{sortedFiltered.map((entry) => { {pagedEntries.map((entry) => {
const customer = customers[entry.customer_id]; const customer = customers[entry.customer_id];
const isExpanded = expandedId === entry.id; const isExpanded = expandedId === entry.id;
const isEmail = entry.type === "email"; const isEmail = entry.type === "email";
@@ -485,13 +506,13 @@ export default function CommsPage() {
)} )}
{entry.subject && ( {entry.subject && (
<span className="text-sm font-medium truncate" style={{ color: "var(--text-heading)", maxWidth: 280 }}> <span className="text-sm font-medium" style={{ color: "var(--text-heading)", flex: 1, minWidth: 0 }}>
{entry.subject} {entry.subject}
</span> </span>
)} )}
<div className="ml-auto flex items-center gap-2"> <div className="flex items-center gap-2" style={{ flexShrink: 0, marginLeft: entry.subject ? 100 : "auto" }}>
<span className="text-xs flex-shrink-0" style={{ color: "var(--text-muted)" }}> <span className="text-xs" style={{ color: "var(--text-muted)", whiteSpace: "nowrap" }}>
{formatRelativeTime(entry.occurred_at)} {formatRelativeTime(entry.occurred_at)}
</span> </span>
</div> </div>
@@ -505,7 +526,7 @@ export default function CommsPage() {
style={{ style={{
color: "var(--text-primary)", color: "var(--text-primary)",
display: "-webkit-box", display: "-webkit-box",
WebkitLineClamp: isExpanded ? "unset" : 2, WebkitLineClamp: isExpanded ? "unset" : 3,
WebkitBoxOrient: "vertical", WebkitBoxOrient: "vertical",
overflow: isExpanded ? "visible" : "hidden", overflow: isExpanded ? "visible" : "hidden",
whiteSpace: "pre-wrap", whiteSpace: "pre-wrap",
@@ -612,6 +633,50 @@ export default function CommsPage() {
})} })}
</div> </div>
</div> </div>
{pageSize > 0 && totalPages > 1 && (
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginTop: 16 }}>
<span style={{ fontSize: 12, color: "var(--text-muted)" }}>
Page {safePage} of {totalPages}
</span>
<div style={{ display: "flex", alignItems: "center", gap: 4 }}>
{[
{ label: "«", onClick: () => setPage(1), disabled: safePage === 1 },
{ label: "", onClick: () => setPage((p) => Math.max(1, p - 1)), disabled: safePage === 1 },
].map(({ label, onClick, disabled }) => (
<button key={label} onClick={onClick} disabled={disabled}
style={{ padding: "3px 8px", fontSize: 12, borderRadius: 4, cursor: disabled ? "default" : "pointer", opacity: disabled ? 0.4 : 1, backgroundColor: "var(--bg-card)", border: "1px solid var(--border-primary)", color: "var(--text-secondary)" }}>
{label}
</button>
))}
{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 === "…" ? (
<span key={`e-${idx}`} style={{ padding: "0 4px", fontSize: 12, color: "var(--text-muted)" }}></span>
) : (
<button key={p} onClick={() => setPage(p)}
style={{ padding: "3px 8px", fontSize: 12, borderRadius: 4, cursor: "pointer", fontWeight: p === safePage ? 700 : 400, backgroundColor: p === safePage ? "var(--accent)" : "var(--bg-card)", color: p === safePage ? "var(--bg-primary)" : "var(--text-secondary)", border: "1px solid var(--border-primary)" }}>
{p}
</button>
)
)}
{[
{ label: "", onClick: () => setPage((p) => Math.min(totalPages, p + 1)), disabled: safePage === totalPages },
{ label: "»", onClick: () => setPage(totalPages), disabled: safePage === totalPages },
].map(({ label, onClick, disabled }) => (
<button key={label} onClick={onClick} disabled={disabled}
style={{ padding: "3px 8px", fontSize: 12, borderRadius: 4, cursor: disabled ? "default" : "pointer", opacity: disabled ? 0.4 : 1, backgroundColor: "var(--bg-card)", border: "1px solid var(--border-primary)", color: "var(--text-secondary)" }}>
{label}
</button>
))}
</div>
</div>
)}
</div> </div>
)} )}

File diff suppressed because it is too large Load Diff

View File

@@ -107,6 +107,8 @@ export default function DeviceList() {
const [hasBellsFilter, setHasBellsFilter] = useState(""); const [hasBellsFilter, setHasBellsFilter] = useState("");
const [deleteTarget, setDeleteTarget] = useState(null); const [deleteTarget, setDeleteTarget] = useState(null);
const [visibleColumns, setVisibleColumns] = useState(getDefaultVisibleColumns); const [visibleColumns, setVisibleColumns] = useState(getDefaultVisibleColumns);
const [pageSize, setPageSize] = useState(20);
const [page, setPage] = useState(1);
const [showColumnPicker, setShowColumnPicker] = useState(false); const [showColumnPicker, setShowColumnPicker] = useState(false);
const [mqttStatusMap, setMqttStatusMap] = useState({}); const [mqttStatusMap, setMqttStatusMap] = useState({});
const columnPickerRef = useRef(null); const columnPickerRef = useRef(null);
@@ -278,6 +280,12 @@ export default function DeviceList() {
return true; 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 activeColumns = ALL_COLUMNS.filter((c) => isVisible(c.key));
const selectClass = "px-3 py-2 rounded-md text-sm cursor-pointer border"; const selectClass = "px-3 py-2 rounded-md text-sm cursor-pointer border";
@@ -291,105 +299,117 @@ export default function DeviceList() {
<div> <div>
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold" style={{ color: "var(--text-heading)" }}>Device Fleet</h1> <h1 className="text-2xl font-bold" style={{ color: "var(--text-heading)" }}>Device Fleet</h1>
{canEdit && ( <div className="flex items-center gap-3">
<button <span className="text-sm whitespace-nowrap" style={{ color: "var(--text-muted)" }}>
onClick={() => navigate("/devices/new")} {filteredDevices.length} {filteredDevices.length === 1 ? "device" : "devices"}
className="px-4 py-2 text-sm rounded-md hover:opacity-90 transition-colors cursor-pointer" </span>
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }} {canEdit && (
> <button
Add Device onClick={() => navigate("/devices/new")}
</button> className="px-4 py-2 text-sm rounded-md hover:opacity-90 transition-colors cursor-pointer"
)} style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
>
Add Device
</button>
)}
</div>
</div> </div>
<div className="mb-4 space-y-3"> <div className="mb-4 flex flex-wrap gap-3 items-center">
<SearchBar <SearchBar
onSearch={setSearch} onSearch={setSearch}
placeholder="Search by name, location, or serial number..." placeholder="Search by name, location, or serial number..."
style={{ flex: "1 1 300px", minWidth: 300 }}
/> />
<div className="flex flex-wrap gap-3 items-center"> <select value={onlineFilter} onChange={(e) => setOnlineFilter(e.target.value)} className={selectClass} style={selectStyle}>
<select value={onlineFilter} onChange={(e) => setOnlineFilter(e.target.value)} className={selectClass} style={selectStyle}> <option value="">All Status</option>
<option value="">All Status</option> <option value="true">Online</option>
<option value="true">Online</option> <option value="false">Offline</option>
<option value="false">Offline</option> </select>
</select> <select value={tierFilter} onChange={(e) => setTierFilter(e.target.value)} className={selectClass} style={selectStyle}>
<select value={tierFilter} onChange={(e) => setTierFilter(e.target.value)} className={selectClass} style={selectStyle}> <option value="">All Tiers</option>
<option value="">All Tiers</option> {TIER_OPTIONS.filter(Boolean).map((t) => (
{TIER_OPTIONS.filter(Boolean).map((t) => ( <option key={t} value={t}>{t.charAt(0).toUpperCase() + t.slice(1)}</option>
<option key={t} value={t}>{t.charAt(0).toUpperCase() + t.slice(1)}</option> ))}
))} </select>
</select> <select value={subscrStatusFilter} onChange={(e) => setSubscrStatusFilter(e.target.value)} className={selectClass} style={selectStyle}>
<select value={subscrStatusFilter} onChange={(e) => setSubscrStatusFilter(e.target.value)} className={selectClass} style={selectStyle}> <option value="">Subscr Status</option>
<option value="">Subscr Status</option> <option value="active">Active</option>
<option value="active">Active</option> <option value="expired">Expired</option>
<option value="expired">Expired</option> </select>
</select> <select value={warrantyStatusFilter} onChange={(e) => setWarrantyStatusFilter(e.target.value)} className={selectClass} style={selectStyle}>
<select value={warrantyStatusFilter} onChange={(e) => setWarrantyStatusFilter(e.target.value)} className={selectClass} style={selectStyle}> <option value="">Warranty Status</option>
<option value="">Warranty Status</option> <option value="active">Active</option>
<option value="active">Active</option> <option value="expired">Expired</option>
<option value="expired">Expired</option> </select>
</select> <select value={hasClockFilter} onChange={(e) => setHasClockFilter(e.target.value)} className={selectClass} style={selectStyle}>
<select value={hasClockFilter} onChange={(e) => setHasClockFilter(e.target.value)} className={selectClass} style={selectStyle}> <option value="">Has Clock</option>
<option value="">Has Clock</option> <option value="yes">Yes</option>
<option value="yes">Yes</option> <option value="no">No</option>
<option value="no">No</option> </select>
</select> <select value={hasBellsFilter} onChange={(e) => setHasBellsFilter(e.target.value)} className={selectClass} style={selectStyle}>
<select value={hasBellsFilter} onChange={(e) => setHasBellsFilter(e.target.value)} className={selectClass} style={selectStyle}> <option value="">Has Bells</option>
<option value="">Has Bells</option> <option value="yes">Yes</option>
<option value="yes">Yes</option> <option value="no">No</option>
<option value="no">No</option> </select>
</select>
{/* Column visibility dropdown */} {/* Column visibility dropdown */}
<div className="relative" ref={columnPickerRef}> <div className="relative" ref={columnPickerRef}>
<button <button
type="button" type="button"
onClick={() => setShowColumnPicker((prev) => !prev)} onClick={() => setShowColumnPicker((prev) => !prev)}
className="px-3 py-2 rounded-md text-sm transition-colors cursor-pointer flex items-center gap-1.5 border" className="px-3 py-2 rounded-md text-sm transition-colors cursor-pointer flex items-center gap-1.5 border"
style={{
borderColor: "var(--border-primary)",
color: "var(--text-secondary)",
}}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7m0 10a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2h-2a2 2 0 00-2 2" />
</svg>
Columns
</button>
{showColumnPicker && (
<div
className="absolute right-0 top-full mt-1 z-20 rounded-lg shadow-lg py-2 w-52 border max-h-80 overflow-y-auto"
style={{ style={{
backgroundColor: "var(--bg-card)",
borderColor: "var(--border-primary)", borderColor: "var(--border-primary)",
color: "var(--text-secondary)",
}} }}
> >
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> {ALL_COLUMNS.map((col) => (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7m0 10a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2h-2a2 2 0 00-2 2" /> <label
</svg> key={col.key}
Columns className="flex items-center gap-2 px-3 py-1.5 text-sm cursor-pointer"
</button> style={{
{showColumnPicker && ( color: col.alwaysOn ? "var(--text-muted)" : "var(--text-primary)",
<div }}
className="absolute right-0 top-full mt-1 z-20 rounded-lg shadow-lg py-2 w-52 border max-h-80 overflow-y-auto" >
style={{ <input
backgroundColor: "var(--bg-card)", type="checkbox"
borderColor: "var(--border-primary)", checked={isVisible(col.key)}
}} onChange={() => toggleColumn(col.key)}
> disabled={col.alwaysOn}
{ALL_COLUMNS.map((col) => ( className="h-3.5 w-3.5 rounded cursor-pointer"
<label />
key={col.key} {col.label}
className="flex items-center gap-2 px-3 py-1.5 text-sm cursor-pointer" </label>
style={{ ))}
color: col.alwaysOn ? "var(--text-muted)" : "var(--text-primary)", </div>
}} )}
>
<input
type="checkbox"
checked={isVisible(col.key)}
onChange={() => toggleColumn(col.key)}
disabled={col.alwaysOn}
className="h-3.5 w-3.5 rounded cursor-pointer"
/>
{col.label}
</label>
))}
</div>
)}
</div>
<span className="flex items-center text-sm" style={{ color: "var(--text-muted)" }}>
{filteredDevices.length} {filteredDevices.length === 1 ? "device" : "devices"}
</span>
</div> </div>
<select
value={String(pageSize)}
onChange={(e) => { setPageSize(Number(e.target.value)); setPage(1); }}
className={selectClass}
style={selectStyle}
>
<option value="10">10 / page</option>
<option value="20">20 / page</option>
<option value="50">50 / page</option>
<option value="0">All</option>
</select>
</div> </div>
{error && ( {error && (
@@ -445,7 +465,7 @@ export default function DeviceList() {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{filteredDevices.map((device) => ( {pagedDevices.map((device) => (
<tr <tr
key={device.id} key={device.id}
onClick={() => navigate(`/devices/${device.id}`)} onClick={() => navigate(`/devices/${device.id}`)}
@@ -484,6 +504,62 @@ export default function DeviceList() {
</tbody> </tbody>
</table> </table>
</div> </div>
{pageSize > 0 && totalPages > 1 && (
<div className="flex items-center justify-between px-4 py-3 border-t" style={{ borderColor: "var(--border-primary)" }}>
<span className="text-xs" style={{ color: "var(--text-muted)" }}>
Page {safePage} of {totalPages} {filteredDevices.length} total
</span>
<div className="flex items-center gap-1">
<button
onClick={() => setPage(1)}
disabled={safePage === 1}
className="px-2 py-1 text-xs rounded disabled:opacity-40 cursor-pointer"
style={{ color: "var(--text-secondary)", backgroundColor: "var(--bg-card-hover)" }}
>«</button>
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={safePage === 1}
className="px-2 py-1 text-xs rounded disabled:opacity-40 cursor-pointer"
style={{ color: "var(--text-secondary)", backgroundColor: "var(--bg-card-hover)" }}
></button>
{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 === "…" ? (
<span key={`ellipsis-${idx}`} className="px-1 text-xs" style={{ color: "var(--text-muted)" }}></span>
) : (
<button
key={p}
onClick={() => setPage(p)}
className="px-2 py-1 text-xs rounded cursor-pointer"
style={{
backgroundColor: p === safePage ? "var(--accent)" : "var(--bg-card-hover)",
color: p === safePage ? "var(--bg-primary)" : "var(--text-secondary)",
fontWeight: p === safePage ? 700 : 400,
}}
>{p}</button>
)
)}
<button
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={safePage === totalPages}
className="px-2 py-1 text-xs rounded disabled:opacity-40 cursor-pointer"
style={{ color: "var(--text-secondary)", backgroundColor: "var(--bg-card-hover)" }}
></button>
<button
onClick={() => setPage(totalPages)}
disabled={safePage === totalPages}
className="px-2 py-1 text-xs rounded disabled:opacity-40 cursor-pointer"
style={{ color: "var(--text-secondary)", backgroundColor: "var(--bg-card-hover)" }}
>»</button>
</div>
</div>
)}
</div> </div>
)} )}

View File

@@ -376,6 +376,8 @@ export default function MelodyList() {
const [builtInSavingId, setBuiltInSavingId] = useState(null); const [builtInSavingId, setBuiltInSavingId] = useState(null);
const [viewRow, setViewRow] = useState(null); const [viewRow, setViewRow] = useState(null);
const [builtMap, setBuiltMap] = useState({}); const [builtMap, setBuiltMap] = useState({});
const [pageSize, setPageSize] = useState(20);
const [page, setPage] = useState(1);
const creatorPickerRef = useRef(null); const creatorPickerRef = useRef(null);
// Derived helpers from colPrefs // Derived helpers from colPrefs
@@ -687,6 +689,12 @@ export default function MelodyList() {
}); });
}, [melodies, createdByFilter, sortBy, sortDir]); // eslint-disable-line react-hooks/exhaustive-deps }, [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( const offlineTaggedCount = useMemo(
() => displayRows.filter((row) => Boolean(row?.information?.available_offline)).length, () => displayRows.filter((row) => Boolean(row?.information?.available_offline)).length,
[displayRows] [displayRows]
@@ -1055,155 +1063,163 @@ export default function MelodyList() {
return ( return (
<div className="w-full min-w-0 max-w-full"> <div className="w-full min-w-0 max-w-full">
<div className="w-full min-w-0 relative" style={{ zIndex: 30 }}> <div className="w-full min-w-0 relative" style={{ zIndex: 30 }}>
<div className="flex items-center justify-between mb-6 w-full min-w-0"> <div className="flex items-center justify-between mb-4 w-full min-w-0 gap-3 flex-wrap">
<h1 className="text-2xl font-bold" style={{ color: "var(--text-heading)" }}>Melody Library</h1> <h1 className="text-2xl font-bold" style={{ color: "var(--text-heading)" }}>Melody Library</h1>
{canEdit && ( <div className="flex items-center gap-3 flex-wrap">
<button <span className="text-sm whitespace-nowrap" style={{ color: "var(--text-muted)" }}>
onClick={() => navigate("/melodies/new")} {hasAnyFilter
className="px-4 py-2 text-sm rounded-md transition-colors cursor-pointer" ? `Filtered — ${displayRows.length} / ${allMelodyCount || melodies.length} | ${offlineTaggedCount} offline-tagged`
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }} : `Showing all (${allMelodyCount || melodies.length}) | ${offlineTaggedCount} tagged for Offline`}
> </span>
Add Melody {canEdit && (
</button> <button
)} type="button"
onClick={() => setShowOfflineModal(true)}
className="px-3 py-2 text-sm rounded-md border transition-colors cursor-pointer whitespace-nowrap"
style={{
borderColor: "var(--border-primary)",
color: "var(--text-secondary)",
backgroundColor: "var(--bg-card)",
}}
>
Build Offline List
</button>
)}
{canEdit && (
<button
onClick={() => navigate("/melodies/new")}
className="px-4 py-2 text-sm rounded-md transition-colors cursor-pointer"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
>
Add Melody
</button>
)}
</div>
</div> </div>
<div className="mb-4 space-y-3 w-full min-w-0 relative" style={{ zIndex: 40 }}> <div className="mb-4 flex flex-wrap gap-3 items-center w-full min-w-0 relative" style={{ zIndex: 40 }}>
<SearchBar <SearchBar
onSearch={setSearch} onSearch={setSearch}
placeholder="Search by name, description, or tags..." placeholder="Search by name, description, or tags..."
style={{ flex: "1 1 250px", minWidth: 250 }}
/> />
<div className="w-full min-w-0 flex items-start justify-between gap-3 flex-wrap"> <select
<div className="min-w-0 flex-1 flex flex-wrap gap-3 items-center"> value={typeFilter}
<select onChange={(e) => setTypeFilter(e.target.value)}
value={typeFilter} className={selectClass}
onChange={(e) => setTypeFilter(e.target.value)} >
className={selectClass} <option value="">All Types</option>
> {MELODY_TYPES.filter(Boolean).map((t) => (
<option value="">All Types</option> <option key={t} value={t}>
{MELODY_TYPES.filter(Boolean).map((t) => ( {t.charAt(0).toUpperCase() + t.slice(1)}
<option key={t} value={t}> </option>
{t.charAt(0).toUpperCase() + t.slice(1)} ))}
</option> </select>
))}
</select>
<select <select
value={toneFilter} value={toneFilter}
onChange={(e) => setToneFilter(e.target.value)} onChange={(e) => setToneFilter(e.target.value)}
className={selectClass} className={selectClass}
> >
<option value="">All Tones</option> <option value="">All Tones</option>
{MELODY_TONES.filter(Boolean).map((t) => ( {MELODY_TONES.filter(Boolean).map((t) => (
<option key={t} value={t}> <option key={t} value={t}>
{t.charAt(0).toUpperCase() + t.slice(1)} {t.charAt(0).toUpperCase() + t.slice(1)}
</option> </option>
))} ))}
</select> </select>
<select <select
value={statusFilter} value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)} onChange={(e) => setStatusFilter(e.target.value)}
className={selectClass} className={selectClass}
> >
<option value="">All Statuses</option> <option value="">All Statuses</option>
<option value="published">Published (Live)</option> <option value="published">Published (Live)</option>
<option value="draft">Drafts</option> <option value="draft">Drafts</option>
</select> </select>
<div className="relative" ref={creatorPickerRef} style={{ zIndex: 60 }}> <div className="relative" ref={creatorPickerRef} style={{ zIndex: 60 }}>
<button <button
type="button" type="button"
onClick={() => setShowCreatorPicker((prev) => !prev)} onClick={() => setShowCreatorPicker((prev) => !prev)}
className="px-3 py-2 rounded-md text-sm transition-colors cursor-pointer flex items-center gap-1.5 border" className="px-3 py-2 rounded-md text-sm transition-colors cursor-pointer flex items-center gap-1.5 border"
style={{ borderColor: "var(--border-primary)", color: "var(--text-secondary)" }} style={{ borderColor: "var(--border-primary)", color: "var(--text-secondary)" }}
> >
Created By Created By
{createdByFilter.length > 0 ? ` (${createdByFilter.length})` : ""} {createdByFilter.length > 0 ? ` (${createdByFilter.length})` : ""}
</button> </button>
{showCreatorPicker && ( {showCreatorPicker && (
<div <div
className="absolute left-0 top-full mt-1 rounded-lg shadow-lg py-2 w-64 border max-h-64 overflow-auto" className="absolute left-0 top-full mt-1 rounded-lg shadow-lg py-2 w-64 border max-h-64 overflow-auto"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)", zIndex: 9999 }} style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)", zIndex: 9999 }}
>
{allCreators.length === 0 ? (
<p className="px-3 py-1.5 text-sm" style={{ color: "var(--text-muted)" }}>No creators found</p>
) : (
allCreators.map((creator) => (
<label
key={creator}
className="flex items-center gap-2 px-3 py-1.5 text-sm cursor-pointer"
style={{ color: "var(--text-primary)" }}
>
<input
type="checkbox"
checked={createdByFilter.includes(creator)}
onChange={() => toggleCreator(creator)}
className="h-3.5 w-3.5 rounded cursor-pointer"
/>
{creator}
</label>
))
)}
{createdByFilter.length > 0 && (
<button
type="button"
onClick={() => setCreatedByFilter([])}
className="w-full text-left px-3 py-1.5 text-xs underline"
style={{ color: "var(--accent)", background: "none", border: "none" }}
> >
{allCreators.length === 0 ? ( Clear selection
<p className="px-3 py-1.5 text-sm" style={{ color: "var(--text-muted)" }}>No creators found</p> </button>
) : (
allCreators.map((creator) => (
<label
key={creator}
className="flex items-center gap-2 px-3 py-1.5 text-sm cursor-pointer"
style={{ color: "var(--text-primary)" }}
>
<input
type="checkbox"
checked={createdByFilter.includes(creator)}
onChange={() => toggleCreator(creator)}
className="h-3.5 w-3.5 rounded cursor-pointer"
/>
{creator}
</label>
))
)}
{createdByFilter.length > 0 && (
<button
type="button"
onClick={() => setCreatedByFilter([])}
className="w-full text-left px-3 py-1.5 text-xs underline"
style={{ color: "var(--accent)", background: "none", border: "none" }}
>
Clear selection
</button>
)}
</div>
)} )}
</div> </div>
)}
</div>
{languages.length > 1 && ( {languages.length > 1 && (
<select <select
value={displayLang} value={displayLang}
onChange={(e) => setDisplayLang(e.target.value)} onChange={(e) => setDisplayLang(e.target.value)}
className={selectClass} className={selectClass}
> >
{languages.map((l) => ( {languages.map((l) => (
<option key={l} value={l}> <option key={l} value={l}>
{getLanguageName(l)} {getLanguageName(l)}
</option> </option>
))} ))}
</select> </select>
)} )}
<div style={{ zIndex: 60, position: "relative" }}> <select
<MelodyColumnToggle value={String(pageSize)}
visible={colPrefs.visible} onChange={(e) => { setPageSize(Number(e.target.value)); setPage(1); }}
orderedIds={colPrefs.orderedIds} className={selectClass}
onChange={handleColVisChange} style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)", color: "var(--text-primary)" }}
onReorder={handleColReorder} >
/> <option value="10">10 / page</option>
</div> <option value="20">20 / page</option>
</div> <option value="50">50 / page</option>
<option value="0">All</option>
</select>
<div className="flex items-center justify-end gap-3 shrink-0 flex-wrap ml-auto"> <div style={{ zIndex: 60, position: "relative" }}>
<span className="text-sm whitespace-nowrap" style={{ color: "var(--text-muted)" }}> <MelodyColumnToggle
{hasAnyFilter visible={colPrefs.visible}
? `Filtered - Showing ${displayRows.length} / ${allMelodyCount || melodies.length} Melodies | ${offlineTaggedCount} Offline-tagged` orderedIds={colPrefs.orderedIds}
: `Showing all (${allMelodyCount || melodies.length}) Melodies | ${offlineTaggedCount} Melodies tagged for Offline`} onChange={handleColVisChange}
</span> onReorder={handleColReorder}
{canEdit && ( />
<button
type="button"
onClick={() => setShowOfflineModal(true)}
className="px-3 py-2 text-sm rounded-md border transition-colors cursor-pointer whitespace-nowrap"
style={{
borderColor: "var(--border-primary)",
color: "var(--text-secondary)",
backgroundColor: "var(--bg-card)",
}}
>
Build Offline List
</button>
)}
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -1284,7 +1300,7 @@ export default function MelodyList() {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{displayRows.map((row) => ( {pagedRows.map((row) => (
<tr <tr
key={row.id} key={row.id}
onClick={() => navigate(`/melodies/${row.id}`)} onClick={() => navigate(`/melodies/${row.id}`)}
@@ -1348,6 +1364,50 @@ export default function MelodyList() {
</tbody> </tbody>
</table> </table>
</div> </div>
{pageSize > 0 && totalPages > 1 && (
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "10px 16px", borderTop: "1px solid var(--border-primary)" }}>
<span style={{ fontSize: 12, color: "var(--text-muted)" }}>
Page {safePage} of {totalPages} {displayRows.length} total
</span>
<div style={{ display: "flex", alignItems: "center", gap: 4 }}>
{[
{ label: "«", onClick: () => setPage(1), disabled: safePage === 1 },
{ label: "", onClick: () => setPage((p) => Math.max(1, p - 1)), disabled: safePage === 1 },
].map(({ label, onClick, disabled }) => (
<button key={label} onClick={onClick} disabled={disabled}
style={{ padding: "3px 8px", fontSize: 12, borderRadius: 4, cursor: disabled ? "default" : "pointer", opacity: disabled ? 0.4 : 1, backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)", border: "none" }}>
{label}
</button>
))}
{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 === "…" ? (
<span key={`e-${idx}`} style={{ padding: "0 4px", fontSize: 12, color: "var(--text-muted)" }}></span>
) : (
<button key={p} onClick={() => setPage(p)}
style={{ padding: "3px 8px", fontSize: 12, borderRadius: 4, cursor: "pointer", fontWeight: p === safePage ? 700 : 400, backgroundColor: p === safePage ? "var(--accent)" : "var(--bg-card-hover)", color: p === safePage ? "var(--bg-primary)" : "var(--text-secondary)", border: "none" }}>
{p}
</button>
)
)}
{[
{ label: "", onClick: () => setPage((p) => Math.min(totalPages, p + 1)), disabled: safePage === totalPages },
{ label: "»", onClick: () => setPage(totalPages), disabled: safePage === totalPages },
].map(({ label, onClick, disabled }) => (
<button key={label} onClick={onClick} disabled={disabled}
style={{ padding: "3px 8px", fontSize: 12, borderRadius: 4, cursor: disabled ? "default" : "pointer", opacity: disabled ? 0.4 : 1, backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)", border: "none" }}>
{label}
</button>
))}
</div>
</div>
)}
</div> </div>
)} )}

View File

@@ -214,6 +214,21 @@ export default function PlaybackModal({ open, melody, builtMelody, files, archet
setActiveBells(new Set()); 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(() => { useEffect(() => {
if (!open) { if (!open) {
stopPlayback(); stopPlayback();
@@ -344,7 +359,7 @@ export default function PlaybackModal({ open, melody, builtMelody, files, archet
<div <div
className="fixed inset-0 flex items-center justify-center z-50" className="fixed inset-0 flex items-center justify-center z-50"
style={{ backgroundColor: "rgba(0,0,0,0.7)" }} style={{ backgroundColor: "rgba(0,0,0,0.7)" }}
onClick={(e) => e.target === e.currentTarget && !playing && onClose()} onClick={(e) => { if (e.target === e.currentTarget) { stopPlayback(); onClose(); } }}
> >
<div <div
className="w-full rounded-lg border shadow-xl" className="w-full rounded-lg border shadow-xl"

View File

@@ -58,44 +58,45 @@ export default function UserList() {
<div> <div>
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold" style={{ color: "var(--text-heading)" }}>User Management</h1> <h1 className="text-2xl font-bold" style={{ color: "var(--text-heading)" }}>User Management</h1>
{canEdit && ( <div className="flex items-center gap-3">
<button <span className="text-sm whitespace-nowrap" style={{ color: "var(--text-muted)" }}>
onClick={() => navigate("/users/new")} {total} {total === 1 ? "user" : "users"}
className="px-4 py-2 text-sm rounded-md hover:opacity-90 transition-colors cursor-pointer" </span>
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }} {canEdit && (
> <button
Add User onClick={() => navigate("/users/new")}
</button> className="px-4 py-2 text-sm rounded-md hover:opacity-90 transition-colors cursor-pointer"
)} style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
>
Add User
</button>
)}
</div>
</div> </div>
<div className="mb-4 space-y-3"> <div className="mb-4 flex flex-wrap gap-3 items-center">
<SearchBar <SearchBar
onSearch={setSearch} onSearch={setSearch}
placeholder="Search by name, email, phone, or UID..." placeholder="Search by name, email, phone, or UID..."
style={{ flex: "1 1 200px", minWidth: 200 }}
/> />
<div className="flex flex-wrap gap-3 items-center"> <select
<select value={statusFilter}
value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}
onChange={(e) => setStatusFilter(e.target.value)} className="px-3 py-2 rounded-md text-sm cursor-pointer border"
className="px-3 py-2 rounded-md text-sm cursor-pointer border" style={{
style={{ backgroundColor: "var(--bg-card)",
backgroundColor: "var(--bg-card)", color: "var(--text-primary)",
color: "var(--text-primary)", borderColor: "var(--border-primary)",
borderColor: "var(--border-primary)", }}
}} >
> <option value="">All Status</option>
<option value="">All Status</option> {STATUS_OPTIONS.filter(Boolean).map((s) => (
{STATUS_OPTIONS.filter(Boolean).map((s) => ( <option key={s} value={s}>
<option key={s} value={s}> {s.charAt(0).toUpperCase() + s.slice(1)}
{s.charAt(0).toUpperCase() + s.slice(1)} </option>
</option> ))}
))} </select>
</select>
<span className="flex items-center text-sm" style={{ color: "var(--text-muted)" }}>
{total} {total === 1 ? "user" : "users"}
</span>
</div>
</div> </div>
{error && ( {error && (