diff --git a/backend/devices/models.py b/backend/devices/models.py index 4e5634f..32f2a39 100644 --- a/backend/devices/models.py +++ b/backend/devices/models.py @@ -75,6 +75,7 @@ class DeviceAttributes(BaseModel): networkSettings: DeviceNetworkSettings = DeviceNetworkSettings() serialLogLevel: int = 0 sdLogLevel: int = 0 + mqttLogLevel: int = 0 class DeviceSubInformation(BaseModel): @@ -151,3 +152,16 @@ class DeviceInDB(DeviceCreate): class DeviceListResponse(BaseModel): devices: List[DeviceInDB] total: int + + +class DeviceUserInfo(BaseModel): + """User info resolved from device_users sub-collection or user_list.""" + user_id: str = "" + display_name: str = "" + email: str = "" + role: str = "" + + +class DeviceUsersResponse(BaseModel): + users: List[DeviceUserInfo] + total: int diff --git a/backend/devices/router.py b/backend/devices/router.py index 2886fbb..39a00ee 100644 --- a/backend/devices/router.py +++ b/backend/devices/router.py @@ -4,6 +4,7 @@ from auth.models import TokenPayload from auth.dependencies import require_device_access, require_viewer from devices.models import ( DeviceCreate, DeviceUpdate, DeviceInDB, DeviceListResponse, + DeviceUsersResponse, DeviceUserInfo, ) from devices import service @@ -33,6 +34,16 @@ async def get_device( return service.get_device(device_id) +@router.get("/{device_id}/users", response_model=DeviceUsersResponse) +async def get_device_users( + device_id: str, + _user: TokenPayload = Depends(require_viewer), +): + users_data = service.get_device_users(device_id) + users = [DeviceUserInfo(**u) for u in users_data] + return DeviceUsersResponse(users=users, total=len(users)) + + @router.post("", response_model=DeviceInDB, status_code=201) async def create_device( body: DeviceCreate, diff --git a/backend/devices/service.py b/backend/devices/service.py index 8289d56..f49acdb 100644 --- a/backend/devices/service.py +++ b/backend/devices/service.py @@ -165,6 +165,94 @@ def update_device(device_doc_id: str, data: DeviceUpdate) -> DeviceInDB: return _doc_to_device(updated_doc) +def get_device_users(device_doc_id: str) -> list[dict]: + """Get users assigned to a device from the device_users sub-collection. + + Falls back to the user_list field on the device document if the + sub-collection is empty. + """ + db = get_db() + doc_ref = db.collection(COLLECTION).document(device_doc_id) + doc = doc_ref.get() + if not doc.exists: + raise NotFoundError("Device") + + # Try sub-collection first + sub_docs = list(doc_ref.collection("device_users").stream()) + users = [] + + if sub_docs: + for sub_doc in sub_docs: + sub_data = sub_doc.to_dict() + role = sub_data.get("role", "") + user_ref = sub_data.get("user_reference") + + # Resolve user reference + user_id = "" + display_name = "" + email = "" + + if isinstance(user_ref, DocumentReference): + try: + user_doc = user_ref.get() + if user_doc.exists: + user_data = user_doc.to_dict() + user_id = user_doc.id + display_name = user_data.get("display_name", "") + email = user_data.get("email", "") + except Exception as e: + print(f"[devices] Error resolving user reference: {e}") + continue + elif isinstance(user_ref, str): + # String path like "users/abc123" + try: + ref_doc = db.document(user_ref).get() + if ref_doc.exists: + user_data = ref_doc.to_dict() + user_id = ref_doc.id + display_name = user_data.get("display_name", "") + email = user_data.get("email", "") + except Exception as e: + print(f"[devices] Error resolving user path: {e}") + continue + + users.append({ + "user_id": user_id, + "display_name": display_name, + "email": email, + "role": role, + }) + else: + # Fallback to user_list field + device_data = doc.to_dict() + user_list = device_data.get("user_list", []) + for entry in user_list: + try: + if isinstance(entry, DocumentReference): + user_doc = entry.get() + elif isinstance(entry, str) and entry.strip(): + # Could be a path like "users/abc" or a raw doc ID + if "/" in entry: + user_doc = db.document(entry).get() + else: + user_doc = db.collection("users").document(entry).get() + else: + continue + + if user_doc.exists: + user_data = user_doc.to_dict() + users.append({ + "user_id": user_doc.id, + "display_name": user_data.get("display_name", ""), + "email": user_data.get("email", ""), + "role": "", + }) + except Exception as e: + print(f"[devices] Error resolving user_list entry: {e}") + + return users + + def delete_device(device_doc_id: str) -> None: """Delete a device document from Firestore.""" db = get_db() diff --git a/backend/equipment/service.py b/backend/equipment/service.py index 3086ad5..e13edb4 100644 --- a/backend/equipment/service.py +++ b/backend/equipment/service.py @@ -44,16 +44,19 @@ def _resolve_names(db, device_id: str | None, user_id: str | None) -> tuple[str, device_name = "" user_name = "" - if device_id: - device_doc = db.collection("devices").document(device_id).get() - if device_doc.exists: - device_name = device_doc.to_dict().get("device_name", "") + try: + if device_id and isinstance(device_id, str) and device_id.strip(): + device_doc = db.collection("devices").document(device_id.strip()).get() + if device_doc.exists: + device_name = device_doc.to_dict().get("device_name", "") - if user_id: - user_doc = db.collection("users").document(user_id).get() - if user_doc.exists: - user_doc_data = user_doc.to_dict() - user_name = user_doc_data.get("display_name", "") or user_doc_data.get("email", "") + if user_id and isinstance(user_id, str) and user_id.strip(): + user_doc = db.collection("users").document(user_id.strip()).get() + if user_doc.exists: + user_doc_data = user_doc.to_dict() + user_name = user_doc_data.get("display_name", "") or user_doc_data.get("email", "") + except Exception as e: + print(f"[equipment] Error resolving names (device_id={device_id}, user_id={user_id}): {e}") return device_name, user_name diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a3c03ab..ab26280 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,8 +8,10 @@ "name": "frontend", "version": "0.0.0", "dependencies": { + "leaflet": "^1.9.4", "react": "^19.2.0", "react-dom": "^19.2.0", + "react-leaflet": "^5.0.0", "react-router-dom": "^7.13.0" }, "devDependencies": { @@ -1009,6 +1011,17 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@react-leaflet/core": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-3.0.0.tgz", + "integrity": "sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ==", + "license": "Hippocratic-2.1", + "peerDependencies": { + "leaflet": "^1.9.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-rc.3", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", @@ -2614,6 +2627,12 @@ "json-buffer": "3.0.1" } }, + "node_modules/leaflet": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", + "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", + "license": "BSD-2-Clause" + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -3158,6 +3177,20 @@ "react": "^19.2.4" } }, + "node_modules/react-leaflet": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-5.0.0.tgz", + "integrity": "sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw==", + "license": "Hippocratic-2.1", + "dependencies": { + "@react-leaflet/core": "^3.0.0" + }, + "peerDependencies": { + "leaflet": "^1.9.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + } + }, "node_modules/react-refresh": { "version": "0.18.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 5708fa0..8b6d23b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,8 +10,10 @@ "preview": "vite preview" }, "dependencies": { + "leaflet": "^1.9.4", "react": "^19.2.0", "react-dom": "^19.2.0", + "react-leaflet": "^5.0.0", "react-router-dom": "^7.13.0" }, "devDependencies": { diff --git a/frontend/src/devices/DeviceDetail.jsx b/frontend/src/devices/DeviceDetail.jsx index c51d1ab..4691b3b 100644 --- a/frontend/src/devices/DeviceDetail.jsx +++ b/frontend/src/devices/DeviceDetail.jsx @@ -1,17 +1,27 @@ import { useState, useEffect } from "react"; import { useParams, useNavigate } from "react-router-dom"; +import { MapContainer, TileLayer, Marker } from "react-leaflet"; +import "leaflet/dist/leaflet.css"; +import L from "leaflet"; import api from "../api/client"; import { useAuth } from "../auth/AuthContext"; import ConfirmDialog from "../components/ConfirmDialog"; import NotesPanel from "../equipment/NotesPanel"; +// Fix default Leaflet marker icon +delete L.Icon.Default.prototype._getIconUrl; +L.Icon.Default.mergeOptions({ + iconRetinaUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png", + iconUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png", + shadowUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png", +}); + +// --- Helper components --- + function Field({ label, children }) { return (
-
+
{label}
@@ -36,6 +46,122 @@ function BoolBadge({ value, yesLabel = "Yes", noLabel = "No" }) { ); } +function SectionCard({ title, children }) { + return ( +
+

{title}

+ {children} +
+ ); +} + +function Subsection({ title, children, isFirst = false }) { + return ( +
+

{title}

+
{children}
+
+ ); +} + +// --- Date / time helpers --- + +function parseFirestoreDate(str) { + if (!str) return null; + const cleaned = str.replace(" at ", " ").replace("UTC+0000", "UTC").replace(/UTC\+(\d{4})/, "UTC"); + const d = new Date(cleaned); + return isNaN(d.getTime()) ? null : d; +} + +function formatDate(str) { + const d = parseFirestoreDate(str); + if (!d) return str || "-"; + const day = d.getUTCDate().toString().padStart(2, "0"); + const month = (d.getUTCMonth() + 1).toString().padStart(2, "0"); + const year = d.getUTCFullYear(); + return `${day}-${month}-${year}`; +} + +function addDays(date, days) { + return new Date(date.getTime() + days * 86400000); +} + +function formatDateNice(d) { + if (!d) return "-"; + const months = ["January","February","March","April","May","June","July","August","September","October","November","December"]; + return `${d.getUTCDate()} ${months[d.getUTCMonth()]} ${d.getUTCFullYear()}`; +} + +function daysUntil(targetDate) { + const now = new Date(); + const diff = targetDate.getTime() - now.getTime(); + return Math.ceil(diff / 86400000); +} + +function formatRelativeTime(days) { + if (days < 0) return null; + if (days === 0) return "today"; + if (days === 1) return "in 1 day"; + if (days < 30) return `in ${days} days`; + const months = Math.floor(days / 30); + const remainDays = days % 30; + if (remainDays === 0) return `in ${months} month${months > 1 ? "s" : ""}`; + return `in ${months} month${months > 1 ? "s" : ""} and ${remainDays} day${remainDays > 1 ? "s" : ""}`; +} + +function daysToDisplay(days) { + if (days >= 365 && days % 365 === 0) return `${days / 365} year${days / 365 > 1 ? "s" : ""} (${days} days)`; + if (days >= 30) { + const months = Math.floor(days / 30); + const rem = days % 30; + if (rem === 0) return `${months} month${months > 1 ? "s" : ""} (${days} days)`; + return `${days} days (~${months} month${months > 1 ? "s" : ""})`; + } + return `${days} days`; +} + +function formatTimestamp(str) { + // Try to parse as a Firestore timestamp and return HH:MM + if (!str) return "-"; + const d = parseFirestoreDate(str); + if (d) { + return `${d.getUTCHours().toString().padStart(2, "0")}:${d.getUTCMinutes().toString().padStart(2, "0")}`; + } + // Maybe it's already a time string like "14:00" + if (/^\d{1,2}:\d{2}/.test(str)) return str; + // Try to extract time from the string + const match = str.match(/(\d{1,2}):(\d{2})/); + if (match) return `${match[1].padStart(2, "0")}:${match[2]}`; + return str; +} + +function msToSeconds(ms) { + if (ms == null) return "-"; + return `${(ms / 1000).toFixed(1)}s`; +} + +// --- Coordinates helpers --- + +function parseCoordinates(coordStr) { + if (!coordStr) return null; + // Handle "lat,lng" or "lat° N, lng° E" or similar + const cleaned = coordStr.replace(/°\s*[NSEW]/gi, "").trim(); + const parts = cleaned.split(",").map((s) => parseFloat(s.trim())); + if (parts.length === 2 && !isNaN(parts[0]) && !isNaN(parts[1])) { + return { lat: parts[0], lng: parts[1] }; + } + return null; +} + +function formatCoordinates(coords) { + if (!coords) return "-"; + const latDir = coords.lat >= 0 ? "N" : "S"; + const lngDir = coords.lng >= 0 ? "E" : "W"; + return `${Math.abs(coords.lat).toFixed(7)}° ${latDir}, ${Math.abs(coords.lng).toFixed(7)}° ${lngDir}`; +} + +// --- Main component --- + export default function DeviceDetail() { const { id } = useParams(); const navigate = useNavigate(); @@ -46,6 +172,10 @@ export default function DeviceDetail() { const [loading, setLoading] = useState(true); const [error, setError] = useState(""); const [showDelete, setShowDelete] = useState(false); + const [mqttStatus, setMqttStatus] = useState(null); + const [locationName, setLocationName] = useState(null); + const [deviceUsers, setDeviceUsers] = useState([]); + const [usersLoading, setUsersLoading] = useState(false); useEffect(() => { loadData(); @@ -54,8 +184,41 @@ export default function DeviceDetail() { const loadData = async () => { setLoading(true); try { - const d = await api.get(`/devices/${id}`); + const [d, mqttData] = await Promise.all([ + api.get(`/devices/${id}`), + api.get("/mqtt/status").catch(() => null), + ]); setDevice(d); + + // Match MQTT status by serial number + if (mqttData?.devices && d.device_id) { + const match = mqttData.devices.find((s) => s.device_serial === d.device_id); + setMqttStatus(match || null); + } + + // Load device users + setUsersLoading(true); + api.get(`/devices/${id}/users`).then((data) => { + setDeviceUsers(data.users || []); + }).catch(() => { + setDeviceUsers([]); + }).finally(() => setUsersLoading(false)); + + // Reverse geocode + const coords = parseCoordinates(d.device_location_coordinates); + if (coords) { + fetch(`https://nominatim.openstreetmap.org/reverse?lat=${coords.lat}&lon=${coords.lng}&format=json&zoom=10`) + .then((r) => r.json()) + .then((data) => { + const addr = data.address || {}; + const name = addr.city || addr.town || addr.village || addr.hamlet || addr.municipality || data.display_name?.split(",")[0] || ""; + const region = addr.state || addr.county || ""; + const country = addr.country || ""; + const parts = [name, region, country].filter(Boolean); + setLocationName(parts.join(", ")); + }) + .catch(() => setLocationName(null)); + } } catch (err) { setError(err.message); } finally { @@ -73,29 +236,12 @@ export default function DeviceDetail() { } }; - if (loading) { - return ( -
- Loading... -
- ); - } - - if (error) { - return ( -
- {error} -
- ); - } - + if (loading) return
Loading...
; + if (error) return ( +
+ {error} +
+ ); if (!device) return null; const attr = device.device_attributes || {}; @@ -103,48 +249,49 @@ export default function DeviceDetail() { const net = attr.networkSettings || {}; const sub = device.device_subscription || {}; const stats = device.device_stats || {}; + const coords = parseCoordinates(device.device_location_coordinates); + const isOnline = mqttStatus ? mqttStatus.online : device.is_Online; + + // Subscription computed fields + const subscrStart = parseFirestoreDate(sub.subscrStart); + const subscrEnd = subscrStart && sub.subscrDuration ? addDays(subscrStart, sub.subscrDuration) : null; + const subscrDaysLeft = subscrEnd ? daysUntil(subscrEnd) : null; + + // Warranty computed fields + const warrantyStart = parseFirestoreDate(stats.warrantyStart); + const warrantyEnd = warrantyStart && stats.warrantyPeriod ? addDays(warrantyStart, stats.warrantyPeriod) : null; + const warrantyDaysLeft = warrantyEnd ? daysUntil(warrantyEnd) : null; + + // Maintenance computed fields + const maintainedOn = parseFirestoreDate(stats.maintainedOn); + const nextMaintenance = maintainedOn && stats.maintainancePeriod ? addDays(maintainedOn, stats.maintainancePeriod) : null; + const maintenanceDaysLeft = nextMaintenance ? daysUntil(nextMaintenance) : null; return (
+ {/* Header */}
-
-

+

{device.device_name || "Unnamed Device"}

{canEdit && (
- -
@@ -154,232 +301,281 @@ export default function DeviceDetail() {
{/* Left column */}
- {/* Basic Info */} -
-

- Basic Information -

+ {/* Basic Information */} +
{device.device_id} - - - {device.id} - - - + + {mqttStatus && ( + + (MQTT {mqttStatus.seconds_since_heartbeat}s ago) + + )} -
- {device.device_location} -
- {device.device_location_coordinates} - {device.websocket_url} -
- {device.churchAssistantURL} -
+ + {device.id} +
-
+ - {/* Device Attributes */} -
-

- Device Attributes -

+ {/* Location */} + +
+ {device.device_location} + +
+ {coords ? formatCoordinates(coords) : device.device_location_coordinates || "-"} + {coords && ( + e.stopPropagation()} + > + Open in Maps + + )} +
+
+ {locationName && ( + {locationName} + )} +
+ {coords && ( +
+ + + + +
+ )} +
+ + {/* Subscription */} +
- - + + + {sub.subscrTier} + + + {formatDate(sub.subscrStart)} + {sub.subscrDuration ? daysToDisplay(sub.subscrDuration) : "-"} + {subscrEnd ? formatDateNice(subscrEnd) : "-"} + + {subscrDaysLeft === null ? "-" : subscrDaysLeft <= 0 ? ( + + Subscription Expired + + ) : ( + + {subscrDaysLeft} days left + + )} + + {sub.maxUsers} + {sub.maxOutputs} +
+
+ + {/* Device Settings (combined) */} + + {/* Subsection 1: Basic Attributes */} + + + + + + + {/* Subsection 2: Bell Settings */} + {attr.totalBells} - - - - - {attr.deviceLocale} - + {attr.bellOutputs?.length > 0 ? attr.bellOutputs.join(", ") : "-"} + {attr.hammerTimings?.length > 0 ? attr.hammerTimings.join(", ") : "-"} + + + {/* Subsection 3.1: Clock Settings */} + + + {clock.clockOutputs?.[0] ?? "-"} + {clock.clockOutputs?.[1] ?? "-"} + {clock.clockTimings?.[0] != null ? msToSeconds(clock.clockTimings[0]) : "-"} + {clock.clockTimings?.[1] != null ? msToSeconds(clock.clockTimings[1]) : "-"} + + {clock.ringAlerts || "-"} + {clock.ringIntervals} + {clock.hourAlertsBell} + {clock.halfhourAlertsBell} + {clock.quarterAlertsBell} + + {clock.isDaySilenceOn && ( + + {formatTimestamp(clock.daySilenceFrom)} - {formatTimestamp(clock.daySilenceTo)} + + )} + + {clock.isNightSilenceOn && ( + + {formatTimestamp(clock.nightSilenceFrom)} - {formatTimestamp(clock.nightSilenceTo)} + + )} + + + {/* Subsection 3.2: Backlight Settings */} + + + {clock.backlightOutput} + {formatTimestamp(clock.backlightTurnOnTime)} + {formatTimestamp(clock.backlightTurnOffTime)} + + + {/* Subsection 4: Network */} + + {net.hostname} + + + + {/* Subsection 5: Logging */} + {attr.serialLogLevel} {attr.sdLogLevel} - - {attr.bellOutputs?.length > 0 ? attr.bellOutputs.join(", ") : "-"} - - - {attr.hammerTimings?.length > 0 ? attr.hammerTimings.join(", ") : "-"} - - -
- - {/* Network */} -
-

- Network Settings -

-
- {net.hostname} - -
-
+ {attr.mqttLogLevel ?? 0} + +
{/* Right column */}
- {/* Subscription */} -
-

- Subscription -

+ {/* Misc */} +
- - - {sub.subscrTier} - - - {sub.subscrStart} - {sub.subscrDuration} months - {sub.maxUsers} - {sub.maxOutputs} -
-
- - {/* Clock Settings */} -
-

- Clock Settings -

-
- - - {clock.ringAlerts} - - {clock.ringIntervals} - {clock.hourAlertsBell} - {clock.halfhourAlertsBell} - {clock.quarterAlertsBell} - {clock.backlightOutput} - - - {clock.clockOutputs?.length > 0 ? clock.clockOutputs.join(", ") : "-"} - - - {clock.clockTimings?.length > 0 ? clock.clockTimings.join(", ") : "-"} - -
- {(clock.isDaySilenceOn || clock.isNightSilenceOn) && ( -
-

- Silence Periods -

-
- {clock.isDaySilenceOn && ( - <> - - {clock.daySilenceFrom} - {clock.daySilenceTo} - - - )} - {clock.isNightSilenceOn && ( - <> - - {clock.nightSilenceFrom} - {clock.nightSilenceTo} - - - )} -
+ {attr.deviceLocale || "-"} + {device.websocket_url} + +
+ {device.churchAssistantURL}
- )} -
+ + - {/* Statistics */} -
-

- Statistics & Warranty -

-
+ {/* Warranty & Maintenance & Statistics */} + + {/* Subsection 1: Warranty */} + + + {warrantyDaysLeft !== null ? ( + warrantyDaysLeft > 0 ? ( + Active + ) : ( + Expired + ) + ) : ( + + )} + + {formatDate(stats.warrantyStart)} + {stats.warrantyPeriod ? daysToDisplay(stats.warrantyPeriod) : "-"} + {warrantyEnd ? formatDateNice(warrantyEnd) : "-"} + + {warrantyDaysLeft === null ? "-" : warrantyDaysLeft <= 0 ? ( + Expired + ) : ( + `${warrantyDaysLeft} days` + )} + + + + {/* Subsection 2: Maintenance */} + + {formatDate(stats.maintainedOn)} + {stats.maintainancePeriod ? daysToDisplay(stats.maintainancePeriod) : "-"} + + {nextMaintenance ? ( + + {formatDateNice(nextMaintenance)} + {maintenanceDaysLeft !== null && ( + + ({maintenanceDaysLeft <= 0 ? "overdue" : formatRelativeTime(maintenanceDaysLeft)}) + + )} + + ) : "-"} + + + + {/* Subsection 3: Statistics */} + {stats.totalPlaybacks} {stats.totalHammerStrikes} {stats.totalWarningsGiven} - - {stats.perBellStrikes?.length > 0 ? stats.perBellStrikes.join(", ") : "-"} - - - {stats.warrantyStart} - {stats.warrantyPeriod} months - {stats.maintainedOn} - {stats.maintainancePeriod} months -
-
+ {device.device_melodies_all?.length ?? 0} + {device.device_melodies_favorites?.length ?? 0} + {stats.perBellStrikes?.length > 0 && ( +
+ +
+ {stats.perBellStrikes.slice(0, attr.totalBells || stats.perBellStrikes.length).map((count, i) => ( + + Bell {i + 1}: {count} + + ))} +
+
+
+ )} + + - {/* Melodies & Users summary */} -
-

- Melodies & Users -

-
- - {device.device_melodies_all?.length ?? 0} - - - {device.device_melodies_favorites?.length ?? 0} - - - {device.user_list?.length ?? 0} - -
-
+ {/* Users */} + + {usersLoading ? ( +

Loading users...

+ ) : deviceUsers.length === 0 ? ( +

No users assigned to this device.

+ ) : ( +
+ {deviceUsers.map((user, i) => ( +
user.user_id && navigate(`/users/${user.user_id}`)} + > +
+
+

+ {user.display_name || user.email || "Unknown User"} +

+ {user.email && user.display_name && ( +

{user.email}

+ )} + {user.user_id && ( +

{user.user_id}

+ )} +
+ {user.role && ( + + {user.role} + + )} +
+
+ ))} +
+ )} +
{/* Equipment Notes */} diff --git a/frontend/src/devices/DeviceList.jsx b/frontend/src/devices/DeviceList.jsx index 1635835..13331e4 100644 --- a/frontend/src/devices/DeviceList.jsx +++ b/frontend/src/devices/DeviceList.jsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useRef } from "react"; import { useNavigate } from "react-router-dom"; import api from "../api/client"; import { useAuth } from "../auth/AuthContext"; @@ -7,20 +7,125 @@ 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 [total, setTotal] = useState(0); 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 [hoveredRow, setHoveredRow] = useState(null); + const [visibleColumns, setVisibleColumns] = useState(getDefaultVisibleColumns); + const [showColumnPicker, setShowColumnPicker] = useState(false); + const columnPickerRef = useRef(null); const navigate = useNavigate(); const { hasRole } = useAuth(); const canEdit = hasRole("superadmin", "device_manager"); + // 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(""); @@ -33,7 +138,6 @@ export default function DeviceList() { const qs = params.toString(); const data = await api.get(`/devices${qs ? `?${qs}` : ""}`); setDevices(data.devices); - setTotal(data.total); } catch (err) { setError(err.message); } finally { @@ -57,6 +161,115 @@ export default function DeviceList() { } }; + 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": + 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 (
@@ -78,39 +291,86 @@ export default function DeviceList() { placeholder="Search by name, location, or serial number..." />
- setOnlineFilter(e.target.value)} className={selectClass} style={selectStyle}> - setTierFilter(e.target.value)} className={selectClass} style={selectStyle}> {TIER_OPTIONS.filter(Boolean).map((t) => ( - + ))} + + + + + + {/* Column visibility dropdown */} +
+ + {showColumnPicker && ( +
+ {ALL_COLUMNS.map((col) => ( + + ))} +
+ )} +
+ - {total} {total === 1 ? "device" : "devices"} + {filteredDevices.length} {filteredDevices.length === 1 ? "device" : "devices"}
@@ -130,7 +390,7 @@ export default function DeviceList() { {loading ? (
Loading...
- ) : devices.length === 0 ? ( + ) : filteredDevices.length === 0 ? (
- Status - Name - Serial Number - Location - Tier - Bells - Users + {activeColumns.map((col) => ( + + {col.key === "status" ? "" : col.label} + + ))} {canEdit && ( )} - {devices.map((device, index) => ( + {filteredDevices.map((device) => ( navigate(`/devices/${device.id}`)} - className="cursor-pointer" - style={{ - borderBottom: index < devices.length - 1 ? "1px solid var(--border-primary)" : "none", - backgroundColor: hoveredRow === device.id ? "var(--bg-card-hover)" : "transparent", - }} - onMouseEnter={() => setHoveredRow(device.id)} - onMouseLeave={() => setHoveredRow(null)} + 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")} > - - - - - {device.device_name || "Unnamed Device"} - - - {device.device_id || "-"} - - - {device.device_location || "-"} - - - ( + - {device.device_subscription?.subscrTier || "basic"} - - - - {device.device_attributes?.totalBells ?? 0} - - - {device.user_list?.length ?? 0} - + {renderCellValue(col.key, device)} + + ))} {canEdit && (