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}
{children || "-"}
); } function BoolBadge({ value, yesLabel = "Yes", noLabel = "No" }) { return ( {value ? yesLabel : noLabel} ); } 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(); const { hasPermission } = useAuth(); const canEdit = hasPermission("devices", "edit"); const [device, setDevice] = useState(null); 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(); }, [id]); const loadData = async () => { setLoading(true); try { 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 { setLoading(false); } }; const handleDelete = async () => { try { await api.delete(`/devices/${id}`); navigate("/devices"); } catch (err) { setError(err.message); setShowDelete(false); } }; if (loading) return
Loading...
; if (error) return (
{error}
); if (!device) return null; const attr = device.device_attributes || {}; const clock = attr.clockSettings || {}; 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 && (
)}
{/* Masonry layout for single-width sections */}
{/* Basic Information */}
{mqttStatus && ( (MQTT {mqttStatus.seconds_since_heartbeat}s ago) )}
{device.device_id} {device.id}
{/* Misc */}
{attr.deviceLocale || "-"} {device.websocket_url}
{device.churchAssistantURL}
{/* 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}
{/* Location */}
{device.device_location}
{coords ? formatCoordinates(coords) : device.device_location_coordinates || "-"} {coords && ( e.stopPropagation()} > Open in Maps )}
{locationName && ( {locationName} )}
{coords && (
)}
{/* 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} {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} ))}
)}
{/* 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 */}
{/* Device Settings — full width, outside masonry flow */}
{/* Left Column */}
{/* Basic Attributes */} {/* Alert Settings */} {clock.ringAlerts || "-"} {clock.ringIntervals} {clock.hourAlertsBell} {clock.halfhourAlertsBell} {clock.quarterAlertsBell} {formatTimestamp(clock.daySilenceFrom)} - {formatTimestamp(clock.daySilenceTo)} {formatTimestamp(clock.nightSilenceFrom)} - {formatTimestamp(clock.nightSilenceTo)} {/* Backlight Settings */} {clock.backlightOutput} {formatTimestamp(clock.backlightTurnOnTime)} - {formatTimestamp(clock.backlightTurnOffTime)} {/* Logging */} {attr.serialLogLevel} {attr.sdLogLevel} {attr.mqttLogLevel ?? 0}
{/* Right Column */}
{/* Network */} {net.hostname} {/* Clock Settings */} {clock.ringIntervals} {clock.clockOutputs?.[0] ?? "-"} {clock.clockOutputs?.[1] ?? "-"} {clock.clockTimings?.[0] != null ? msToSeconds(clock.clockTimings[0]) : "-"} {clock.clockTimings?.[1] != null ? msToSeconds(clock.clockTimings[1]) : "-"} {/* Bell Settings */} {attr.totalBells ?? "-"} {/* Bell Output-to-Timing mapping */} {attr.bellOutputs?.length > 0 && (
Output & Timing Map
{attr.bellOutputs.map((output, i) => (
Bell {i + 1}
Output {output}
{attr.hammerTimings?.[i] != null ? ( <>{attr.hammerTimings[i]} ms ) : "-"}
))}
)}
setShowDelete(false)} />
); }