fix: devices list accepts ips as single strings / various ui changes
This commit is contained in:
@@ -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):
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,14 +14,18 @@ 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={(e) => setValue(e.target.value)}
|
onChange={handleChange}
|
||||||
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"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--bg-input)",
|
||||||
|
borderColor: "var(--border-primary)",
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
{value && (
|
{value && (
|
||||||
<button
|
<button
|
||||||
@@ -34,16 +38,5 @@ export default function SearchBar({ onSearch, placeholder = "Search..." }) {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="px-4 py-2 text-sm rounded-md transition-colors"
|
|
||||||
style={{
|
|
||||||
backgroundColor: "var(--btn-neutral)",
|
|
||||||
color: "var(--text-heading)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Search
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -44,7 +44,8 @@ function resolveLanguage(val) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ALL_COLUMNS = [
|
const ALL_COLUMNS = [
|
||||||
{ id: "name", label: "Name", default: true },
|
{ id: "name", label: "Name", default: true, locked: true },
|
||||||
|
{ id: "status", label: "Status", default: true },
|
||||||
{ id: "organization", label: "Organization", default: true },
|
{ id: "organization", label: "Organization", default: true },
|
||||||
{ id: "address", label: "Full Address", default: true },
|
{ id: "address", label: "Full Address", default: true },
|
||||||
{ id: "location", label: "Location", default: true },
|
{ id: "location", label: "Location", default: true },
|
||||||
@@ -64,6 +65,7 @@ const SORT_OPTIONS = [
|
|||||||
|
|
||||||
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" }}>
|
||||||
|
<div style={{ display: "inline-flex", alignItems: "center", gap: 6 }}>
|
||||||
{hasNeg && (
|
{hasNeg && (
|
||||||
<span title="Negotiating" style={{ color: negColor, display: "inline-flex" }}>
|
<span
|
||||||
<IconNegotiations style={iconSize} />
|
title={pendingOurReply ? "Negotiating — client awaiting our reply" : "Negotiating — we sent last"}
|
||||||
|
style={{ color: negColor, display: "inline-flex" }}
|
||||||
|
>
|
||||||
|
<IconNegotiations style={{ width: QUICK_NEG_ICON_SIZE, height: QUICK_NEG_ICON_SIZE, display: "inline-block", flexShrink: 0 }} />
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{hasIssue && (
|
{hasIssue && (
|
||||||
<span title="Has issue" style={{ color: issColor, display: "inline-flex" }}>
|
<span
|
||||||
<IconIssues style={iconSize} />
|
title={pendingOurReply ? "Open issue — client awaiting our reply" : "Open issue — we last contacted them"}
|
||||||
|
style={{ color: issColor, display: "inline-flex" }}
|
||||||
|
>
|
||||||
|
<IconIssues style={{ width: QUICK_ISS_ICON_SIZE, height: QUICK_ISS_ICON_SIZE, display: "inline-block", flexShrink: 0 }} />
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{(hasNeg || hasIssue) && pendingOurReply && (
|
{(hasNeg || hasIssue) && pendingOurReply && (
|
||||||
<span title="Awaiting our reply" style={{ color: "var(--crm-status-warn)", display: "inline-flex" }}>
|
<span title="Awaiting our reply" style={{ color: "var(--crm-status-alert)", display: "inline-flex" }}>
|
||||||
<IconImportant style={iconSize} className="crm-icon-breathe" />
|
<IconImportant style={{ width: QUICK_IMP_ICON_SIZE, height: QUICK_IMP_ICON_SIZE, display: "inline-block", flexShrink: 0 }} className="crm-icon-breathe" />
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</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) => {
|
||||||
|
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 = (
|
||||||
<tr
|
<tr
|
||||||
key={c.id}
|
key={`${c.id}-main`}
|
||||||
onClick={() => navigate(`/crm/customers/${c.id}`)}
|
onClick={() => navigate(`/crm/customers/${c.id}`)}
|
||||||
className="cursor-pointer"
|
className="cursor-pointer"
|
||||||
style={{
|
style={rowStyle}
|
||||||
borderBottom: index < filteredCustomers.length - 1 ? "1px solid var(--border-secondary)" : "none",
|
|
||||||
backgroundColor: hoveredRow === c.id ? "var(--bg-card-hover)" : "transparent",
|
|
||||||
}}
|
|
||||||
onMouseEnter={() => setHoveredRow(c.id)}
|
onMouseEnter={() => setHoveredRow(c.id)}
|
||||||
onMouseLeave={() => setHoveredRow(null)}
|
onMouseLeave={() => setHoveredRow(null)}
|
||||||
>
|
>
|
||||||
{visibleCols.map((col) => renderCell(col, c))}
|
{visibleColsForMode.map((col) => renderCell(col, c, direction))}
|
||||||
{canEdit && (
|
{canEdit && (
|
||||||
<td className="px-4 py-3 text-right">
|
<td className="px-4 py-3 text-right">
|
||||||
<ActionsDropdown customer={c} onUpdate={handleCustomerUpdate} />
|
<ActionsDropdown customer={c} onUpdate={handleCustomerUpdate} />
|
||||||
</td>
|
</td>
|
||||||
)}
|
)}
|
||||||
</tr>
|
</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>
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -3,72 +3,199 @@ import { useNavigate } from "react-router-dom";
|
|||||||
import { useAuth } from "../auth/AuthContext";
|
import { useAuth } from "../auth/AuthContext";
|
||||||
import api from "../api/client";
|
import api from "../api/client";
|
||||||
|
|
||||||
const STATUS_STYLES = {
|
// ─── Status config ────────────────────────────────────────────────────────────
|
||||||
manufactured: { bg: "var(--bg-card-hover)", color: "var(--text-muted)" },
|
const STATUS_CFG = {
|
||||||
flashed: { bg: "var(--badge-blue-bg)", color: "var(--badge-blue-text)" },
|
manufactured: { color: "#9ca3af", glow: "rgba(156,163,175,0.15)", label: "Manufactured" },
|
||||||
provisioned: { bg: "#0a2e2a", color: "#4dd6c8" },
|
flashed: { color: "#63b3ed", glow: "rgba(99,179,237,0.15)", label: "Flashed" },
|
||||||
sold: { bg: "#1e1036", color: "#c084fc" },
|
provisioned: { color: "#4dd6c8", glow: "rgba(77,214,200,0.15)", label: "Provisioned" },
|
||||||
claimed: { bg: "#2e1a00", color: "#fb923c" },
|
sold: { color: "#c084fc", glow: "rgba(192,132,252,0.15)", label: "Sold" },
|
||||||
decommissioned: { bg: "var(--danger-bg)", color: "var(--danger-text)" },
|
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 STATUS_ORDER = ["manufactured", "flashed", "provisioned", "sold", "claimed", "decommissioned"];
|
||||||
|
|
||||||
const ACTION_LABELS = {
|
const ACTION_LABELS = {
|
||||||
batch_created: "Batch created",
|
batch_created: "Batch Created",
|
||||||
device_flashed: "NVS downloaded",
|
device_flashed: "NVS Downloaded",
|
||||||
device_assigned: "Device assigned",
|
device_assigned: "Device Assigned",
|
||||||
status_updated: "Status updated",
|
status_updated: "Status Updated",
|
||||||
};
|
};
|
||||||
|
|
||||||
function StatusBadge({ status }) {
|
// ─── Module nav tiles ─────────────────────────────────────────────────────────
|
||||||
const style = STATUS_STYLES[status] || STATUS_STYLES.manufactured;
|
const MODULE_TILES = [
|
||||||
|
{
|
||||||
|
key: "devices",
|
||||||
|
label: "Fleet",
|
||||||
|
sublabel: "Deployed Devices",
|
||||||
|
to: "/devices",
|
||||||
|
permission: "devices",
|
||||||
|
icon: (
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<rect x="2" y="3" width="20" height="14" rx="2" />
|
||||||
|
<path d="M8 21h8M12 17v4" />
|
||||||
|
<circle cx="12" cy="10" r="3" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
accent: "#4dd6c8",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "melodies",
|
||||||
|
label: "Melodies",
|
||||||
|
sublabel: "Library & Composer",
|
||||||
|
to: "/melodies",
|
||||||
|
permission: "melodies",
|
||||||
|
icon: (
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M9 18V5l12-2v13" />
|
||||||
|
<circle cx="6" cy="18" r="3" />
|
||||||
|
<circle cx="18" cy="16" r="3" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
accent: "#c084fc",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "crm",
|
||||||
|
label: "Customers",
|
||||||
|
sublabel: "CRM & Orders",
|
||||||
|
to: "/crm/customers",
|
||||||
|
permission: "crm",
|
||||||
|
icon: (
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2" />
|
||||||
|
<circle cx="9" cy="7" r="4" />
|
||||||
|
<path d="M23 21v-2a4 4 0 00-3-3.87M16 3.13a4 4 0 010 7.75" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
accent: "#fb923c",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "manufacturing",
|
||||||
|
label: "Manufacturing",
|
||||||
|
sublabel: "Inventory & Provisioning",
|
||||||
|
to: "/manufacturing",
|
||||||
|
permission: "manufacturing",
|
||||||
|
icon: (
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
accent: "#74b816",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "firmware",
|
||||||
|
label: "Firmware",
|
||||||
|
sublabel: "OTA Manager",
|
||||||
|
to: "/firmware",
|
||||||
|
permission: "manufacturing",
|
||||||
|
icon: (
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<polyline points="16 18 22 12 16 6" />
|
||||||
|
<polyline points="8 6 2 12 8 18" />
|
||||||
|
<line x1="19" y1="12" x2="5" y2="12" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
accent: "#63b3ed",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "mqtt",
|
||||||
|
label: "Command Center",
|
||||||
|
sublabel: "MQTT & Control",
|
||||||
|
to: "/mqtt/commands",
|
||||||
|
permission: "mqtt",
|
||||||
|
icon: (
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M18.36 6.64a9 9 0 010 12.72M5.64 6.64a9 9 0 000 12.72M12 18a6 6 0 010-12M12 18v.01" />
|
||||||
|
<circle cx="12" cy="12" r="1" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
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 <span style={{ color: "var(--text-muted)", fontSize: 11 }}>{status}</span>;
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className="px-2 py-0.5 text-xs rounded-full capitalize font-medium"
|
style={{
|
||||||
style={{ backgroundColor: style.bg, color: style.color }}
|
display: "inline-block",
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: 700,
|
||||||
|
letterSpacing: "0.06em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
padding: "2px 8px",
|
||||||
|
borderRadius: 3,
|
||||||
|
backgroundColor: cfg.glow,
|
||||||
|
color: cfg.color,
|
||||||
|
border: `1px solid ${cfg.color}33`,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{status}
|
{cfg.label}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatCard({ label, count, status, onClick }) {
|
// ─── Background grid texture ──────────────────────────────────────────────────
|
||||||
const style = STATUS_STYLES[status] || STATUS_STYLES.manufactured;
|
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")`;
|
||||||
return (
|
|
||||||
<button
|
|
||||||
onClick={onClick}
|
|
||||||
className="rounded-lg border p-4 text-left transition-colors cursor-pointer w-full"
|
|
||||||
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
|
|
||||||
onMouseEnter={(e) => (e.currentTarget.style.backgroundColor = "var(--bg-card-hover)")}
|
|
||||||
onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = "var(--bg-card)")}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="text-3xl font-bold mb-1"
|
|
||||||
style={{ color: style.color }}
|
|
||||||
>
|
|
||||||
{count}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs capitalize font-medium" style={{ color: "var(--text-muted)" }}>
|
|
||||||
{label}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// ─── Main Dashboard ───────────────────────────────────────────────────────────
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const { user, hasPermission } = useAuth();
|
const { user, hasPermission } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const canViewMfg = hasPermission("manufacturing", "view");
|
const canViewMfg = hasPermission("manufacturing", "view");
|
||||||
|
|
||||||
const [stats, setStats] = useState(null);
|
const [stats, setStats] = useState(null);
|
||||||
const [auditLog, setAuditLog] = useState([]);
|
const [auditLog, setAuditLog] = useState([]);
|
||||||
const [loadingStats, setLoadingStats] = useState(false);
|
const [loadingStats, setLoadingStats] = useState(false);
|
||||||
const [loadingAudit, setLoadingAudit] = 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(() => {
|
useEffect(() => {
|
||||||
if (!canViewMfg) return;
|
if (!canViewMfg) return;
|
||||||
|
|
||||||
setLoadingStats(true);
|
setLoadingStats(true);
|
||||||
api.get("/manufacturing/stats")
|
api.get("/manufacturing/stats")
|
||||||
.then(setStats)
|
.then(setStats)
|
||||||
@@ -76,191 +203,605 @@ export default function DashboardPage() {
|
|||||||
.finally(() => setLoadingStats(false));
|
.finally(() => setLoadingStats(false));
|
||||||
|
|
||||||
setLoadingAudit(true);
|
setLoadingAudit(true);
|
||||||
api.get("/manufacturing/audit-log?limit=20")
|
api.get("/manufacturing/audit-log?limit=12")
|
||||||
.then((data) => setAuditLog(data.entries || []))
|
.then((data) => setAuditLog(data.entries || []))
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
.finally(() => setLoadingAudit(false));
|
.finally(() => setLoadingAudit(false));
|
||||||
}, [canViewMfg]);
|
}, [canViewMfg]);
|
||||||
|
|
||||||
const formatTs = (ts) => {
|
const totalDevices = stats
|
||||||
if (!ts) return "—";
|
? STATUS_ORDER.reduce((s, k) => s + (stats.counts[k] ?? 0), 0)
|
||||||
try {
|
: null;
|
||||||
return new Date(ts).toLocaleString("en-US", {
|
|
||||||
month: "short", day: "numeric",
|
const accessibleModules = MODULE_TILES.filter((m) =>
|
||||||
hour: "2-digit", minute: "2-digit",
|
!m.permission || hasPermission(m.permission, "view")
|
||||||
});
|
);
|
||||||
} catch {
|
|
||||||
return ts;
|
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 (
|
return (
|
||||||
<div>
|
<div style={{ minHeight: "100%", position: "relative" }}>
|
||||||
<h1 className="text-2xl font-bold mb-1" style={{ color: "var(--text-heading)" }}>
|
{/* Subtle grid background */}
|
||||||
Dashboard
|
<div
|
||||||
</h1>
|
style={{
|
||||||
<p className="text-sm mb-6" style={{ color: "var(--text-secondary)" }}>
|
position: "absolute", inset: 0, pointerEvents: "none", zIndex: 0,
|
||||||
Welcome, {user?.name}.{" "}
|
backgroundImage: BG_GRID,
|
||||||
<span className="font-medium" style={{ color: "var(--accent)" }}>{user?.role}</span>
|
opacity: 0.4,
|
||||||
</p>
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
{canViewMfg && (
|
<div style={{ position: "relative", zIndex: 1 }}>
|
||||||
<>
|
|
||||||
{/* Device Status Summary */}
|
{/* ── Hero header ─────────────────────────────────────────────────────── */}
|
||||||
<div className="mb-2 flex items-center justify-between">
|
<div
|
||||||
<h2 className="text-sm font-semibold uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>
|
style={{
|
||||||
Device Inventory
|
display: "flex",
|
||||||
</h2>
|
alignItems: "flex-start",
|
||||||
<button
|
justifyContent: "space-between",
|
||||||
onClick={() => navigate("/manufacturing")}
|
marginBottom: "2rem",
|
||||||
className="text-xs underline"
|
paddingBottom: "1.5rem",
|
||||||
style={{ color: "var(--text-link)" }}
|
borderBottom: "1px solid var(--border-primary)",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
View all
|
<div>
|
||||||
</button>
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 700,
|
||||||
|
letterSpacing: "0.12em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
color: "var(--accent)",
|
||||||
|
marginBottom: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
BellSystems · Control Panel
|
||||||
</div>
|
</div>
|
||||||
|
<h1
|
||||||
|
style={{
|
||||||
|
fontSize: "clamp(1.6rem, 3vw, 2.4rem)",
|
||||||
|
fontWeight: 800,
|
||||||
|
letterSpacing: "-0.03em",
|
||||||
|
color: "var(--text-heading)",
|
||||||
|
margin: 0,
|
||||||
|
lineHeight: 1.1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Good {getGreeting()},{" "}
|
||||||
|
<span style={{ color: "var(--accent)" }}>{user?.name?.split(" ")[0]}</span>.
|
||||||
|
</h1>
|
||||||
|
<p style={{ fontSize: 13, color: "var(--text-muted)", marginTop: 6 }}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: "inline-block",
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: 700,
|
||||||
|
letterSpacing: "0.06em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
padding: "2px 7px",
|
||||||
|
borderRadius: 3,
|
||||||
|
backgroundColor: "var(--badge-blue-bg)",
|
||||||
|
color: "var(--badge-blue-text)",
|
||||||
|
marginRight: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{user?.role}
|
||||||
|
</span>
|
||||||
|
{dateStr}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Live clock */}
|
||||||
|
<div style={{ textAlign: "right", flexShrink: 0, marginLeft: 24 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontFamily: "'Courier New', Courier, monospace",
|
||||||
|
fontSize: "clamp(1.4rem, 2.5vw, 2rem)",
|
||||||
|
fontWeight: 700,
|
||||||
|
letterSpacing: "0.06em",
|
||||||
|
color: "var(--accent)",
|
||||||
|
lineHeight: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{clockStr}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 10, color: "var(--text-muted)", marginTop: 4, letterSpacing: "0.04em" }}>
|
||||||
|
LOCAL TIME
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Device inventory stats (mfg permission) ──────────────────────────── */}
|
||||||
|
{canViewMfg && (
|
||||||
|
<section style={{ marginBottom: "2rem" }}>
|
||||||
|
<SectionLabel label="Device Inventory" action="View all" onAction={() => navigate("/manufacturing")} />
|
||||||
|
|
||||||
{loadingStats ? (
|
{loadingStats ? (
|
||||||
<div className="text-sm mb-6" style={{ color: "var(--text-muted)" }}>Loading…</div>
|
<LoadingRow />
|
||||||
) : stats ? (
|
) : stats ? (
|
||||||
<div className="grid grid-cols-3 sm:grid-cols-6 gap-3 mb-8">
|
<>
|
||||||
{STATUS_ORDER.map((s) => (
|
{/* Big total + per-status bars */}
|
||||||
<StatCard
|
<div
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "auto 1fr",
|
||||||
|
gap: 16,
|
||||||
|
alignItems: "center",
|
||||||
|
padding: "20px 24px",
|
||||||
|
borderRadius: 8,
|
||||||
|
border: "1px solid var(--border-primary)",
|
||||||
|
backgroundColor: "var(--bg-card)",
|
||||||
|
marginBottom: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Total */}
|
||||||
|
<div style={{ paddingRight: 28, borderRight: "1px solid var(--border-primary)" }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontFamily: "'Courier New', Courier, monospace",
|
||||||
|
fontSize: "3rem",
|
||||||
|
fontWeight: 900,
|
||||||
|
lineHeight: 1,
|
||||||
|
color: "var(--accent)",
|
||||||
|
textShadow: "0 0 30px rgba(116,184,22,0.3)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AnimatedCount value={totalDevices ?? 0} />
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 10, fontWeight: 700, letterSpacing: "0.08em", textTransform: "uppercase", color: "var(--text-muted)", marginTop: 4 }}>
|
||||||
|
Total Units
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Per-status grid */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "repeat(auto-fill, minmax(110px, 1fr))",
|
||||||
|
gap: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{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 (
|
||||||
|
<button
|
||||||
key={s}
|
key={s}
|
||||||
label={s}
|
|
||||||
count={stats.counts[s] ?? 0}
|
|
||||||
status={s}
|
|
||||||
onClick={() => navigate(`/manufacturing?status=${s}`)}
|
onClick={() => navigate(`/manufacturing?status=${s}`)}
|
||||||
|
style={{
|
||||||
|
background: "none",
|
||||||
|
border: "none",
|
||||||
|
padding: 0,
|
||||||
|
cursor: "pointer",
|
||||||
|
textAlign: "left",
|
||||||
|
}}
|
||||||
|
title={`View ${cfg.label} devices`}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline", marginBottom: 4 }}>
|
||||||
|
<span style={{ fontSize: 10, fontWeight: 700, letterSpacing: "0.06em", textTransform: "uppercase", color: cfg.color }}>
|
||||||
|
{cfg.label}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontFamily: "'Courier New', Courier, monospace",
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: cfg.color,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{count}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/* Progress bar */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: 3,
|
||||||
|
borderRadius: 2,
|
||||||
|
backgroundColor: "var(--border-primary)",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: "100%",
|
||||||
|
width: `${pct}%`,
|
||||||
|
backgroundColor: cfg.color,
|
||||||
|
boxShadow: `0 0 6px ${cfg.color}`,
|
||||||
|
borderRadius: 2,
|
||||||
|
transition: "width 0.8s cubic-bezier(0.16, 1, 0.3, 1)",
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Module navigation tiles ───────────────────────────────────────────── */}
|
||||||
|
<section style={{ marginBottom: "2rem" }}>
|
||||||
|
<SectionLabel label="Modules" />
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "repeat(auto-fill, minmax(180px, 1fr))",
|
||||||
|
gap: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{accessibleModules.map((m) => (
|
||||||
|
<ModuleTile key={m.key} {...m} onClick={() => navigate(m.to)} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
</section>
|
||||||
|
|
||||||
|
{/* ── Bottom two-column: Recent Activity + Audit Log ────────────────────── */}
|
||||||
|
{canViewMfg && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "1fr 1fr",
|
||||||
|
gap: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
{/* Recent Activity */}
|
{/* Recent Activity */}
|
||||||
{stats?.recent_activity?.length > 0 && (
|
<section>
|
||||||
<div className="mb-8">
|
<SectionLabel label="Recent Device Activity" action="All devices" onAction={() => navigate("/manufacturing")} />
|
||||||
<h2 className="text-sm font-semibold uppercase tracking-wide mb-2" style={{ color: "var(--text-muted)" }}>
|
<div
|
||||||
Recent Activity
|
style={{
|
||||||
</h2>
|
border: "1px solid var(--border-primary)",
|
||||||
<div className="rounded-lg border overflow-hidden" style={{ borderColor: "var(--border-primary)" }}>
|
borderRadius: 8,
|
||||||
<table className="w-full text-sm">
|
backgroundColor: "var(--bg-card)",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{!stats?.recent_activity?.length ? (
|
||||||
|
<EmptyState text="No recent activity" />
|
||||||
|
) : (
|
||||||
|
<table style={{ width: "100%", borderCollapse: "collapse" }}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr style={{ backgroundColor: "var(--bg-secondary)", borderBottom: "1px solid var(--border-primary)" }}>
|
<tr style={{ borderBottom: "1px solid var(--border-secondary)" }}>
|
||||||
<th className="px-4 py-2 text-left font-medium text-xs" style={{ color: "var(--text-muted)" }}>Serial Number</th>
|
<TH>Serial</TH>
|
||||||
<th className="px-4 py-2 text-left font-medium text-xs" style={{ color: "var(--text-muted)" }}>Status</th>
|
<TH>Status</TH>
|
||||||
<th className="px-4 py-2 text-left font-medium text-xs" style={{ color: "var(--text-muted)" }}>Owner</th>
|
<TH>Owner</TH>
|
||||||
<th className="px-4 py-2 text-left font-medium text-xs" style={{ color: "var(--text-muted)" }}>Date</th>
|
<TH>When</TH>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{stats.recent_activity.map((item, i) => (
|
{stats.recent_activity.map((item, i) => (
|
||||||
<tr
|
<ActivityRow
|
||||||
key={i}
|
key={i}
|
||||||
className="cursor-pointer"
|
item={item}
|
||||||
style={{ borderBottom: "1px solid var(--border-secondary)" }}
|
|
||||||
onClick={() => navigate(`/manufacturing/devices/${item.serial_number}`)}
|
onClick={() => navigate(`/manufacturing/devices/${item.serial_number}`)}
|
||||||
onMouseEnter={(e) => (e.currentTarget.style.backgroundColor = "var(--bg-card-hover)")}
|
/>
|
||||||
onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = "")}
|
|
||||||
>
|
|
||||||
<td className="px-4 py-2 font-mono text-xs" style={{ color: "var(--text-primary)" }}>
|
|
||||||
{item.serial_number}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-2">
|
|
||||||
<StatusBadge status={item.mfg_status} />
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-2 text-xs" style={{ color: "var(--text-muted)" }}>
|
|
||||||
{item.owner || "—"}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-2 text-xs" style={{ color: "var(--text-muted)" }}>
|
|
||||||
{formatTs(item.updated_at)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
{/* Audit Log */}
|
{/* Audit Log */}
|
||||||
<div>
|
<section>
|
||||||
<h2 className="text-sm font-semibold uppercase tracking-wide mb-2" style={{ color: "var(--text-muted)" }}>
|
<SectionLabel label="Audit Log" />
|
||||||
Audit Log
|
<div
|
||||||
</h2>
|
style={{
|
||||||
|
border: "1px solid var(--border-primary)",
|
||||||
|
borderRadius: 8,
|
||||||
|
backgroundColor: "var(--bg-card)",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
{loadingAudit ? (
|
{loadingAudit ? (
|
||||||
<div className="text-sm" style={{ color: "var(--text-muted)" }}>Loading…</div>
|
<LoadingRow />
|
||||||
) : auditLog.length === 0 ? (
|
) : !auditLog.length ? (
|
||||||
<div className="text-sm" style={{ color: "var(--text-muted)" }}>No audit entries yet.</div>
|
<EmptyState text="No audit entries yet" />
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-lg border overflow-hidden" style={{ borderColor: "var(--border-primary)" }}>
|
<table style={{ width: "100%", borderCollapse: "collapse" }}>
|
||||||
<table className="w-full text-sm">
|
|
||||||
<thead>
|
<thead>
|
||||||
<tr style={{ backgroundColor: "var(--bg-secondary)", borderBottom: "1px solid var(--border-primary)" }}>
|
<tr style={{ borderBottom: "1px solid var(--border-secondary)" }}>
|
||||||
<th className="px-4 py-2 text-left font-medium text-xs" style={{ color: "var(--text-muted)" }}>Time</th>
|
<TH>Time</TH>
|
||||||
<th className="px-4 py-2 text-left font-medium text-xs" style={{ color: "var(--text-muted)" }}>Admin</th>
|
<TH>User</TH>
|
||||||
<th className="px-4 py-2 text-left font-medium text-xs" style={{ color: "var(--text-muted)" }}>Action</th>
|
<TH>Action</TH>
|
||||||
<th className="px-4 py-2 text-left font-medium text-xs" style={{ color: "var(--text-muted)" }}>Device</th>
|
<TH>Device</TH>
|
||||||
<th className="px-4 py-2 text-left font-medium text-xs" style={{ color: "var(--text-muted)" }}>Detail</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{auditLog.map((entry) => (
|
{auditLog.map((entry) => (
|
||||||
<tr
|
<AuditRow
|
||||||
key={entry.id}
|
key={entry.id}
|
||||||
style={{ borderBottom: "1px solid var(--border-secondary)" }}
|
entry={entry}
|
||||||
|
onDeviceClick={(sn) => navigate(`/manufacturing/devices/${sn}`)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!canViewMfg && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "40px 0",
|
||||||
|
textAlign: "center",
|
||||||
|
color: "var(--text-muted)",
|
||||||
|
fontSize: 14,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<td className="px-4 py-2 text-xs whitespace-nowrap" style={{ color: "var(--text-muted)" }}>
|
Select a module above to get started.
|
||||||
{formatTs(entry.timestamp)}
|
</div>
|
||||||
</td>
|
)}
|
||||||
<td className="px-4 py-2 text-xs" style={{ color: "var(--text-secondary)" }}>
|
</div>
|
||||||
{entry.admin_user}
|
</div>
|
||||||
</td>
|
);
|
||||||
<td className="px-4 py-2 text-xs font-medium" style={{ color: "var(--text-primary)" }}>
|
}
|
||||||
{ACTION_LABELS[entry.action] || entry.action}
|
|
||||||
</td>
|
// ─── Sub-components ───────────────────────────────────────────────────────────
|
||||||
<td className="px-4 py-2 font-mono text-xs" style={{ color: "var(--text-muted)" }}>
|
|
||||||
{entry.serial_number
|
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 (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
marginBottom: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 3,
|
||||||
|
height: 14,
|
||||||
|
borderRadius: 2,
|
||||||
|
backgroundColor: "var(--accent)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: 800,
|
||||||
|
letterSpacing: "0.1em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
color: "var(--text-muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{action && (
|
||||||
<button
|
<button
|
||||||
className="underline"
|
onClick={onAction}
|
||||||
style={{ color: "var(--text-link)" }}
|
style={{
|
||||||
onClick={() => navigate(`/manufacturing/devices/${entry.serial_number}`)}
|
background: "none",
|
||||||
|
border: "none",
|
||||||
|
fontSize: 11,
|
||||||
|
color: "var(--text-link)",
|
||||||
|
cursor: "pointer",
|
||||||
|
padding: 0,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{entry.serial_number}
|
{action} →
|
||||||
</button>
|
</button>
|
||||||
)
|
)}
|
||||||
: "—"}
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ModuleTile({ label, sublabel, accent, icon, onClick }) {
|
||||||
|
const [hovered, setHovered] = useState(false);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
onMouseEnter={() => setHovered(true)}
|
||||||
|
onMouseLeave={() => setHovered(false)}
|
||||||
|
style={{
|
||||||
|
background: hovered
|
||||||
|
? `linear-gradient(135deg, ${accent}14 0%, ${accent}08 100%)`
|
||||||
|
: "var(--bg-card)",
|
||||||
|
border: `1px solid ${hovered ? accent + "55" : "var(--border-primary)"}`,
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: "16px 16px 14px",
|
||||||
|
cursor: "pointer",
|
||||||
|
textAlign: "left",
|
||||||
|
transition: "all 0.2s ease",
|
||||||
|
transform: hovered ? "translateY(-1px)" : "none",
|
||||||
|
boxShadow: hovered ? `0 4px 20px ${accent}22` : "none",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
color: hovered ? accent : "var(--text-muted)",
|
||||||
|
transition: "color 0.2s ease",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: hovered ? "var(--text-heading)" : "var(--text-primary)",
|
||||||
|
transition: "color 0.2s ease",
|
||||||
|
letterSpacing: "-0.01em",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 11, color: "var(--text-muted)", marginTop: 2 }}>
|
||||||
|
{sublabel}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TH({ children }) {
|
||||||
|
return (
|
||||||
|
<th
|
||||||
|
style={{
|
||||||
|
padding: "8px 14px",
|
||||||
|
textAlign: "left",
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: 700,
|
||||||
|
letterSpacing: "0.07em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
color: "var(--text-muted)",
|
||||||
|
backgroundColor: "var(--bg-secondary)",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</th>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ActivityRow({ item, onClick }) {
|
||||||
|
const [hovered, setHovered] = useState(false);
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
onClick={onClick}
|
||||||
|
onMouseEnter={() => 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",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<td style={{ padding: "8px 14px" }}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontFamily: "'Courier New', Courier, monospace",
|
||||||
|
fontSize: 11,
|
||||||
|
color: "var(--text-link)",
|
||||||
|
letterSpacing: "0.04em",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.serial_number}
|
||||||
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2 text-xs" style={{ color: "var(--text-muted)" }}>
|
<td style={{ padding: "8px 14px" }}>
|
||||||
{entry.detail
|
<StatusPill status={item.mfg_status} />
|
||||||
? (() => {
|
</td>
|
||||||
|
<td style={{ padding: "8px 14px", fontSize: 12, color: "var(--text-muted)", maxWidth: 100, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||||
|
{item.owner || "—"}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: "8px 14px", fontSize: 11, color: "var(--text-muted)", whiteSpace: "nowrap" }}>
|
||||||
|
{formatTs(item.updated_at)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AuditRow({ entry, onDeviceClick }) {
|
||||||
|
const parseDetail = (detail) => {
|
||||||
|
if (!detail) return "—";
|
||||||
try {
|
try {
|
||||||
const d = JSON.parse(entry.detail);
|
const d = JSON.parse(detail);
|
||||||
return Object.entries(d)
|
return Object.entries(d)
|
||||||
.filter(([, v]) => v !== null && v !== undefined)
|
.filter(([, v]) => v !== null && v !== undefined)
|
||||||
.map(([k, v]) => `${k}: ${v}`)
|
.map(([k, v]) => `${k}: ${v}`)
|
||||||
.join(", ");
|
.join(", ");
|
||||||
} catch {
|
} catch {
|
||||||
return entry.detail;
|
return detail;
|
||||||
}
|
}
|
||||||
})()
|
};
|
||||||
: "—"}
|
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
style={{
|
||||||
|
borderBottom: "1px solid var(--border-secondary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<td style={{ padding: "8px 14px", fontSize: 11, color: "var(--text-muted)", whiteSpace: "nowrap" }}>
|
||||||
|
{formatTs(entry.timestamp)}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: "8px 14px", fontSize: 12, color: "var(--text-secondary)", whiteSpace: "nowrap" }}>
|
||||||
|
{entry.admin_user}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: "8px 14px" }}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: 700,
|
||||||
|
letterSpacing: "0.06em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
color: "var(--accent)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{ACTION_LABELS[entry.action] || entry.action}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: "8px 14px" }}>
|
||||||
|
{entry.serial_number ? (
|
||||||
|
<button
|
||||||
|
onClick={() => onDeviceClick(entry.serial_number)}
|
||||||
|
style={{
|
||||||
|
background: "none",
|
||||||
|
border: "none",
|
||||||
|
padding: 0,
|
||||||
|
fontFamily: "'Courier New', Courier, monospace",
|
||||||
|
fontSize: 11,
|
||||||
|
color: "var(--text-link)",
|
||||||
|
cursor: "pointer",
|
||||||
|
letterSpacing: "0.04em",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{entry.serial_number}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span style={{ fontSize: 12, color: "var(--text-muted)" }}>—</span>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
);
|
||||||
</tbody>
|
}
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!canViewMfg && (
|
function LoadingRow() {
|
||||||
<p className="text-sm" style={{ color: "var(--text-muted)" }}>
|
return (
|
||||||
Select a section from the sidebar to get started.
|
<div style={{ padding: "24px 16px", textAlign: "center" }}>
|
||||||
</p>
|
<div
|
||||||
)}
|
style={{
|
||||||
|
display: "inline-block",
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
border: "2px solid var(--border-primary)",
|
||||||
|
borderTopColor: "var(--accent)",
|
||||||
|
borderRadius: "50%",
|
||||||
|
animation: "spin 0.7s linear infinite",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyState({ text }) {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: "28px 16px", textAlign: "center", color: "var(--text-muted)", fontSize: 13 }}>
|
||||||
|
{text}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,6 +299,10 @@ 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>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-sm whitespace-nowrap" style={{ color: "var(--text-muted)" }}>
|
||||||
|
{filteredDevices.length} {filteredDevices.length === 1 ? "device" : "devices"}
|
||||||
|
</span>
|
||||||
{canEdit && (
|
{canEdit && (
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate("/devices/new")}
|
onClick={() => navigate("/devices/new")}
|
||||||
@@ -301,13 +313,14 @@ export default function DeviceList() {
|
|||||||
</button>
|
</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>
|
||||||
@@ -386,10 +399,17 @@ export default function DeviceList() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span className="flex items-center text-sm" style={{ color: "var(--text-muted)" }}>
|
<select
|
||||||
{filteredDevices.length} {filteredDevices.length === 1 ? "device" : "devices"}
|
value={String(pageSize)}
|
||||||
</span>
|
onChange={(e) => { setPageSize(Number(e.target.value)); setPage(1); }}
|
||||||
</div>
|
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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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,8 +1063,28 @@ 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>
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
<span className="text-sm whitespace-nowrap" style={{ color: "var(--text-muted)" }}>
|
||||||
|
{hasAnyFilter
|
||||||
|
? `Filtered — ${displayRows.length} / ${allMelodyCount || melodies.length} | ${offlineTaggedCount} offline-tagged`
|
||||||
|
: `Showing all (${allMelodyCount || melodies.length}) | ${offlineTaggedCount} tagged for Offline`}
|
||||||
|
</span>
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
{canEdit && (
|
{canEdit && (
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate("/melodies/new")}
|
onClick={() => navigate("/melodies/new")}
|
||||||
@@ -1067,14 +1095,14 @@ export default function MelodyList() {
|
|||||||
</button>
|
</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">
|
|
||||||
<div className="min-w-0 flex-1 flex flex-wrap gap-3 items-center">
|
|
||||||
<select
|
<select
|
||||||
value={typeFilter}
|
value={typeFilter}
|
||||||
onChange={(e) => setTypeFilter(e.target.value)}
|
onChange={(e) => setTypeFilter(e.target.value)}
|
||||||
@@ -1173,6 +1201,18 @@ export default function MelodyList() {
|
|||||||
</select>
|
</select>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<select
|
||||||
|
value={String(pageSize)}
|
||||||
|
onChange={(e) => { setPageSize(Number(e.target.value)); setPage(1); }}
|
||||||
|
className={selectClass}
|
||||||
|
style={{ backgroundColor: "var(--bg-card)", 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>
|
||||||
|
|
||||||
<div style={{ zIndex: 60, position: "relative" }}>
|
<div style={{ zIndex: 60, position: "relative" }}>
|
||||||
<MelodyColumnToggle
|
<MelodyColumnToggle
|
||||||
visible={colPrefs.visible}
|
visible={colPrefs.visible}
|
||||||
@@ -1182,30 +1222,6 @@ export default function MelodyList() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-end gap-3 shrink-0 flex-wrap ml-auto">
|
|
||||||
<span className="text-sm whitespace-nowrap" style={{ color: "var(--text-muted)" }}>
|
|
||||||
{hasAnyFilter
|
|
||||||
? `Filtered - Showing ${displayRows.length} / ${allMelodyCount || melodies.length} Melodies | ${offlineTaggedCount} Offline-tagged`
|
|
||||||
: `Showing all (${allMelodyCount || melodies.length}) Melodies | ${offlineTaggedCount} Melodies tagged for Offline`}
|
|
||||||
</span>
|
|
||||||
{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>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -58,6 +58,10 @@ 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>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-sm whitespace-nowrap" style={{ color: "var(--text-muted)" }}>
|
||||||
|
{total} {total === 1 ? "user" : "users"}
|
||||||
|
</span>
|
||||||
{canEdit && (
|
{canEdit && (
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate("/users/new")}
|
onClick={() => navigate("/users/new")}
|
||||||
@@ -68,13 +72,14 @@ export default function UserList() {
|
|||||||
</button>
|
</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)}
|
||||||
@@ -92,10 +97,6 @@ export default function UserList() {
|
|||||||
</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 && (
|
||||||
|
|||||||
Reference in New Issue
Block a user