import { useState, useEffect, useRef } from "react"; import { useNavigate } from "react-router-dom"; import api from "../api/client"; import { useAuth } from "../auth/AuthContext"; import SearchBar from "../components/SearchBar"; import ConfirmDialog from "../components/ConfirmDialog"; const TIER_OPTIONS = ["", "basic", "small", "mini", "premium", "vip", "custom"]; // All available columns with their defaults const ALL_COLUMNS = [ { key: "status", label: "Status", defaultOn: true }, { key: "name", label: "Name", defaultOn: true, alwaysOn: true }, { key: "serialNumber", label: "Serial Number", defaultOn: true }, { key: "location", label: "Location", defaultOn: true }, { key: "subscrTier", label: "Subscr Tier", defaultOn: true }, { key: "maxOutputs", label: "Max Outputs", defaultOn: false }, { key: "hasClock", label: "Has Clock", defaultOn: false }, { key: "hasBells", label: "Has Bells", defaultOn: false }, { key: "totalBells", label: "Total Bells", defaultOn: true }, { key: "bellGuard", label: "Bell Guard", defaultOn: false }, { key: "warningsOn", label: "Warnings On", defaultOn: false }, { key: "serialLogLevel", label: "Serial Log Level", defaultOn: false }, { key: "sdLogLevel", label: "SD Log Level", defaultOn: false }, { key: "bellOutputs", label: "Bell Outputs", defaultOn: false }, { key: "hammerTimings", label: "Hammer Timings", defaultOn: false }, { key: "ringAlertsMaster", label: "Ring Alerts Master", defaultOn: false }, { key: "totalPlaybacks", label: "Total Playbacks", defaultOn: false }, { key: "totalHammerStrikes", label: "Total Hammer Strikes", defaultOn: false }, { key: "totalWarnings", label: "Total Warnings", defaultOn: false }, { key: "warrantyActive", label: "Warranty Active", defaultOn: false }, { key: "totalMelodies", label: "Total Melodies", defaultOn: false }, { key: "assignedUsers", label: "Assigned Users", defaultOn: true }, ]; function getDefaultVisibleColumns() { const saved = localStorage.getItem("deviceListColumns"); if (saved) { try { return JSON.parse(saved); } catch { // ignore } } return ALL_COLUMNS.filter((c) => c.defaultOn).map((c) => c.key); } function BoolBadge({ value, yesLabel = "Yes", noLabel = "No" }) { return ( {value ? yesLabel : noLabel} ); } function isSubscriptionActive(device) { const sub = device.device_subscription; if (!sub?.subscrStart || !sub?.subscrDuration) return false; try { const start = parseFirestoreDate(sub.subscrStart); if (!start) return false; const end = new Date(start.getTime() + sub.subscrDuration * 86400000); return end > new Date(); } catch { return false; } } function isWarrantyActive(device) { const stats = device.device_stats; if (!stats?.warrantyStart || !stats?.warrantyPeriod) return !!stats?.warrantyActive; try { const start = parseFirestoreDate(stats.warrantyStart); if (!start) return !!stats?.warrantyActive; const end = new Date(start.getTime() + stats.warrantyPeriod * 86400000); return end > new Date(); } catch { return !!stats?.warrantyActive; } } function parseFirestoreDate(str) { if (!str) return null; // Handle format like "22 December 2025 at 16:35:56 UTC+0000" const cleaned = str.replace(" at ", " ").replace("UTC+0000", "UTC").replace(/UTC\+(\d{4})/, "UTC"); const d = new Date(cleaned); return isNaN(d.getTime()) ? null : d; } export default function DeviceList() { const [devices, setDevices] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(""); const [search, setSearch] = useState(""); const [onlineFilter, setOnlineFilter] = useState(""); const [tierFilter, setTierFilter] = useState(""); const [subscrStatusFilter, setSubscrStatusFilter] = useState(""); const [warrantyStatusFilter, setWarrantyStatusFilter] = useState(""); const [hasClockFilter, setHasClockFilter] = useState(""); const [hasBellsFilter, setHasBellsFilter] = useState(""); const [deleteTarget, setDeleteTarget] = useState(null); const [visibleColumns, setVisibleColumns] = useState(getDefaultVisibleColumns); const [showColumnPicker, setShowColumnPicker] = useState(false); const [mqttStatusMap, setMqttStatusMap] = useState({}); const columnPickerRef = useRef(null); const navigate = useNavigate(); const { hasPermission } = useAuth(); const canEdit = hasPermission("devices", "edit"); // Close column picker on outside click useEffect(() => { const handleClick = (e) => { if (columnPickerRef.current && !columnPickerRef.current.contains(e.target)) { setShowColumnPicker(false); } }; if (showColumnPicker) { document.addEventListener("mousedown", handleClick); return () => document.removeEventListener("mousedown", handleClick); } }, [showColumnPicker]); const fetchDevices = async () => { setLoading(true); setError(""); try { const params = new URLSearchParams(); if (search) params.set("search", search); if (onlineFilter === "true") params.set("online", "true"); if (onlineFilter === "false") params.set("online", "false"); if (tierFilter) params.set("tier", tierFilter); const qs = params.toString(); // Phase 1: load devices from DB immediately const data = await api.get(`/devices${qs ? `?${qs}` : ""}`); setDevices(data.devices); setLoading(false); // Phase 2: fetch MQTT status in background and update online indicators api.get("/mqtt/status").then((mqttData) => { if (mqttData?.devices) { const map = {}; for (const s of mqttData.devices) { map[s.device_serial] = s; } setMqttStatusMap(map); } }).catch(() => {}); } catch (err) { setError(err.message); } finally { setLoading(false); } }; useEffect(() => { fetchDevices(); }, [search, onlineFilter, tierFilter]); const handleDelete = async () => { if (!deleteTarget) return; try { await api.delete(`/devices/${deleteTarget.id}`); setDeleteTarget(null); fetchDevices(); } catch (err) { setError(err.message); setDeleteTarget(null); } }; const toggleColumn = (key) => { const col = ALL_COLUMNS.find((c) => c.key === key); if (col?.alwaysOn) return; setVisibleColumns((prev) => { const next = prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key]; localStorage.setItem("deviceListColumns", JSON.stringify(next)); return next; }); }; const isVisible = (key) => visibleColumns.includes(key); const renderCellValue = (key, device) => { const attr = device.device_attributes || {}; const clock = attr.clockSettings || {}; const sub = device.device_subscription || {}; const stats = device.device_stats || {}; switch (key) { case "status": { const mqtt = mqttStatusMap[device.device_id]; const isOnline = mqtt ? mqtt.online : device.is_Online; return ( ); } case "name": return ( {device.device_name || "Unnamed Device"} ); case "serialNumber": return {device.device_id || "-"}; case "location": return device.device_location || "-"; case "subscrTier": return ( {sub.subscrTier || "basic"} ); case "maxOutputs": return sub.maxOutputs ?? "-"; case "hasClock": return ; case "hasBells": return ; case "totalBells": return attr.totalBells ?? 0; case "bellGuard": return ; case "warningsOn": return ; case "serialLogLevel": return attr.serialLogLevel ?? 0; case "sdLogLevel": return attr.sdLogLevel ?? 0; case "bellOutputs": return attr.bellOutputs?.length > 0 ? attr.bellOutputs.join(", ") : "-"; case "hammerTimings": return attr.hammerTimings?.length > 0 ? attr.hammerTimings.join(", ") : "-"; case "ringAlertsMaster": return ; case "totalPlaybacks": return stats.totalPlaybacks ?? 0; case "totalHammerStrikes": return stats.totalHammerStrikes ?? 0; case "totalWarnings": return stats.totalWarningsGiven ?? 0; case "warrantyActive": return ; case "totalMelodies": return device.device_melodies_all?.length ?? 0; case "assignedUsers": return device.user_list?.length ?? 0; default: return "-"; } }; // Apply client-side filters const filteredDevices = devices.filter((device) => { if (subscrStatusFilter === "active" && !isSubscriptionActive(device)) return false; if (subscrStatusFilter === "expired" && isSubscriptionActive(device)) return false; if (warrantyStatusFilter === "active" && !isWarrantyActive(device)) return false; if (warrantyStatusFilter === "expired" && isWarrantyActive(device)) return false; if (hasClockFilter === "yes" && !device.device_attributes?.hasClock) return false; if (hasClockFilter === "no" && device.device_attributes?.hasClock) return false; if (hasBellsFilter === "yes" && !device.device_attributes?.hasBells) return false; if (hasBellsFilter === "no" && device.device_attributes?.hasBells) return false; return true; }); const activeColumns = ALL_COLUMNS.filter((c) => isVisible(c.key)); const selectClass = "px-3 py-2 rounded-md text-sm cursor-pointer border"; const selectStyle = { backgroundColor: "var(--bg-card)", color: "var(--text-primary)", borderColor: "var(--border-primary)", }; return (

Device Fleet

{canEdit && ( )}
{/* Column visibility dropdown */}
{showColumnPicker && (
{ALL_COLUMNS.map((col) => ( ))}
)}
{filteredDevices.length} {filteredDevices.length === 1 ? "device" : "devices"}
{error && (
{error}
)} {loading ? (
Loading...
) : filteredDevices.length === 0 ? (
No devices found.
) : (
{activeColumns.map((col) => ( ))} {canEdit && ( {filteredDevices.map((device) => ( navigate(`/devices/${device.id}`)} className="cursor-pointer transition-colors" style={{ borderBottom: "1px solid var(--border-secondary)" }} onMouseEnter={(e) => (e.currentTarget.style.backgroundColor = "var(--bg-card-hover)")} onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = "transparent")} > {activeColumns.map((col) => ( ))} {canEdit && ( )} ))}
{col.key === "status" ? "" : col.label} )}
{renderCellValue(col.key, device)}
e.stopPropagation()} >
)} setDeleteTarget(null)} />
); }