import { useState, useEffect, useCallback, useRef } from "react";
import { useParams, useNavigate } from "react-router-dom";
import QRCode from "qrcode";
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";
import useMqttWebSocket from "../mqtt/useMqttWebSocket";
// 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",
});
// --- Helpers ---
function formatSecondsAgo(seconds) {
if (seconds == null) return null;
if (seconds < 60) return `${seconds}s ago`;
if (seconds < 3600) {
const m = Math.round(seconds / 60);
return `${m}m ago`;
}
if (seconds < 86400) {
const h = Math.round(seconds / 3600);
return `${h}h ago`;
}
const d = Math.round(seconds / 86400);
return `${d}d ago`;
}
// --- Helper components ---
function Field({ label, children }) {
const isUnavailableText =
typeof children === "string" &&
(children.toLowerCase() === "unavailable info" || children.toLowerCase() === "info unavailable");
return (
{LOG_LEVEL_OPTIONS.map((opt) => {
const isSelected = value === opt.value;
return (
);
})}
);
}
function QrModal({ value, onClose }) {
const canvasRef = useRef(null);
useEffect(() => {
if (!value || !canvasRef.current) return;
QRCode.toCanvas(canvasRef.current, value, { width: 220, margin: 2, color: { dark: "#e3e5ea", light: "#1f2937" } });
}, [value]);
useEffect(() => {
const onKey = (e) => { if (e.key === "Escape") onClose(); };
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [onClose]);
if (!value) return null;
return (
Loading...
;
if (error) return (
{/* ── Hero row: main card + notes card side-by-side ─────────────── */}
{/* ── Main info card ── */}
{/* 1. Product photo */}
{/* 2. Main info — Serial / Hardware / Location */}
SERIAL NUMBER
{device.serial_number || device.device_id || "-"}
{(device.serial_number || device.device_id) && (
)}
HARDWARE VARIANT
{hwVariant}
LOCATION
{device.device_location || "-"}
{/* 3. Subscription / Warranty */}
{/* Row 1: Tier left, Warranty Status right */}
SUBSCRIPTION TIER
{sub.subscrTier || "-"}
{sub.subscrDuration ? (
<>·
{subscrDurationLabel(sub.subscrDuration)}>
) : null}
WARRANTY STATUS
{warrantyStatusLabel}
{/* Row 2: Subscription progress */}
{/* Row 3: Warranty progress */}
WARRANTY PROGRESS
0) ? "#B86716" : "var(--danger)"
}} />
{/* 4. Uptime / Latest Issue — 2 items; sits in rows 1 and 2, row 3 empty */}
{/* Row 1: Device Uptime */}
DEVICE UPTIME
{mqttStatus?.uptime_seconds != null
? (() => {
const s = mqttStatus.uptime_seconds;
const d = Math.floor(s / 86400);
const h = Math.floor((s % 86400) / 3600);
const m = Math.floor((s % 3600) / 60);
return [d && `${d} day${d !== 1 ? "s" : ""}`, h && `${h} hour${h !== 1 ? "s" : ""}`, m && `${m} min`].filter(Boolean).join(" · ") || "< 1 min";
})()
: Unavailable}
{/* Row 2: Latest Device Issue */}
LATEST DEVICE ISSUE
{mqttStatus?.last_warn_message ? (
<>
MQTT · WARN · Health Monitor
{mqttStatus.last_warn_message}
>
) : (
No recent issues
)}
{/* ── Admin Quick Notes card ── */}
ADMIN QUICK NOTES
{!editingNotes && (
)}
{editingNotes ? (
) : (
setEditingNotes(true)}
style={{ color: staffNotes ? "var(--text-secondary)" : "var(--text-muted)" }}
>
{staffNotes || "Add a quick note to help identify this device…"}
)}
{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.photo_url ? (

) : (
{(user.display_name || user.email || "?").charAt(0).toUpperCase()}
)}
{user.display_name || user.email || "Unknown User"}
{user.email && user.display_name && (
{user.email}
)}
{user.role && (
{user.role}
)}
))}
)}
);
const generalInfoTab = (
{locationCard}
setEditingAttributes(true) : undefined}>
{formatConnectedBells(attr.totalBells)}
setEditingLogging(true) : undefined}>
{(() => {
const info = formatLogLevel(attr.serialLogLevel);
if (!info) return UNAVAILABLE_INFO;
return {info.text};
})()}
{(() => {
const info = formatLogLevel(attr.sdLogLevel);
if (!info) return UNAVAILABLE_INFO;
return {info.text};
})()}
{(() => {
const info = formatLogLevel(attr.mqttLogLevel);
if (!info) return UNAVAILABLE_INFO;
return {info.text};
})()}
setEditingMisc(true) : undefined}>
{formatLocale(attr.deviceLocale)}
{hasValue(device.websocket_url) ? device.websocket_url : UNAVAILABLE_INFO}
{hasValue(net.hostname) ? net.hostname : UNAVAILABLE_INFO}
{hasValue(staticIpAddress) ? staticIpAddress : UNAVAILABLE_INFO}
{/* ── Tags ── */}
{tags.length === 0 && (
No tags yet.
)}
{tags.map((tag) => (
{tag}
{canEdit && (
)}
))}
{canEdit && (
setTagInput(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); handleAddTag(tagInput); } }}
placeholder="Add tag and press Enter…"
className="px-3 py-1.5 rounded-md text-sm border flex-1"
style={{ backgroundColor: "var(--bg-input)", borderColor: "var(--border-input)", color: "var(--text-primary)" }}
/>
)}
{/* ── Owner ── */}
{device.customer_id ? (
{ownerCustomer ? (
navigate(`/crm/customers/${device.customer_id}`)}
title="View customer"
>
{(ownerCustomer.name || "?")[0].toUpperCase()}
{ownerCustomer.name || "—"}
{ownerCustomer.organization && (
{ownerCustomer.organization}
)}
) : (
Customer assigned (loading details…)
)}
{canEdit && (
)}
) : (
No customer assigned yet.
{canEdit && (
)}
)}
{showAssignSearch && (
{ setShowAssignSearch(false); handleAssignCustomer(c); }}
onCancel={() => setShowAssignSearch(false)}
/>
)}
{/* ── Device Notes ── */}
{!notesLoaded ? (
Loading…
) : (
<>
{deviceNotes.length === 0 && !addingNote && (
No notes for this device.
)}
{deviceNotes.map((note) => (
{editingNoteId === note.id ? (
) : (
{note.content}
{note.created_by}{note.created_at ? ` · ${new Date(note.created_at).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" })}` : ""}
{canEdit && (
)}
)}
))}
{canEdit && (
addingNote ? (
) : (
)
)}
>
)}
);
const bellMechanismsTab = (
Bell Commander
Bell Guard
Connected Bells
{formatConnectedBells(attr.totalBells)}
{canEdit && (
CAUTION
Bell Commander
{
if (!val && !window.confirm("Are you sure you want to DISABLE the Bell Mechanisms?")) return;
try {
await api.put(`/devices/${id}`, { device_attributes: { hasBells: val } });
await loadData();
} catch {}
}}
onLabel="Active"
offLabel="Disabled"
/>
)}
{attr.bellOutputs?.length ? (
{attr.bellOutputs.map((output, i) => {
const hammingMs = attr.hammerTimings?.[i];
const strikerSize = getStrikerSize(hammingMs);
const ordinal = ORDINAL_NAMES[i] ?? `${i + 1}th`;
return (
{/* Left column */}
{ordinal} Bell Mechanism
{Number.isFinite(output) && Number(output) === 0
? Currently Disabled
: <>Assigned to{" "}{Number.isFinite(output) ? formatOutput(output) : UNAVAILABLE_INFO}>
}
with a relay timing of{" "}
{hammingMs != null ? `${hammingMs} ms` : UNAVAILABLE_INFO}
using a{" "}
{strikerSize ? `Size ${strikerSize}` : UNAVAILABLE_INFO}
{" "}
Striker Mechanism
has been struck{" "}
{Number.isFinite(liveStrikeCounters?.[i])
? `${liveStrikeCounters[i]} Times`
: requestingStrikeCounters
? "Loading..."
: UNAVAILABLE_INFO}
{/* Right column */}
{strikerSize && (

)}
Manuf. Certified
BellSystems Striker
);
})}
) : (
No bell mechanism data available.
)}
);
const clockAlertsTab = (
setEditingClockSettings(true) : undefined}>
{formatOutput(clock.clockOutputs?.[0])}
{formatOutput(clock.clockOutputs?.[1])}
{clock.clockTimings?.[0] != null ? formatMsWithSeconds(clock.clockTimings[0]) : UNAVAILABLE_INFO}
{clock.clockTimings?.[1] != null ? formatMsWithSeconds(clock.clockTimings[1]) : UNAVAILABLE_INFO}
setEditingAlerts(true) : undefined}>
{clock.ringAlerts === "multi"
? "Hour Indicating"
: clock.ringAlerts === "single"
? "Single Fire"
: clock.ringAlerts === "off" || clock.ringAlerts === "disabled"
? "Deactivated"
: UNAVAILABLE_INFO}
{Number.isFinite(clock.ringIntervals) ? formatMsWithSeconds(clock.ringIntervals) : UNAVAILABLE_INFO}
{formatBell(clock.hourAlertsBell)}
{formatBell(clock.halfhourAlertsBell)}
{formatBell(clock.quarterAlertsBell)}
{formatSilenceRange(clock.isDaySilenceOn, clock.daySilenceFrom, clock.daySilenceTo)}
{formatSilenceRange(clock.isNightSilenceOn, clock.nightSilenceFrom, clock.nightSilenceTo)}
setEditingBacklight(true) : undefined}>
{formatOutputWithDisabledZero(clock.backlightOutput)}
{formatSilencePeriod(clock.isBacklightAutomationOn, clock.backlightTurnOnTime, clock.backlightTurnOffTime)}
);
const warrantySubscriptionTab = (
setEditingSubscription(true) : undefined}>
{hasValue(sub.subscrTier) ? sub.subscrTier : UNAVAILABLE_INFO}
{subscrDaysLeft == null ? UNAVAILABLE_INFO : subscrDaysLeft <= 0 ? "Expired" : formatDaysVerbose(subscrDaysLeft)}
{Number.isFinite(sub.maxUsers) ? formatCount(sub.maxUsers, "User") : UNAVAILABLE_INFO}
{Number.isFinite(sub.maxOutputs) ? formatCount(sub.maxOutputs, "Output") : UNAVAILABLE_INFO}
{formatAnyDateVerbose(sub.subscrStart)}
{subscrEnd ? formatDateNice(subscrEnd) : UNAVAILABLE_INFO}
{Number.isFinite(sub.subscrDuration) && sub.subscrDuration > 0 ? formatDaysVerbose(sub.subscrDuration) : UNAVAILABLE_INFO}
setEditingWarranty(true) : undefined}>
0 : stats.warrantyActive) ? "device-status-text--positive" : "device-status-text--negative"}`}>
{warrantyIsVoid ? "Warranty Void" : warrantyDaysLeft !== null ? (warrantyDaysLeft > 0 ? "Active" : "Expired") : formatBoolean(stats.warrantyActive, "Active", "Expired")}
{warrantyDaysLeft == null ? UNAVAILABLE_INFO : warrantyDaysLeft <= 0 ? "Expired" : formatDaysVerbose(warrantyDaysLeft)}
{formatAnyDateVerbose(stats.warrantyStart)}
{warrantyEnd ? formatDateNice(warrantyEnd) : UNAVAILABLE_INFO}
{Number.isFinite(stats.warrantyPeriod) && stats.warrantyPeriod > 0 ? formatDaysVerbose(stats.warrantyPeriod) : UNAVAILABLE_INFO}
{formatAnyDateVerbose(stats.maintainedOn)}
{Number.isFinite(stats.maintainancePeriod) && stats.maintainancePeriod > 0 ? formatDaysVerbose(stats.maintainancePeriod) : UNAVAILABLE_INFO}
{nextMaintenance ? `${formatDateNice(nextMaintenance)} (${maintenanceDaysLeft <= 0 ? "overdue" : formatRelativeTime(maintenanceDaysLeft)})` : UNAVAILABLE_INFO}
{randomPlaybacks} Times
{Number.isFinite(hammerStrikesFromLive)
? (
Striked{" "}
0 ? "#f59e0b" : "var(--success-text)" }}>
{hammerStrikesFromLive}
{" "}
Times
)
: requestingStrikeCounters
? "Loading..."
: UNAVAILABLE_INFO}
{Number.isFinite(stats.totalWarningsGiven)
? stats.totalWarningsGiven === 0
? No Warnings
: stats.totalWarningsGiven === 1
? 1 Warning given
: {stats.totalWarningsGiven} Warnings given
: UNAVAILABLE_INFO}
{Array.isArray(device.device_melodies_all)
? device.device_melodies_all.length === 0
? "No Melodies yet"
: `${device.device_melodies_all.length} on-board Melodies`
: UNAVAILABLE_INFO}
{Array.isArray(device.device_melodies_favorites)
? device.device_melodies_favorites.length === 0
? "No Melodies yet"
: `${device.device_melodies_favorites.length} on-board Melodies`
: UNAVAILABLE_INFO}
);
const totalBells = attr?.totalBells || 0;
const bellNumbers = Array.from({ length: totalBells }, (_, i) => i + 1);
const CTRL_STATUS_STYLES = {
success: { bg: "var(--success-bg)", color: "var(--success-text)" },
error: { bg: "var(--danger-bg)", color: "var(--danger-text)" },
pending: { bg: "var(--badge-blue-bg)", color: "var(--badge-blue-text)" },
timeout: { bg: "var(--danger-bg)", color: "var(--danger-text)" },
};
const ctrlFormatPayload = (jsonStr) => {
if (!jsonStr) return null;
try { return JSON.stringify(JSON.parse(jsonStr), null, 2); } catch { return jsonStr; }
};
// Control card button style
const ctrlBtn = (danger = false) => ({
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
padding: "0.6rem 1.25rem",
borderRadius: "0.5rem",
border: `1px solid ${danger ? "var(--danger)" : "var(--border-secondary)"}`,
backgroundColor: danger ? "var(--danger-btn, #7f1d1d)" : "var(--bg-input, #2E3D51)",
color: danger ? "#fff" : "var(--text-primary)",
fontSize: "0.875rem",
cursor: "pointer",
fontFamily: "inherit",
transition: "opacity 0.15s",
whiteSpace: "nowrap",
});
const ctrlSelect = {
display: "inline-flex",
alignItems: "center",
justifyContent: "space-between",
padding: "0.6rem 1rem",
borderRadius: "0.5rem",
border: "1px solid var(--border-secondary)",
backgroundColor: "var(--bg-input, #2E3D51)",
color: "var(--text-primary)",
fontSize: "0.875rem",
cursor: "pointer",
fontFamily: "inherit",
minWidth: 140,
};
const ctrlLabel = {
display: "block",
fontSize: "0.65rem",
fontWeight: 600,
letterSpacing: "0.07em",
textTransform: "uppercase",
color: "var(--text-muted)",
marginBottom: "0.45rem",
};
const ctrlFieldGroup = {
display: "flex",
flexDirection: "column",
gap: "0.35rem",
};
const ORDINAL_CTRL = ["1st", "2nd", "3rd", "4th", "5th", "6th", "7th", "8th", "9th", "10th", "11th", "12th"];
const TIMEZONES = [
"UTC", "Europe/London", "Europe/Paris", "Europe/Athens", "Europe/Helsinki",
"America/New_York", "America/Chicago", "America/Denver", "America/Los_Angeles",
"Asia/Tokyo", "Asia/Shanghai", "Australia/Sydney",
];
const controlTab = (
{/* ── Grid of control cards ── */}
{/* 1. Restart */}
Send a restart command to the device. The device will reboot and reconnect automatically.
{/* 2. Firmware Update */}
{/* Info column */}
Device Firmware
{device?.device_firmware_version || "—"}
Firmware Family
{device?.device_firmware_family || "Bell Core"}
Last Updated On
{device?.device_firmware_updated ? formatDate(device.device_firmware_updated) : "—"}
{/* Controls column — pushed to far right */}
Change Branch
{/* 3. Test Fire Bells */}
Click any bell button to fire it once. Use "Fire All" to trigger each bell in sequence.
{totalBells === 0 ? (
No bells configured on this device.
) : (
{bellNumbers.map((n) => (
))}
)}
{/* 4. HTTP Server */}
Enable or disable the HTTP server on this device. Turning it off will make the web interface inaccessible.
HTTP Server Status
{ctrlHttpEnabled ? "Running" : "Disabled"}
{/* 5. SD Card */}
List all files stored on the device's SD card, or permanently delete all contents including melodies, logs, and configuration files.
{/* 6. Time Sync */}
Device Date & Time
setCtrlDeviceTime(e.target.value)}
style={{
...ctrlSelect,
minWidth: 200,
padding: "0.55rem 0.85rem",
}}
/>
Timezone
setCtrlDst(val)}
onLabel="DST On"
offLabel="DST Off"
/>
{/* end device-tab-grid */}
{/* 7. Device Melodies List — full width */}
{ctrlMelodies.length > 0 && (
)}
{ctrlMelodiesLoading ? (
Requesting melody list from device...
) : ctrlMelodies.length === 0 ? (
No melodies loaded yet. Press "Fetch Melody List" to request them from the device.
) : (
| # |
Filename |
Size |
Actions |
{ctrlMelodies.map((mel, i) => (
| {i + 1} |
{mel.name} |
{mel.size ? `${mel.size} bytes` : "—"} |
|
))}
)}
{/* 8. Custom Command */}
{/* 9. Logs — full width, scrollable */}
{/* 10. Command History — full width */}
{ctrlCmdHistory.length} commands
{ctrlCmdHistoryLoading ? (
Loading...
) : ctrlCmdHistory.length === 0 ? (
No commands sent to this device yet.{" "}
) : (
| Time |
Command |
Status |
Details |
{ctrlCmdHistory.map((cmd, index) => {
const s = CTRL_STATUS_STYLES[cmd.status] || CTRL_STATUS_STYLES.pending;
const isExpanded = ctrlExpandedCmd === cmd.id;
return (
<>
setCtrlExpandedCmd(isExpanded ? null : cmd.id)}
>
|
{cmd.sent_at?.replace("T", " ").substring(0, 19)}
|
{cmd.command_name}
|
{cmd.status}
|
{cmd.responded_at ? `Replied ${cmd.responded_at.replace("T", " ").substring(0, 19)}` : "Awaiting response..."}
|
{isExpanded && (
Sent Payload
{ctrlFormatPayload(cmd.command_payload) || "—"}
Response
{ctrlFormatPayload(cmd.response_payload) || "Waiting..."}
|
)}
>
);
})}
)}
);
// ── Manage tab ──────────────────────────────────────────────────────────────
const manageTab = (
{/* Issues & Notes — full width */}
{/* User Assignment */}
App Users ({deviceUsers.length})
{canEdit && (
)}
{usersLoading ? (
Loading users…
) : deviceUsers.length === 0 ? (
No users assigned. Users are added when they claim the device via the app, or you can add them manually.
) : (
{deviceUsers.map((u) => (
{u.photo_url ? (

) : (
{(u.display_name || u.email || "?")[0].toUpperCase()}
)}
{u.display_name || "—"}
{u.email &&
{u.email}
}
{u.role && (
{u.role}
)}
{canEdit && (
)}
))}
)}
{/* User search modal */}
{showUserSearch && (
Add User
setUserSearchQuery(e.target.value)}
placeholder="Search by name, email, or phone…"
className="w-full px-3 py-2 rounded-md text-sm border"
style={{ backgroundColor: "var(--bg-input)", borderColor: "var(--border-input)", color: "var(--text-primary)" }}
/>
{userSearching && (
…
)}
{userSearchResults.length === 0 ? (
{userSearching ? "Searching…" : userSearchQuery ? "No users found." : "Type to search users…"}
) : (
userSearchResults.map((u) => {
const alreadyAdded = deviceUsers.some((du) => du.user_id === u.id);
return (
);
})
)}
)}
);
const renderTabContent = () => {
if (activeTab === "dashboard") return dashboardTab;
if (activeTab === "general") return generalInfoTab;
if (activeTab === "bells") return bellMechanismsTab;
if (activeTab === "clock") return clockAlertsTab;
if (activeTab === "warranty") return warrantySubscriptionTab;
if (activeTab === "manage") return manageTab;
return controlTab;
};
return (
{device.device_name || "Unnamed Device"}
{isOnline ? "Online" : "Offline"}
{mqttStatus && (
{formatSecondsAgo(mqttStatus.seconds_since_heartbeat)}
)}
{unresolvedIssues > 0 && (
<>
>
)}
{canEdit && (
)}
{TAB_DEFS.map((tab, index) => (
setActiveTab(tab.id)}>
{tab.label}
{index < TAB_DEFS.length - 1 && }
))}
{renderTabContent()}
setShowDelete(false)}
/>
setEditingLocation(false)}
onSaved={loadData}
device={device}
coords={coords}
id={id}
/>
setEditingAttributes(false)}
onSaved={loadData}
device={device}
attr={attr}
id={id}
/>
setEditingLogging(false)}
onSaved={loadData}
attr={attr}
id={id}
/>
setEditingMisc(false)}
onSaved={loadData}
device={device}
attr={attr}
id={id}
/>
setEditingBellOutputs(false)}
onSaved={loadData}
attr={attr}
clock={clock}
sub={sub}
id={id}
/>
setEditingClockSettings(false)}
onSaved={loadData}
attr={attr}
clock={clock}
sub={sub}
id={id}
/>
setEditingAlerts(false)}
onSaved={loadData}
attr={attr}
clock={clock}
id={id}
/>
setEditingBacklight(false)}
onSaved={loadData}
attr={attr}
clock={clock}
sub={sub}
id={id}
/>
setEditingSubscription(false)}
onSaved={loadData}
sub={sub}
id={id}
/>
setEditingWarranty(false)}
onSaved={loadData}
stats={stats}
id={id}
/>
setQrTarget(null)} />
);
}