Files
bellsystems-cp/frontend/src/devices/DeviceDetail.jsx
bonamin 5d8ef96d4c update: CRM customers, orders, device detail, and status system changes
- CustomerList, CustomerForm, CustomerDetail: various updates
- Orders: removed OrderDetail and OrderForm, updated OrderList and index
- DeviceDetail: updates
- index.css: added new styles
- CRM_STATUS_SYSTEM_PLAN.md: new planning document
- Added customer-status assets and CustomerDetail subfolder

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 10:39:38 +02:00

3962 lines
170 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (
<div className="device-field">
<dt className="db-info-label">{label}</dt>
<dd className="device-field-value db-info-value">
{children == null ? (
<span className="device-value-secondary">Unavailable info</span>
) : isUnavailableText ? (
<span className="device-value-secondary">{children}</span>
) : (
children
)}
</dd>
</div>
);
}
function BoolBadge({ value, yesLabel = "Yes", noLabel = "No" }) {
const isMissing = typeof value !== "boolean";
const text = isMissing ? "Unavailable info" : value ? yesLabel : noLabel;
const tone = isMissing ? "muted" : value ? "positive" : "negative";
return (
<span className={`device-status-text device-status-text--${tone}`}>
{text}
</span>
);
}
function SectionCard({ title, children, onEdit }) {
return (
<section className="device-section-card">
<div className="device-section-card__title-row">
<h2 className="device-section-card__title">{title}</h2>
{onEdit && (
<button
type="button"
onClick={onEdit}
className="section-edit-btn"
title={`Edit ${title}`}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" width="13" height="13" aria-hidden="true">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
Edit
</button>
)}
</div>
{children}
</section>
);
}
function Subsection({ title, children, isFirst = false }) {
return (
<div className={isFirst ? "" : "mt-4 pt-4 border-t"} style={isFirst ? {} : { borderColor: "var(--border-secondary)" }}>
<h3 className="text-sm font-semibold mb-3" style={{ color: "var(--text-primary)" }}>{title}</h3>
{children}
</div>
);
}
/** A grid row of fields — Nth items align across rows within a subsection */
function FieldRow({ children, columns }) {
const childArray = Array.isArray(children) ? children.filter(Boolean) : [children];
const count = columns || childArray.length;
return (
<dl className="device-field-row" style={{ gridTemplateColumns: `repeat(${count}, minmax(0, 1fr))` }}>
{children}
</dl>
);
}
function EmptyCell() {
return <div className="device-empty-cell" aria-hidden="true" />;
}
function TabButton({ active, onClick, tone, children }) {
return (
<button
onClick={onClick}
className={`device-tab-btn device-tab-btn--${tone} ${active ? "device-tab-btn--active" : ""}`}
type="button"
>
{children}
</button>
);
}
function ProgressBar({ label, value, tone = "var(--accent)" }) {
const width = Number.isFinite(value) ? Math.max(0, Math.min(100, value)) : 0;
return (
<div className="space-y-2">
{label && <p className="text-xs font-medium" style={{ color: "var(--text-muted)" }}>{label}</p>}
<div className="device-progress-track">
<div className="device-progress-fill" style={{ width: `${width}%`, backgroundColor: tone }} />
</div>
<p className="text-xs" style={{ color: "var(--text-secondary)" }}>{Math.round(width)}%</p>
</div>
);
}
// --- Shared modal helpers ---
function ToggleSwitch({ value, onChange, onLabel = "On", offLabel = "Off" }) {
return (
<button
type="button"
onClick={() => onChange(!value)}
className="toggle-switch"
data-on={value ? "true" : "false"}
aria-pressed={value}
>
<span className="toggle-switch__track" data-on={value ? "true" : "false"}>
<span className="toggle-switch__thumb" />
</span>
<span className="toggle-switch__label" data-on={value ? "true" : "false"}>
{value ? onLabel : offLabel}
</span>
</button>
);
}
const LOG_LEVEL_OPTIONS = [
{ value: 0, label: "Disabled", color: "var(--text-muted)" },
{ value: 1, label: "Error", color: "var(--danger-text)" },
{ value: 2, label: "Warning", color: "#f59e0b" },
{ value: 3, label: "Info", color: "#63b3ed" },
{ value: 4, label: "Debug", color: "#9f7aea" },
{ value: 5, label: "Verbose", color: "#20c997" },
];
function LogLevelSelect({ value, onChange }) {
return (
<div className="flex flex-wrap gap-2">
{LOG_LEVEL_OPTIONS.map((opt) => {
const isSelected = value === opt.value;
return (
<button
key={opt.value}
type="button"
onClick={() => onChange(opt.value)}
className="px-3 py-1.5 text-xs rounded-md border cursor-pointer transition-colors"
style={{
backgroundColor: isSelected ? opt.color : "var(--bg-input)",
borderColor: isSelected ? opt.color : "var(--border-input)",
color: isSelected ? "#000" : opt.color,
fontWeight: isSelected ? 700 : 400,
}}
>
{opt.label}
</button>
);
})}
</div>
);
}
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 (
<div className="fixed inset-0 z-50 flex items-center justify-center" style={{ backgroundColor: "rgba(0,0,0,0.7)" }}
onClick={onClose}>
<div className="rounded-xl border p-5 shadow-2xl flex flex-col items-center gap-3"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
onClick={(e) => e.stopPropagation()}>
<p className="text-xs font-mono" style={{ color: "var(--text-muted)" }}>{value}</p>
<canvas ref={canvasRef} style={{ borderRadius: 8 }} />
<p className="text-xs" style={{ color: "var(--text-muted)" }}>Click outside or press ESC to close</p>
</div>
</div>
);
}
function SectionModal({ open, title, onCancel, onSave, saving, disabled, size = "max-w-lg", children }) {
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="fixed inset-0 bg-black/60" onClick={onCancel} />
<div
className={`relative rounded-xl shadow-2xl border w-full ${size} flex flex-col`}
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)", maxHeight: "90vh" }}
>
<div className="flex items-center justify-between px-6 pt-5 pb-4 border-b" style={{ borderColor: "var(--border-secondary)" }}>
<h3 className="text-base font-semibold" style={{ color: "var(--text-heading)" }}>{title}</h3>
<button type="button" onClick={onCancel} className="text-lg leading-none hover:opacity-70 cursor-pointer" style={{ color: "var(--text-muted)" }}>&#x2715;</button>
</div>
<div className="overflow-y-auto px-6 py-5 flex-1">
{children}
</div>
<div className="flex justify-end gap-3 px-6 py-4 border-t" style={{ borderColor: "var(--border-secondary)" }}>
<button
type="button"
onClick={onCancel}
className="px-4 py-2 text-sm rounded-md cursor-pointer hover:opacity-80"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-primary)" }}
>
Cancel
</button>
<button
type="button"
onClick={onSave}
disabled={saving || disabled}
className="px-4 py-2 text-sm rounded-md font-medium cursor-pointer hover:opacity-90 disabled:opacity-50 disabled:cursor-not-allowed"
style={{ backgroundColor: "var(--accent)", color: "#000" }}
>
{saving ? "Saving..." : "Sync & Save Settings"}
</button>
</div>
</div>
</div>
);
}
function ModalField({ label, children, hint }) {
return (
<div className="mb-4">
<label className="block text-xs font-semibold mb-1.5 uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>{label}</label>
{children}
{hint && <p className="mt-1 text-xs" style={{ color: "var(--text-muted)" }}>{hint}</p>}
</div>
);
}
function PulseButtonGroup({ value, onChange, options }) {
return (
<div className="flex flex-wrap gap-2">
{options.map((ms) => (
<button
key={ms}
type="button"
onClick={() => onChange(ms)}
className="px-3 py-1.5 text-xs rounded-md border cursor-pointer transition-colors"
style={{
backgroundColor: value === ms ? "var(--accent)" : "var(--bg-input)",
borderColor: value === ms ? "var(--accent)" : "var(--border-input)",
color: value === ms ? "#000" : "var(--text-primary)",
fontWeight: value === ms ? 600 : 400,
}}
>
{(ms / 1000).toFixed(1)}s
</button>
))}
</div>
);
}
function AlertTypeGroup({ value, onChange }) {
const opts = [
{ val: "disabled", label: "Disabled" },
{ val: "single", label: "Single Fire" },
{ val: "multi", label: "Hour Indicating" },
];
return (
<div className="flex gap-2">
{opts.map((o) => (
<button
key={o.val}
type="button"
onClick={() => onChange(o.val)}
className="flex-1 px-3 py-2 text-xs rounded-md border cursor-pointer transition-colors"
style={{
backgroundColor: value === o.val ? "var(--accent)" : "var(--bg-input)",
borderColor: value === o.val ? "var(--accent)" : "var(--border-input)",
color: value === o.val ? "#000" : "var(--text-primary)",
fontWeight: value === o.val ? 600 : 400,
}}
>
{o.label}
</button>
))}
</div>
);
}
function extractTimeFromTimestamp(tsStr) {
if (!tsStr) return "";
const d = new Date(tsStr.replace(" at ", " ").replace("UTC+0000", "UTC").replace(/UTC\+(\d{4})/, "UTC"));
if (!isNaN(d.getTime())) {
return `${d.getUTCHours().toString().padStart(2, "0")}:${d.getUTCMinutes().toString().padStart(2, "0")}`;
}
const match = tsStr.match(/(\d{1,2}):(\d{2})/);
if (match) return `${match[1].padStart(2, "0")}:${match[2]}`;
return "";
}
function timeToIso(hhmm) {
if (!hhmm) return "";
return `2000-01-01T${hhmm}:00Z`;
}
function DraggableMarker({ lat, lng, onMove }) {
const markerRef = useRef(null);
const eventHandlers = {
dragend() {
const m = markerRef.current;
if (m) {
const pos = m.getLatLng();
onMove(parseFloat(pos.lat.toFixed(7)), parseFloat(pos.lng.toFixed(7)));
}
},
};
return <Marker draggable eventHandlers={eventHandlers} position={[lat, lng]} ref={markerRef} />;
}
// --- Top-level modal components (defined before DeviceDetail to satisfy rules of hooks) ---
function LocationModal({ open, onClose, onSaved, device, coords, id }) {
const [locName, setLocName] = useState(device?.device_location || "");
const [lat, setLat] = useState(coords ? String(coords.lat) : "");
const [lng, setLng] = useState(coords ? String(coords.lng) : "");
const [saving, setSaving] = useState(false);
const handleSave = async () => {
setSaving(true);
const latNum = parseFloat(lat);
const lngNum = parseFloat(lng);
const coordPayload = (!isNaN(latNum) && !isNaN(lngNum))
? { lat: latNum, lng: lngNum }
: (device?.device_location_coordinates || null);
try {
await api.put(`/devices/${id}`, { device_location: locName, device_location_coordinates: coordPayload });
await onSaved();
onClose();
} finally { setSaving(false); }
};
return (
<SectionModal open={open} title="Edit Device Location" onCancel={onClose} onSave={handleSave} saving={saving} size="max-w-lg">
<ModalField label="Location Name" hint={`${locName.length}/35 characters`}>
<input
type="text"
value={locName}
maxLength={35}
onChange={(e) => setLocName(e.target.value)}
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)" }}
placeholder="e.g. Saint Mary's Church"
/>
</ModalField>
<div className="flex gap-3 mb-4">
<ModalField label="Latitude">
<input
type="number"
value={lat}
step="0.0000001"
onChange={(e) => setLat(e.target.value)}
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)" }}
placeholder="e.g. 37.9838"
/>
</ModalField>
<ModalField label="Longitude">
<input
type="number"
value={lng}
step="0.0000001"
onChange={(e) => setLng(e.target.value)}
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)" }}
placeholder="e.g. 23.7275"
/>
</ModalField>
</div>
{(() => {
const latN = parseFloat(lat);
const lngN = parseFloat(lng);
if (isNaN(latN) || isNaN(lngN)) return null;
return (
<div className="rounded-md overflow-hidden border mb-2" style={{ borderColor: "var(--border-primary)", height: 220 }}>
<MapContainer
key={`${lat}-${lng}`}
center={[latN, lngN]}
zoom={13}
style={{ height: "100%", width: "100%" }}
scrollWheelZoom={true}
>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
<DraggableMarker lat={latN} lng={lngN} onMove={(newLat, newLng) => { setLat(String(newLat)); setLng(String(newLng)); }} />
</MapContainer>
</div>
);
})()}
<p className="text-xs" style={{ color: "var(--text-muted)" }}>Drag the pin to update coordinates, or type them directly above.</p>
</SectionModal>
);
}
function AttributesModal({ open, onClose, onSaved, device, attr, id }) {
const [bellGuardOn, setBellGuardOn] = useState(attr?.bellGuardOn ?? false);
const [warningsOn, setWarningsOn] = useState(attr?.warningsOn ?? false);
const [bellGuardSafetyOn, setBellGuardSafetyOn] = useState(attr?.bellGuardSafetyOn ?? false);
const [hasBells, setHasBells] = useState(attr?.hasBells ?? false);
const [hasClock, setHasClock] = useState(attr?.hasClock ?? false);
const [saving, setSaving] = useState(false);
const handleSave = async () => {
setSaving(true);
try {
await api.put(`/devices/${id}`, { device_attributes: { bellGuardOn, warningsOn, bellGuardSafetyOn, hasBells, hasClock } });
await onSaved();
onClose();
} finally { setSaving(false); }
};
return (
<SectionModal open={open} title="Edit Basic Attributes" onCancel={onClose} onSave={handleSave} saving={saving} size="max-w-md">
<div className="flex gap-4">
<div className="flex-1">
<ModalField label="Bell Guard">
<ToggleSwitch value={bellGuardOn} onChange={setBellGuardOn} onLabel="Active" offLabel="Disabled" />
</ModalField>
</div>
<div className="flex-1">
<ModalField label="Bell Guard Safety">
<ToggleSwitch value={bellGuardSafetyOn} onChange={setBellGuardSafetyOn} onLabel="Armed" offLabel="Disarmed" />
</ModalField>
</div>
</div>
<ModalField label="Warnings">
<ToggleSwitch value={warningsOn} onChange={setWarningsOn} onLabel="Active" offLabel="Disabled" />
</ModalField>
<div className="flex gap-4">
<div className="flex-1">
<ModalField label="Bells Mechanism">
<ToggleSwitch value={hasBells} onChange={setHasBells} onLabel="Enabled" offLabel="Disabled" />
</ModalField>
</div>
<div className="flex-1">
<ModalField label="Bells Output">
<ToggleSwitch value={hasClock} onChange={setHasClock} onLabel="Enabled" offLabel="Disabled" />
</ModalField>
</div>
</div>
<div className="mt-2 p-3 rounded-md" style={{ backgroundColor: "var(--bg-primary)", borderColor: "var(--border-secondary)" }}>
<p className="text-xs" style={{ color: "var(--text-muted)" }}>
Connected Bells: <span style={{ color: "var(--text-primary)" }}>{formatConnectedBells(attr?.totalBells)}</span> managed via Bell Mechanisms tab.
</p>
</div>
</SectionModal>
);
}
function LoggingModal({ open, onClose, onSaved, attr, id }) {
const [serialLevel, setSerialLevel] = useState(attr?.serialLogLevel ?? 0);
const [sdLevel, setSdLevel] = useState(attr?.sdLogLevel ?? 0);
const [mqttLevel, setMqttLevel] = useState(attr?.mqttLogLevel ?? 0);
const [saving, setSaving] = useState(false);
const handleSave = async () => {
setSaving(true);
try {
await api.put(`/devices/${id}`, { device_attributes: { serialLogLevel: serialLevel, sdLogLevel: sdLevel, mqttLogLevel: mqttLevel } });
await onSaved();
onClose();
} finally { setSaving(false); }
};
return (
<SectionModal open={open} title="Edit Log Levels" onCancel={onClose} onSave={handleSave} saving={saving} size="max-w-lg">
<div className="flex flex-col gap-5">
<ModalField label="Serial Logs Level">
<LogLevelSelect value={serialLevel} onChange={setSerialLevel} />
</ModalField>
<ModalField label="SD Card Log Level">
<LogLevelSelect value={sdLevel} onChange={setSdLevel} />
</ModalField>
<ModalField label="MQTT Log Level">
<LogLevelSelect value={mqttLevel} onChange={setMqttLevel} />
</ModalField>
</div>
</SectionModal>
);
}
function MiscModal({ open, onClose, onSaved, device, attr, id }) {
const [eventsOn, setEventsOn] = useState(device?.events_on ?? false);
const [locale, setLocale] = useState(attr?.deviceLocale || "all");
const [wsUrl, setWsUrl] = useState(device?.websocket_url || "");
const [saving, setSaving] = useState(false);
const handleSave = async () => {
setSaving(true);
try {
await api.put(`/devices/${id}`, { events_on: eventsOn, device_attributes: { deviceLocale: locale }, websocket_url: wsUrl });
await onSaved();
onClose();
} finally { setSaving(false); }
};
return (
<SectionModal open={open} title="Edit Misc Settings" onCancel={onClose} onSave={handleSave} saving={saving} size="max-w-md">
<ModalField label="Automated Events">
<ToggleSwitch value={eventsOn} onChange={setEventsOn} onLabel="Enabled" offLabel="Disabled" />
</ModalField>
<ModalField label="Church Type">
<select
value={locale}
onChange={(e) => setLocale(e.target.value)}
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)" }}
>
<option value="all">All (Global)</option>
<option value="orthodox">Orthodox</option>
<option value="catholic">Catholic</option>
</select>
</ModalField>
<ModalField label="WebSocket URL">
<input
type="text"
value={wsUrl}
onChange={(e) => setWsUrl(e.target.value)}
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)" }}
placeholder="ws://..."
/>
</ModalField>
</SectionModal>
);
}
function BellOutputsModal({ open, onClose, onSaved, attr, clock, sub, id }) {
const maxOutputs = sub?.maxOutputs || 8;
const clockOutputsUsed = (clock?.clockOutputs || []).filter((o) => o > 0);
const existingCount = attr?.totalBells || 0;
const existingOutputs = attr?.bellOutputs || [];
const existingTimings = attr?.hammerTimings || [];
const TIMING_OPTIONS = [80, 85, 90, 95, 100, 110, 120, 140];
const TIMING_LABELS = { 80: "Min - 80ms", 85: "Size 1 - 85ms", 90: "Size 2 - 90ms", 95: "Size 3 - 95ms", 100: "Size 4 - 100ms", 110: "Size 5 - 110ms", 120: "Size 6 - 120ms", 140: "Max - 140ms" };
const buildDefaults = (count) =>
Array.from({ length: count }, (_, i) => ({
output: existingOutputs[i] ?? 0,
timing: existingTimings[i] ?? 90,
}));
const [activeBells, setActiveBells] = useState(existingCount);
const [rows, setRows] = useState(buildDefaults(maxOutputs));
const [saving, setSaving] = useState(false);
const updateRow = (i, field, val) => {
setRows((prev) => prev.map((r, idx) => idx === i ? { ...r, [field]: val } : r));
};
const activeRows = rows.slice(0, activeBells);
const duplicateOutputs = activeRows
.map((r, i) => ({ output: r.output, i }))
.filter((r) => r.output > 0)
.filter((r, _, arr) => arr.filter((x) => x.output === r.output).length > 1)
.map((r) => r.i);
const handleSave = async () => {
setSaving(true);
try {
const bellOutputs = activeRows.map((r) => r.output);
const hammerTimings = activeRows.map((r) => r.timing);
await api.put(`/devices/${id}`, { device_attributes: { totalBells: activeBells, bellOutputs, hammerTimings } });
await onSaved();
onClose();
} finally { setSaving(false); }
};
return (
<SectionModal open={open} title="Edit Bell Outputs" onCancel={onClose} onSave={handleSave} saving={saving} size="max-w-2xl">
<p className="text-xs mb-4" style={{ color: "var(--text-muted)" }}>
Click a bell to set how many bells are active (1 through {maxOutputs}). Then assign each an output and timing below.
</p>
<div className="flex gap-3 flex-wrap mb-2">
{Array.from({ length: maxOutputs }, (_, i) => {
const n = i + 1;
const isActive = n <= activeBells;
return (
<button
key={n}
type="button"
onClick={() => setActiveBells(n === activeBells ? n - 1 : n)}
title={`Bell ${n}`}
className="flex items-center justify-center p-2 rounded-lg border cursor-pointer transition-colors"
style={{
borderColor: isActive ? "var(--accent)" : "var(--border-input)",
backgroundColor: isActive ? "rgba(116,184,22,0.1)" : "var(--bg-input)",
width: 52,
height: 52,
}}
>
<svg viewBox="0 0 24 24" fill={isActive ? "var(--accent)" : "var(--text-muted)"} width="32" height="32" aria-hidden="true">
<path d="M13.73 21a2 2 0 0 1-3.46 0M18.63 13A17.89 17.89 0 0 1 18 8a6 6 0 0 0-12 0c0 1.76-.35 3.45-1 4.98L4 14h16l-.37-1z" />
</svg>
</button>
);
})}
</div>
<p className="text-xs mb-5" style={{ color: "var(--text-secondary)" }}>
{activeBells} of {maxOutputs} bells active
</p>
{activeBells > 0 && (
<div className="rounded-md border overflow-hidden mb-2" style={{ borderColor: "var(--border-primary)" }}>
<table className="w-full text-sm">
<thead>
<tr style={{ backgroundColor: "var(--bg-primary)", borderBottom: "1px solid var(--border-secondary)" }}>
<th className="px-3 py-2 text-left text-xs font-semibold" style={{ color: "var(--text-muted)" }}>Bell</th>
<th className="px-3 py-2 text-left text-xs font-semibold" style={{ color: "var(--text-muted)" }}>Output Assignment</th>
<th className="px-3 py-2 text-left text-xs font-semibold" style={{ color: "var(--text-muted)" }}>Timing (Striker)</th>
</tr>
</thead>
<tbody>
{activeRows.map((row, i) => {
const isDup = duplicateOutputs.includes(i);
const isClockConflict = row.output > 0 && clockOutputsUsed.includes(row.output);
return (
<tr key={i} style={{ borderBottom: i < activeBells - 1 ? "1px solid var(--border-secondary)" : "none" }}>
<td className="px-3 py-2 text-xs font-medium" style={{ color: "var(--text-primary)" }}>
{ORDINAL_NAMES[i] ?? `${i + 1}th`} Bell
</td>
<td className="px-3 py-2">
<select
value={row.output}
onChange={(e) => updateRow(i, "output", Number(e.target.value))}
className="w-full px-2 py-1.5 rounded text-xs border"
style={{
backgroundColor: "var(--bg-input)",
borderColor: (isDup || isClockConflict) ? "var(--danger)" : "var(--border-input)",
color: "var(--text-primary)",
}}
>
<option value={0}>Disabled</option>
{Array.from({ length: maxOutputs }, (_, j) => {
const out = j + 1;
const isClock = clockOutputsUsed.includes(out);
return (
<option key={out} value={out} style={{ color: isClock ? "var(--danger-text)" : "inherit" }}>
Output {out}{isClock ? " (Clock — conflict)" : ""}
</option>
);
})}
</select>
{isDup && <p className="text-xs mt-0.5" style={{ color: "var(--danger-text)" }}>Duplicate output</p>}
{isClockConflict && <p className="text-xs mt-0.5" style={{ color: "var(--danger-text)" }}>Used by clock</p>}
</td>
<td className="px-3 py-2">
<select
value={row.timing}
onChange={(e) => updateRow(i, "timing", Number(e.target.value))}
className="w-full px-2 py-1.5 rounded text-xs border"
style={{ backgroundColor: "var(--bg-input)", borderColor: "var(--border-input)", color: "var(--text-primary)" }}
>
{TIMING_OPTIONS.map((t) => (
<option key={t} value={t}>{TIMING_LABELS[t]}</option>
))}
</select>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</SectionModal>
);
}
function ClockSettingsModal({ open, onClose, onSaved, attr, clock, sub, id }) {
const maxOutputs = sub?.maxOutputs || 8;
const [hasClock, setHasClock] = useState(attr?.hasClock ?? false);
const [oddOut, setOddOut] = useState(clock?.clockOutputs?.[0] || 0);
const [evenOut, setEvenOut] = useState(clock?.clockOutputs?.[1] || 0);
const [runPulse, setRunPulse] = useState(clock?.clockTimings?.[0] || 5000);
const [pause, setPause] = useState(clock?.clockTimings?.[1] || 2000);
const [saving, setSaving] = useState(false);
const bellOutputsUsed = (attr?.bellOutputs || []).filter((o) => o > 0);
const buildOutputOptions = (excludeOther) =>
Array.from({ length: maxOutputs }, (_, j) => {
const out = j + 1;
const isBell = bellOutputsUsed.includes(out);
const isOther = out === excludeOther && excludeOther > 0;
return { out, isBell, isOther };
});
const conflictMsg = oddOut > 0 && oddOut === evenOut ? "ODD and EVEN outputs cannot be the same." : null;
const validationMsg = hasClock && (oddOut === 0 || evenOut === 0 || !runPulse || !pause)
? "When clock is enabled, both outputs and both pulse timings must be set."
: null;
const canSave = !conflictMsg && !validationMsg;
const handleSave = async () => {
setSaving(true);
try {
await api.put(`/devices/${id}`, {
device_attributes: {
hasClock,
clockSettings: { clockOutputs: [oddOut, evenOut], clockTimings: [runPulse, pause] },
},
});
await onSaved();
onClose();
} finally { setSaving(false); }
};
return (
<SectionModal open={open} title="Edit Clock Settings" onCancel={onClose} onSave={handleSave} saving={saving} disabled={!canSave} size="max-w-lg">
<ModalField label="Clock Controller">
<ToggleSwitch value={hasClock} onChange={setHasClock} onLabel="Enabled" offLabel="Disabled" />
</ModalField>
<div className="flex gap-4">
<div className="flex-1">
<ModalField label="ODD Output">
<select
value={oddOut}
onChange={(e) => setOddOut(Number(e.target.value))}
className="w-full px-3 py-2 rounded-md text-sm border"
style={{ backgroundColor: "var(--bg-input)", borderColor: oddOut === evenOut && oddOut > 0 ? "var(--danger)" : "var(--border-input)", color: "var(--text-primary)" }}
>
<option value={0}>Disconnected</option>
{buildOutputOptions(evenOut).map(({ out, isBell }) => (
<option key={out} value={out} disabled={isBell} style={{ color: isBell ? "var(--danger-text)" : "inherit" }}>
Output {out}{isBell ? ` (Bell)` : ""}
</option>
))}
</select>
</ModalField>
</div>
<div className="flex-1">
<ModalField label="EVEN Output">
<select
value={evenOut}
onChange={(e) => setEvenOut(Number(e.target.value))}
className="w-full px-3 py-2 rounded-md text-sm border"
style={{ backgroundColor: "var(--bg-input)", borderColor: oddOut === evenOut && oddOut > 0 ? "var(--danger)" : "var(--border-input)", color: "var(--text-primary)" }}
>
<option value={0}>Disconnected</option>
{buildOutputOptions(oddOut).map(({ out, isBell }) => (
<option key={out} value={out} disabled={isBell} style={{ color: isBell ? "var(--danger-text)" : "inherit" }}>
Output {out}{isBell ? ` (Bell)` : ""}
</option>
))}
</select>
</ModalField>
</div>
</div>
{conflictMsg && <p className="text-xs mb-3" style={{ color: "var(--danger-text)" }}>{conflictMsg}</p>}
{validationMsg && !conflictMsg && <p className="text-xs mb-3" style={{ color: "#f59e0b" }}>{validationMsg}</p>}
<ModalField label="Run Pulse">
<PulseButtonGroup value={runPulse} onChange={setRunPulse} options={[2000, 3000, 4000, 5000, 6000, 7000, 8000]} />
</ModalField>
<ModalField label="Pause">
<PulseButtonGroup value={pause} onChange={setPause} options={[1000, 2000, 3000, 4000, 5000]} />
</ModalField>
</SectionModal>
);
}
function AlertsModal({ open, onClose, onSaved, attr, clock, id }) {
const [ringAlerts, setRingAlerts] = useState(clock?.ringAlerts || "disabled");
const [ringIntervals, setRingIntervals] = useState(clock?.ringIntervals ?? 1000);
const [hourBell, setHourBell] = useState(clock?.hourAlertsBell ?? 0);
const [halfBell, setHalfBell] = useState(clock?.halfhourAlertsBell ?? 0);
const [quarterBell, setQuarterBell] = useState(clock?.quarterAlertsBell ?? 0);
const [isDaySilenceOn, setIsDaySilenceOn] = useState(clock?.isDaySilenceOn ?? false);
const [daySilenceFrom, setDaySilenceFrom] = useState(extractTimeFromTimestamp(clock?.daySilenceFrom));
const [daySilenceTo, setDaySilenceTo] = useState(extractTimeFromTimestamp(clock?.daySilenceTo));
const [isNightSilenceOn, setIsNightSilenceOn] = useState(clock?.isNightSilenceOn ?? false);
const [nightSilenceFrom, setNightSilenceFrom] = useState(extractTimeFromTimestamp(clock?.nightSilenceFrom));
const [nightSilenceTo, setNightSilenceTo] = useState(extractTimeFromTimestamp(clock?.nightSilenceTo));
const [saving, setSaving] = useState(false);
const totalBells = attr?.totalBells || 0;
const bellOptions = [{ value: 0, label: "Disabled" }, ...Array.from({ length: totalBells }, (_, i) => ({ value: i + 1, label: `Bell ${i + 1}` }))];
const alertValidationMsg = (() => {
if (ringAlerts === "multi" && !ringIntervals) return "Alert Tempo must be selected when Hour Indicating is active.";
if (isDaySilenceOn && (!daySilenceFrom || !daySilenceTo)) return "Day Silence is ON — both From and To times must be set.";
if (isNightSilenceOn && (!nightSilenceFrom || !nightSilenceTo)) return "Night Silence is ON — both From and To times must be set.";
return null;
})();
const handleSave = async () => {
setSaving(true);
try {
await api.put(`/devices/${id}`, {
device_attributes: {
clockSettings: {
ringAlerts,
ringAlertsMasterOn: ringAlerts !== "disabled",
ringIntervals,
hourAlertsBell: hourBell,
halfhourAlertsBell: halfBell,
quarterAlertsBell: quarterBell,
isDaySilenceOn,
daySilenceFrom: daySilenceFrom ? timeToIso(daySilenceFrom) : "",
daySilenceTo: daySilenceTo ? timeToIso(daySilenceTo) : "",
isNightSilenceOn,
nightSilenceFrom: nightSilenceFrom ? timeToIso(nightSilenceFrom) : "",
nightSilenceTo: nightSilenceTo ? timeToIso(nightSilenceTo) : "",
},
},
});
await onSaved();
onClose();
} finally { setSaving(false); }
};
return (
<SectionModal open={open} title="Edit Alert Settings" onCancel={onClose} onSave={handleSave} saving={saving} disabled={!!alertValidationMsg} size="max-w-xl">
{alertValidationMsg && <p className="text-xs mb-3 p-2 rounded" style={{ color: "#f59e0b", backgroundColor: "rgba(245,158,11,0.08)", border: "1px solid rgba(245,158,11,0.2)" }}>{alertValidationMsg}</p>}
<ModalField label="Alerts Type">
<AlertTypeGroup value={ringAlerts} onChange={setRingAlerts} />
</ModalField>
<ModalField label="Alert Tempo">
<PulseButtonGroup value={ringIntervals} onChange={setRingIntervals} options={[500, 1000, 1500, 2000, 2500]} />
</ModalField>
<div className="flex gap-4">
<div className="flex-1">
<ModalField label="Hour Bell">
<select value={hourBell} onChange={(e) => setHourBell(Number(e.target.value))} 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)" }}>
{bellOptions.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
</ModalField>
</div>
<div className="flex-1">
<ModalField label="Half-Hour Bell">
<select value={halfBell} onChange={(e) => setHalfBell(Number(e.target.value))} 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)" }}>
{bellOptions.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
</ModalField>
</div>
<div className="flex-1">
<ModalField label="Quarter Bell">
<select value={quarterBell} onChange={(e) => setQuarterBell(Number(e.target.value))} 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)" }}>
{bellOptions.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
</ModalField>
</div>
</div>
<div className="p-4 rounded-lg border mb-4" style={{ borderColor: "var(--border-secondary)", backgroundColor: "var(--bg-primary)" }}>
<div className="flex items-center justify-between mb-3">
<span className="text-sm font-semibold" style={{ color: "var(--text-primary)" }}>Day Silence</span>
<ToggleSwitch value={isDaySilenceOn} onChange={setIsDaySilenceOn} onLabel="On" offLabel="Off" />
</div>
{isDaySilenceOn && (
<div className="flex gap-4">
<ModalField label="From">
<input type="time" value={daySilenceFrom} onChange={(e) => setDaySilenceFrom(e.target.value)} 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)" }} />
</ModalField>
<ModalField label="Up To">
<input type="time" value={daySilenceTo} onChange={(e) => setDaySilenceTo(e.target.value)} 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)" }} />
</ModalField>
</div>
)}
</div>
<div className="p-4 rounded-lg border" style={{ borderColor: "var(--border-secondary)", backgroundColor: "var(--bg-primary)" }}>
<div className="flex items-center justify-between mb-3">
<span className="text-sm font-semibold" style={{ color: "var(--text-primary)" }}>Night Silence</span>
<ToggleSwitch value={isNightSilenceOn} onChange={setIsNightSilenceOn} onLabel="On" offLabel="Off" />
</div>
{isNightSilenceOn && (
<div className="flex gap-4">
<ModalField label="From">
<input type="time" value={nightSilenceFrom} onChange={(e) => setNightSilenceFrom(e.target.value)} 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)" }} />
</ModalField>
<ModalField label="Up To">
<input type="time" value={nightSilenceTo} onChange={(e) => setNightSilenceTo(e.target.value)} 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)" }} />
</ModalField>
</div>
)}
</div>
</SectionModal>
);
}
function BacklightModal({ open, onClose, onSaved, attr, clock, sub, id }) {
const maxOutputs = sub?.maxOutputs || 8;
const [isOn, setIsOn] = useState(clock?.isBacklightAutomationOn ?? false);
const [output, setOutput] = useState(clock?.backlightOutput ?? 0);
const [onTime, setOnTime] = useState(extractTimeFromTimestamp(clock?.backlightTurnOnTime));
const [offTime, setOffTime] = useState(extractTimeFromTimestamp(clock?.backlightTurnOffTime));
const [saving, setSaving] = useState(false);
const bellOutputsUsed = (attr?.bellOutputs || []).filter((o) => o > 0);
const clockOutputsUsed = (clock?.clockOutputs || []).filter((o) => o > 0);
const backlightValidationMsg = (() => {
if (!isOn) return null;
if (!output) return "An output must be assigned when backlight automation is enabled.";
if (!onTime || !offTime) return "Both Turn On and Turn Off times must be set when automation is enabled.";
return null;
})();
const handleSave = async () => {
setSaving(true);
try {
await api.put(`/devices/${id}`, {
device_attributes: {
clockSettings: {
isBacklightAutomationOn: isOn,
backlightOutput: output,
backlightTurnOnTime: isOn && onTime ? timeToIso(onTime) : "",
backlightTurnOffTime: isOn && offTime ? timeToIso(offTime) : "",
},
},
});
await onSaved();
onClose();
} finally { setSaving(false); }
};
return (
<SectionModal open={open} title="Edit Backlight Settings" onCancel={onClose} onSave={handleSave} saving={saving} disabled={!!backlightValidationMsg} size="max-w-md">
{backlightValidationMsg && <p className="text-xs mb-3 p-2 rounded" style={{ color: "#f59e0b", backgroundColor: "rgba(245,158,11,0.08)", border: "1px solid rgba(245,158,11,0.2)" }}>{backlightValidationMsg}</p>}
<ModalField label="Automation">
<ToggleSwitch value={isOn} onChange={setIsOn} onLabel="Automated" offLabel="Disabled" />
</ModalField>
<ModalField label="Output">
<select
value={output}
onChange={(e) => setOutput(Number(e.target.value))}
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)" }}
>
<option value={0}>Disabled</option>
{Array.from({ length: maxOutputs }, (_, j) => {
const out = j + 1;
const isBell = bellOutputsUsed.includes(out);
const isClock = clockOutputsUsed.includes(out);
const isConflict = isBell || isClock;
return (
<option key={out} value={out} style={{ color: isConflict ? "var(--danger-text)" : "inherit" }}>
Output {out}{isBell ? " (Bell)" : isClock ? " (Clock)" : ""}
</option>
);
})}
</select>
</ModalField>
{isOn && (
<div className="flex gap-4">
<ModalField label="Turn On Time">
<input type="time" value={onTime} onChange={(e) => setOnTime(e.target.value)} 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)" }} />
</ModalField>
<ModalField label="Turn Off Time">
<input type="time" value={offTime} onChange={(e) => setOffTime(e.target.value)} 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)" }} />
</ModalField>
</div>
)}
</SectionModal>
);
}
function SubscriptionModal({ open, onClose, onSaved, sub, id }) {
const [tier, setTier] = useState(sub?.subscrTier || "basic");
const [startDateStr, setStartDateStr] = useState(() => {
const d = parseFirestoreDate(sub?.subscrStart);
return d ? d.toISOString().split("T")[0] : "";
});
const [duration, setDuration] = useState(sub?.subscrDuration || 0);
const [maxUsers, setMaxUsers] = useState(sub?.maxUsers || 1);
const [maxOutputsVal, setMaxOutputsVal] = useState(sub?.maxOutputs || 1);
const [saving, setSaving] = useState(false);
const addDuration = (days) => setDuration((prev) => Math.max(0, prev + days));
const calcEnd = () => {
if (!startDateStr || !duration) return null;
const d = new Date(startDateStr + "T00:00:00Z");
return new Date(d.getTime() + duration * 86400000);
};
const endDate = calcEnd();
const handleSave = async () => {
setSaving(true);
try {
await api.put(`/devices/${id}`, {
device_subscription: {
subscrTier: tier,
subscrStart: startDateStr ? startDateStr + "T00:00:00Z" : sub?.subscrStart,
subscrDuration: duration,
maxUsers: Number(maxUsers),
maxOutputs: Number(maxOutputsVal),
}
});
await onSaved();
onClose();
} finally { setSaving(false); }
};
return (
<SectionModal open={open} title="Edit Subscription" onCancel={onClose} onSave={handleSave} saving={saving} size="max-w-lg">
<ModalField label="Tier">
<select value={tier} onChange={(e) => setTier(e.target.value)} className="w-full px-3 py-2 rounded-md text-sm border capitalize" style={{ backgroundColor: "var(--bg-input)", borderColor: "var(--border-input)", color: "var(--text-primary)" }}>
{["basic", "small", "mini", "premium", "vip", "custom"].map((t) => (
<option key={t} value={t} className="capitalize">{t.charAt(0).toUpperCase() + t.slice(1)}</option>
))}
</select>
</ModalField>
<ModalField label="Start Date">
<input type="date" value={startDateStr} onChange={(e) => setStartDateStr(e.target.value)} 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)" }} />
</ModalField>
<ModalField label="Duration (days)" hint={endDate ? `End date: ${formatDateNice(endDate)}` : undefined}>
<div className="flex gap-2 items-center mb-2">
<input
type="number"
min={0}
value={duration}
onChange={(e) => setDuration(Math.max(0, parseInt(e.target.value) || 0))}
className="w-32 px-3 py-2 rounded-md text-sm border"
style={{ backgroundColor: "var(--bg-input)", borderColor: "var(--border-input)", color: "var(--text-primary)" }}
/>
<span className="text-xs" style={{ color: "var(--text-muted)" }}>days</span>
</div>
<div className="flex gap-2 flex-wrap">
{[{ label: "+1 Month", days: 30 }, { label: "+6 Months", days: 180 }, { label: "+1 Year", days: 365 }].map((q) => (
<button key={q.label} type="button" onClick={() => addDuration(q.days)} className="px-3 py-1.5 text-xs rounded-md border cursor-pointer hover:opacity-80" style={{ backgroundColor: "var(--bg-input)", borderColor: "var(--border-input)", color: "var(--text-primary)" }}>
{q.label}
</button>
))}
</div>
</ModalField>
<div className="flex gap-4">
<div className="flex-1">
<ModalField label="Max Users">
<select value={maxUsers} onChange={(e) => setMaxUsers(Number(e.target.value))} 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)" }}>
{Array.from({ length: 10 }, (_, i) => i + 1).map((n) => <option key={n} value={n}>{n} {n === 1 ? "User" : "Users"}</option>)}
</select>
</ModalField>
</div>
<div className="flex-1">
<ModalField label="Max Outputs">
<select value={maxOutputsVal} onChange={(e) => setMaxOutputsVal(Number(e.target.value))} 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)" }}>
{Array.from({ length: 16 }, (_, i) => i + 1).map((n) => <option key={n} value={n}>{n} {n === 1 ? "Output" : "Outputs"}</option>)}
</select>
</ModalField>
</div>
</div>
</SectionModal>
);
}
function WarrantyModal({ open, onClose, onSaved, stats, id }) {
const [active, setActive] = useState(stats?.warrantyActive ?? true);
const [startDateStr, setStartDateStr] = useState(() => {
const d = parseFirestoreDate(stats?.warrantyStart);
return d ? d.toISOString().split("T")[0] : "";
});
const [period, setPeriod] = useState(stats?.warrantyPeriod || 0);
const [saving, setSaving] = useState(false);
const addPeriod = (days) => setPeriod((prev) => Math.max(0, prev + days));
const calcExpiry = () => {
if (!startDateStr || !period) return null;
const d = new Date(startDateStr + "T00:00:00Z");
return new Date(d.getTime() + period * 86400000);
};
const expiry = calcExpiry();
const handleSave = async () => {
setSaving(true);
try {
await api.put(`/devices/${id}`, {
device_stats: {
warrantyActive: active,
warrantyStart: startDateStr ? startDateStr + "T00:00:00Z" : stats?.warrantyStart,
warrantyPeriod: period,
}
});
await onSaved();
onClose();
} finally { setSaving(false); }
};
return (
<SectionModal open={open} title="Edit Warranty" onCancel={onClose} onSave={handleSave} saving={saving} size="max-w-md">
<ModalField label="Warranty Status">
<ToggleSwitch value={active} onChange={setActive} onLabel="Active" offLabel="Voided" />
{!active && (
<p className="mt-2 text-xs" style={{ color: "var(--danger-text)" }}>
Warning: Setting this to Voided will mark the warranty as void, even if it has not yet expired.
</p>
)}
</ModalField>
<ModalField label="Start Date">
<input type="date" value={startDateStr} onChange={(e) => setStartDateStr(e.target.value)} 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)" }} />
</ModalField>
<ModalField label="Period (days)" hint={expiry ? `Expiry: ${formatDateNice(expiry)}` : undefined}>
<div className="flex gap-2 items-center mb-2">
<input
type="number"
min={0}
value={period}
onChange={(e) => setPeriod(Math.max(0, parseInt(e.target.value) || 0))}
className="w-32 px-3 py-2 rounded-md text-sm border"
style={{ backgroundColor: "var(--bg-input)", borderColor: "var(--border-input)", color: "var(--text-primary)" }}
/>
<span className="text-xs" style={{ color: "var(--text-muted)" }}>days</span>
</div>
<div className="flex gap-2 flex-wrap">
{[{ label: "+1 Month", days: 30 }, { label: "+6 Months", days: 180 }, { label: "+1 Year", days: 365 }].map((q) => (
<button key={q.label} type="button" onClick={() => addPeriod(q.days)} className="px-3 py-1.5 text-xs rounded-md border cursor-pointer hover:opacity-80" style={{ backgroundColor: "var(--bg-input)", borderColor: "var(--border-input)", color: "var(--text-primary)" }}>
{q.label}
</button>
))}
</div>
</ModalField>
</SectionModal>
);
}
// --- Log level styles ---
const LOG_LEVEL_STYLES = {
INFO: { bg: "var(--badge-blue-bg)", color: "var(--badge-blue-text)" },
WARN: { bg: "#3d2e00", color: "#fbbf24" },
ERROR: { bg: "var(--danger-bg)", color: "var(--danger-text)" },
};
// --- Date / time helpers ---
function parseFirestoreDate(str) {
if (!str) return null;
// Handle Firestore Timestamp objects serialised as {_seconds, _nanoseconds} or {seconds, nanoseconds}
if (typeof str === "object") {
const secs = str._seconds ?? str.seconds;
if (typeof secs === "number") return new Date(secs * 1000);
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) {
if (!str) return "-";
const d = parseFirestoreDate(str);
if (d) {
return `${d.getUTCHours().toString().padStart(2, "0")}:${d.getUTCMinutes().toString().padStart(2, "0")}`;
}
if (/^\d{1,2}:\d{2}/.test(str)) return str;
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`;
}
const UNAVAILABLE_INFO = "Unavailable info";
const LOG_LEVEL_META = {
0: { label: "Disabled", description: "No logs", color: "var(--text-muted)" },
1: { label: "Error", description: "Errors only", color: "var(--danger-text)" },
2: { label: "Warning", description: "Warnings and errors", color: "#f59e0b" },
3: { label: "Info", description: "Info, warnings, and errors", color: "#63b3ed" },
4: { label: "Debug", description: "Debug logs. Really high level (full debugging)", color: "#9f7aea" },
5: { label: "Verbose", description: "Nearly every command gets printed", color: "#20c997" },
};
function hasValue(value) {
if (value === null || value === undefined) return false;
if (typeof value === "string") return value.trim().length > 0;
if (Array.isArray(value)) return value.length > 0;
return true;
}
function formatBoolean(value, trueText, falseText, missingText = UNAVAILABLE_INFO) {
if (typeof value !== "boolean") return missingText;
return value ? trueText : falseText;
}
function formatCount(value, singular, plural = `${singular}s`) {
if (!Number.isFinite(value)) return UNAVAILABLE_INFO;
const count = Number(value);
if (count === 1) return `1 ${singular}`;
return `${count} ${plural}`;
}
const ORDINAL_NAMES = ["First", "Second", "Third", "Fourth", "Fifth", "Sixth", "Seventh", "Eighth", "Ninth", "Tenth"];
function getStrikerSize(ms) {
if (!Number.isFinite(ms)) return null;
if (ms <= 89) return 1;
if (ms <= 94) return 2;
if (ms <= 99) return 3;
if (ms <= 109) return 4;
if (ms <= 119) return 5;
return 6;
}
function formatConnectedBells(value) {
if (!Number.isFinite(value)) return <span className="device-value-secondary">{UNAVAILABLE_INFO}</span>;
const count = Number(value);
return count === 1 ? "1 bell" : `${count} Bells`;
}
function formatOutput(value, noun = "Output") {
if (!Number.isFinite(value)) return UNAVAILABLE_INFO;
return `${noun} ${value}`;
}
function formatBell(value) {
if (!Number.isFinite(value)) return UNAVAILABLE_INFO;
if (Number(value) === 0) return "Disabled";
return `Bell ${value}`;
}
function formatOutputWithDisabledZero(value, noun = "Output") {
if (!Number.isFinite(value)) return UNAVAILABLE_INFO;
if (Number(value) === 0) return "Disabled";
return `${noun} ${value}`;
}
function formatLogLevel(levelRaw) {
if (!Number.isFinite(levelRaw)) return null;
const level = Number(levelRaw);
const meta = LOG_LEVEL_META[level];
if (!meta) return null;
return {
text: `(${level}) ${meta.label}`,
description: meta.description,
color: meta.color,
};
}
function formatLocale(locale) {
if (!hasValue(locale)) return UNAVAILABLE_INFO;
if (locale === "all") return "Global";
if (locale === "orthodox") return "Orthodox";
if (locale === "catholic") return "Catholic";
return locale;
}
function formatMsWithSeconds(ms) {
if (!Number.isFinite(ms)) return UNAVAILABLE_INFO;
const seconds = (Number(ms) / 1000).toFixed(1);
return (
<span>
{seconds} sec{" "}
<span className="device-value-secondary">({ms} ms)</span>
</span>
);
}
function formatDaysVerbose(days) {
if (!Number.isFinite(days) || days <= 0) return null;
if (days >= 30) {
const months = Math.floor(days / 30);
return (
<span>
{months} {months === 1 ? "Month" : "Months"}
<span className="device-value-secondary"> · {days} days</span>
</span>
);
}
return `${days} ${days === 1 ? "Day" : "Days"}`;
}
function formatAnyDateVerbose(value) {
if (!hasValue(value)) return UNAVAILABLE_INFO;
const parsed = parseFirestoreDate(value);
if (parsed) return formatDateNice(parsed);
const fromNative = new Date(value);
if (!isNaN(fromNative.getTime())) return formatDateNice(fromNative);
return UNAVAILABLE_INFO;
}
function formatSilencePeriod(isEnabled, fromValue, toValue) {
if (typeof isEnabled !== "boolean") return UNAVAILABLE_INFO;
if (!isEnabled) return "Disabled";
const hasFrom = hasValue(fromValue);
const hasTo = hasValue(toValue);
if (hasFrom !== hasTo) return "Not Set Correctly";
if (!hasFrom && !hasTo) return "Not Set Correctly";
return (
<span>
From <strong>{formatTimestamp(fromValue)}</strong> to <strong>{formatTimestamp(toValue)}</strong>
</span>
);
}
function formatSilenceRange(isEnabled, fromValue, toValue) {
if (typeof isEnabled !== "boolean") return UNAVAILABLE_INFO;
if (!isEnabled) return "Disabled";
const hasFrom = hasValue(fromValue);
const hasTo = hasValue(toValue);
if (hasFrom !== hasTo) return "Not Set Correctly";
if (!hasFrom && !hasTo) return "Not Set Correctly";
return `${formatTimestamp(fromValue)} - ${formatTimestamp(toValue)}`;
}
function playbackPlaceholderForId(seedValue) {
const seed = String(seedValue || "device");
let hash = 0;
for (let i = 0; i < seed.length; i += 1) {
hash = (hash * 31 + seed.charCodeAt(i)) >>> 0;
}
return 130 + (hash % 121);
}
// --- Coordinates helpers ---
function parseCoordinates(coordStr) {
if (!coordStr) return null;
// Support plain { lat, lng } objects (from API returning GeoPoint as dict)
if (typeof coordStr === "object" && coordStr !== null) {
const lat = parseFloat(coordStr.lat ?? coordStr.latitude);
const lng = parseFloat(coordStr.lng ?? coordStr.longitude);
if (!isNaN(lat) && !isNaN(lng)) return { lat, lng };
return null;
}
const numbers = coordStr.match(/-?\d+(?:\.\d+)?/g);
if (numbers && numbers.length >= 2) {
let lat = parseFloat(numbers[0]);
let lng = parseFloat(numbers[1]);
if (!isNaN(lat) && !isNaN(lng)) {
// Restore sign from direction letters (legacy string format)
if (/\bS\b/.test(coordStr)) lat = -Math.abs(lat);
if (/\bW\b/.test(coordStr)) lng = -Math.abs(lng);
return { lat, lng };
}
}
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}`;
}
function getNearestPlaceLabel(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 || "";
return [name, region, country].filter(Boolean).join(", ");
}
// --- Device Logs Panel ---
function DeviceLogsPanel({ deviceSerial }) {
const [logs, setLogs] = useState([]);
const [loading, setLoading] = useState(false);
const [levelFilter, setLevelFilter] = useState("");
const [searchText, setSearchText] = useState("");
const [autoRefresh, setAutoRefresh] = useState(false);
const [liveLogs, setLiveLogs] = useState([]);
const limit = 25;
const fetchLogs = useCallback(async () => {
if (!deviceSerial) return;
setLoading(true);
try {
const params = new URLSearchParams();
if (levelFilter) params.set("level", levelFilter);
if (searchText) params.set("search", searchText);
params.set("limit", limit.toString());
params.set("offset", "0");
const data = await api.get(`/mqtt/logs/${deviceSerial}?${params}`);
setLogs(data.logs || []);
} catch {
// silently fail in compact view
} finally {
setLoading(false);
}
}, [deviceSerial, levelFilter, searchText]);
useEffect(() => {
if (deviceSerial) fetchLogs();
}, [deviceSerial, fetchLogs]);
useEffect(() => {
if (!autoRefresh || !deviceSerial) return;
const interval = setInterval(fetchLogs, 5000);
return () => clearInterval(interval);
}, [autoRefresh, deviceSerial, fetchLogs]);
const handleWsMessage = useCallback(
(data) => {
if (data.type === "logs" && data.device_serial === deviceSerial) {
const logEntry = {
id: Date.now(),
level: data.payload?.level?.includes("EROR") ? "ERROR" : data.payload?.level?.includes("WARN") ? "WARN" : "INFO",
message: data.payload?.message || "",
received_at: new Date().toISOString(),
_live: true,
};
setLiveLogs((prev) => [logEntry, ...prev].slice(0, 50));
}
},
[deviceSerial]
);
useMqttWebSocket({ enabled: true, onMessage: handleWsMessage });
const allLogs = [...liveLogs.filter((l) => {
if (levelFilter && l.level !== levelFilter) return false;
if (searchText && !l.message.toLowerCase().includes(searchText.toLowerCase())) return false;
return true;
}), ...logs];
return (
<SectionCard title="Latest Logs">
{/* Controls */}
<div style={{ display: "flex", flexWrap: "wrap", gap: "0.6rem", alignItems: "center", marginBottom: "1rem" }}>
<select
value={levelFilter}
onChange={(e) => setLevelFilter(e.target.value)}
className="px-2 rounded-md text-xs border"
style={{ height: 30, backgroundColor: "var(--bg-primary)", color: "var(--text-primary)", borderColor: "var(--border-primary)" }}
>
<option value="">All Levels</option>
<option value="INFO">INFO</option>
<option value="WARN">WARN</option>
<option value="ERROR">ERROR</option>
</select>
<input
type="text"
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
placeholder="Search log messages..."
className="flex-1 min-w-32 px-2 rounded-md text-xs border"
style={{ height: 30, minWidth: 120, backgroundColor: "var(--bg-primary)", color: "var(--text-primary)", borderColor: "var(--border-primary)" }}
/>
<ToggleSwitch value={autoRefresh} onChange={setAutoRefresh} onLabel="Auto-refresh" offLabel="Auto-refresh" />
<button
onClick={fetchLogs}
className="px-2.5 rounded-md text-xs border hover:opacity-80 cursor-pointer"
style={{ height: 30, borderColor: "var(--badge-blue-bg)", backgroundColor: "var(--badge-blue-bg)", color: "var(--badge-blue-text)" }}
>
Refresh
</button>
</div>
{/* Log table */}
{loading && logs.length === 0 ? (
<p className="text-xs" style={{ color: "var(--text-muted)" }}>Loading...</p>
) : allLogs.length === 0 ? (
<p className="text-xs" style={{ color: "var(--text-muted)" }}>No logs found.</p>
) : (
<div className="rounded-md overflow-hidden border" style={{ borderColor: "var(--border-primary)" }}>
<div className="overflow-x-auto" style={{ maxHeight: 220, overflowY: "auto" }}>
<table className="w-full text-xs">
<thead style={{ position: "sticky", top: 0, zIndex: 1 }}>
<tr style={{ backgroundColor: "var(--bg-primary)", borderBottom: "1px solid var(--border-primary)" }}>
<th className="px-3 py-2 text-left font-medium w-40" style={{ color: "var(--text-secondary)" }}>Time</th>
<th className="px-3 py-2 text-left font-medium w-16" style={{ color: "var(--text-secondary)" }}>Level</th>
<th className="px-3 py-2 text-left font-medium" style={{ color: "var(--text-secondary)" }}>Message</th>
</tr>
</thead>
<tbody>
{allLogs.slice(0, limit).map((log, index) => {
const style = LOG_LEVEL_STYLES[log.level] || LOG_LEVEL_STYLES.INFO;
return (
<tr
key={log._live ? `live-${log.id}` : log.id}
style={{
borderBottom: index < Math.min(allLogs.length, limit) - 1 ? "1px solid var(--border-primary)" : "none",
backgroundColor: log._live ? "rgba(116, 184, 22, 0.05)" : "transparent",
}}
>
<td className="px-3 py-2 font-mono" style={{ color: "var(--text-muted)" }}>
{log.received_at?.replace("T", " ").substring(0, 19)}
{log._live && <span className="ml-1" style={{ color: "var(--accent)" }}>LIVE</span>}
</td>
<td className="px-3 py-2">
<span className="px-1.5 py-0.5 text-xs rounded-full" style={{ backgroundColor: style.bg, color: style.color }}>
{log.level}
</span>
</td>
<td className="px-3 py-2 font-mono" style={{ color: "var(--text-primary)" }}>
{log.message}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
</SectionCard>
);
}
// --- Main component ---
const TAB_DEFS = [
{ id: "dashboard", label: "Dashboard", tone: "dashboard" },
{ id: "general", label: "General Information", tone: "general" },
{ id: "bells", label: "Bell Mechanisms", tone: "bells" },
{ id: "clock", label: "Clock & Alerts", tone: "clock" },
{ id: "warranty", label: "Warranty & Subscription", tone: "warranty" },
{ id: "manage", label: "Manage", tone: "manage" },
{ id: "control", label: "Control", tone: "control" },
];
function calcPeriodProgress(start, end) {
if (!start || !end) return null;
const total = end.getTime() - start.getTime();
if (total <= 0) return null;
const elapsed = Date.now() - start.getTime();
return Math.max(0, Math.min(100, (elapsed / total) * 100));
}
function calcMaintenanceProgress(lastDate, periodDays) {
if (!lastDate || !periodDays) return null;
const total = periodDays * 86400000;
const elapsed = Date.now() - lastDate.getTime();
return Math.max(0, Math.min(100, (elapsed / total) * 100));
}
// ─── Customer Assign Modal ────────────────────────────────────────────────────
function CustomerAssignModal({ deviceId, onSelect, onCancel }) {
const [query, setQuery] = useState("");
const [results, setResults] = useState([]);
const [searching, setSearching] = useState(false);
const inputRef = useRef(null);
useEffect(() => { inputRef.current?.focus(); }, []);
const search = useCallback(async (q) => {
setSearching(true);
try {
const data = await api.get(`/devices/${deviceId}/customer-search?q=${encodeURIComponent(q)}`);
setResults(data.results || []);
} catch {
setResults([]);
} finally {
setSearching(false);
}
}, [deviceId]);
useEffect(() => {
const t = setTimeout(() => search(query), 250);
return () => clearTimeout(t);
}, [query, search]);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" style={{ backgroundColor: "rgba(0,0,0,0.6)" }}>
<div
className="rounded-xl border w-full max-w-lg flex flex-col"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)", maxHeight: "80vh" }}
>
<div className="flex items-center justify-between px-6 pt-5 pb-4 border-b" style={{ borderColor: "var(--border-secondary)" }}>
<h3 className="text-base font-semibold" style={{ color: "var(--text-heading)" }}>Assign to Customer</h3>
<button type="button" onClick={onCancel} className="text-lg leading-none hover:opacity-70 cursor-pointer" style={{ color: "var(--text-muted)" }}>&#x2715;</button>
</div>
<div className="px-6 pt-4 pb-2">
<div style={{ position: "relative" }}>
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search by name, email, phone, org, tags…"
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)" }}
/>
{searching && (
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-xs" style={{ color: "var(--text-muted)" }}></span>
)}
</div>
</div>
<div className="overflow-y-auto flex-1 px-6 pb-4">
<div className="rounded-md border overflow-hidden" style={{ borderColor: "var(--border-secondary)", minHeight: 60 }}>
{results.length === 0 ? (
<p className="px-3 py-3 text-sm" style={{ color: "var(--text-muted)" }}>
{searching ? "Searching…" : query ? "No customers found." : "Type to search customers…"}
</p>
) : (
results.map((c) => (
<button
key={c.id}
type="button"
onClick={() => onSelect(c)}
className="w-full text-left px-3 py-2.5 text-sm hover:opacity-80 cursor-pointer border-b last:border-b-0 transition-colors"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-secondary)", color: "var(--text-primary)" }}
>
<span className="font-medium block">
{[c.name, c.surname].filter(Boolean).join(" ")}
{c.city && (
<>
<span className="mx-1.5" style={{ color: "var(--text-muted)", fontSize: "8px", verticalAlign: "middle" }}></span>
<span style={{ color: "var(--text-muted)", fontWeight: 400 }}>{c.city}</span>
</>
)}
</span>
{c.organization && (
<span className="text-xs block" style={{ color: "var(--text-muted)" }}>{c.organization}</span>
)}
</button>
))
)}
</div>
</div>
<div className="flex justify-end px-6 py-4 border-t" style={{ borderColor: "var(--border-secondary)" }}>
<button
onClick={onCancel}
className="px-4 py-1.5 text-sm rounded-md hover:opacity-80 cursor-pointer"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)" }}
>
Cancel
</button>
</div>
</div>
</div>
);
}
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 [activeTab, setActiveTab] = useState("dashboard");
const [deviceUsers, setDeviceUsers] = useState([]);
const [staffNotes, setStaffNotes] = useState("");
const [editingNotes, setEditingNotes] = useState(false);
const [savingNotes, setSavingNotes] = useState(false);
const notesRef = useRef(null);
const [usersLoading, setUsersLoading] = useState(false);
const [unresolvedIssues, setUnresolvedIssues] = useState(0);
const [notesPanelTab, setNotesPanelTab] = useState(null);
const notesPanelRef = useRef(null);
const [liveStrikeCounters, setLiveStrikeCounters] = useState(null);
const [requestingStrikeCounters, setRequestingStrikeCounters] = useState(false);
const lastStrikeRequestAtRef = useRef(0);
const [hwProduct, setHwProduct] = useState(null);
// --- Section edit modal open/close state ---
const [editingLocation, setEditingLocation] = useState(false);
const [editingAttributes, setEditingAttributes] = useState(false);
const [editingLogging, setEditingLogging] = useState(false);
const [editingMisc, setEditingMisc] = useState(false);
const [editingBellOutputs, setEditingBellOutputs] = useState(false);
const [editingClockSettings, setEditingClockSettings] = useState(false);
const [editingAlerts, setEditingAlerts] = useState(false);
const [editingBacklight, setEditingBacklight] = useState(false);
const [editingSubscription, setEditingSubscription] = useState(false);
const [editingWarranty, setEditingWarranty] = useState(false);
const [qrTarget, setQrTarget] = useState(null);
useEffect(() => {
loadData();
}, [id]);
useEffect(() => {
setLiveStrikeCounters(null);
setRequestingStrikeCounters(false);
lastStrikeRequestAtRef.current = 0;
}, [id]);
const loadData = async () => {
setLoading(true);
try {
// Phase 1: load device from DB immediately — renders page without waiting for MQTT
const d = await api.get(`/devices/${id}`);
setDevice(d);
if (d.staffNotes) setStaffNotes(d.staffNotes);
if (Array.isArray(d.tags)) setTags(d.tags);
setLoading(false);
// Phase 2: fire async background fetches — do not block the render
const deviceSN = d.serial_number || d.device_id;
if (deviceSN) {
api.get("/mqtt/status").then((mqttData) => {
if (mqttData?.devices) {
const match = mqttData.devices.find((s) => s.device_serial === deviceSN);
setMqttStatus(match || null);
}
}).catch(() => {});
}
// Fetch owner customer details
if (d.customer_id) {
api.get(`/devices/${id}/customer`).then((res) => {
setOwnerCustomer(res.customer || null);
}).catch(() => setOwnerCustomer(null));
} else {
setOwnerCustomer(null);
}
setUsersLoading(true);
api.get(`/devices/${id}/users`).then((data) => {
setDeviceUsers(data.users || []);
}).catch(() => {
setDeviceUsers([]);
}).finally(() => setUsersLoading(false));
// Fetch manufacturing record + product catalog to resolve hw image
if (deviceSN) {
Promise.all([
api.get(`/manufacturing/devices/${deviceSN}`).catch(() => null),
api.get("/crm/products").catch(() => null),
]).then(([mfgItem, productsRes]) => {
const hwType = mfgItem?.hw_type || "";
if (!hwType) return;
const products = productsRes?.products || [];
const norm = (s) => (s || "").toLowerCase().replace(/[^a-z0-9]/g, "");
const normHw = norm(hwType);
const match = products.find(
(p) => norm(p.name) === normHw || norm(p.sku) === normHw ||
norm(p.name).includes(normHw) || normHw.includes(norm(p.name))
);
if (match) setHwProduct(match);
}).catch(() => {});
}
api.get(`/equipment/notes?device_id=${id}`).then((data) => {
const issues = (data.notes || []).filter(
(n) => (n.category === "issue" || n.category === "action_item") && n.status !== "completed"
);
setUnresolvedIssues(issues.length);
}).catch(() => setUnresolvedIssues(0));
const geoCoords = parseCoordinates(d.device_location_coordinates);
if (geoCoords) {
const nominatimUrl = `https://nominatim.openstreetmap.org/reverse?lat=${geoCoords.lat}&lon=${geoCoords.lng}&format=json&zoom=10&addressdetails=1`;
const mapsCoUrl = `https://geocode.maps.co/reverse?lat=${geoCoords.lat}&lon=${geoCoords.lng}`;
fetch(nominatimUrl, { headers: { Accept: "application/json", "Accept-Language": "en" } })
.then((r) => (r.ok ? r.json() : Promise.reject(new Error("nominatim failed"))))
.then((data) => setLocationName(getNearestPlaceLabel(data) || null))
.catch(() => {
fetch(mapsCoUrl)
.then((r) => (r.ok ? r.json() : Promise.reject(new Error("maps.co failed"))))
.then((data) => setLocationName(getNearestPlaceLabel(data) || null))
.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);
}
};
const saveStaffNotes = async (value) => {
setSavingNotes(true);
try {
await api.put(`/devices/${id}`, { staffNotes: value });
setStaffNotes(value);
} catch {
// silently fail
} finally {
setSavingNotes(false);
setEditingNotes(false);
}
};
// --- Device Notes handlers ---
const handleAddNote = async () => {
if (!newNoteText.trim()) return;
setSavingNote(true);
try {
const data = await api.post(`/devices/${id}/notes`, {
content: newNoteText.trim(),
created_by: "admin",
});
setDeviceNotes((prev) => [data, ...prev]);
setNewNoteText("");
setAddingNote(false);
} catch {} finally { setSavingNote(false); }
};
const handleUpdateNote = async (noteId) => {
if (!editingNoteText.trim()) return;
setSavingNote(true);
try {
const data = await api.put(`/devices/${id}/notes/${noteId}`, { content: editingNoteText.trim() });
setDeviceNotes((prev) => prev.map((n) => n.id === noteId ? data : n));
setEditingNoteId(null);
} catch {} finally { setSavingNote(false); }
};
const handleDeleteNote = async (noteId) => {
if (!window.confirm("Delete this note?")) return;
try {
await api.delete(`/devices/${id}/notes/${noteId}`);
setDeviceNotes((prev) => prev.filter((n) => n.id !== noteId));
} catch {}
};
// --- Tags handlers ---
const handleAddTag = async (tag) => {
const trimmed = tag.trim();
if (!trimmed || tags.includes(trimmed)) return;
const next = [...tags, trimmed];
setSavingTags(true);
try {
await api.put(`/devices/${id}/tags`, { tags: next });
setTags(next);
setTagInput("");
} catch {} finally { setSavingTags(false); }
};
const handleRemoveTag = async (tag) => {
const next = tags.filter((t) => t !== tag);
setSavingTags(true);
try {
await api.put(`/devices/${id}/tags`, { tags: next });
setTags(next);
} catch {} finally { setSavingTags(false); }
};
// --- Customer assign handlers ---
const handleAssignCustomer = async (customer) => {
setAssigningCustomer(true);
try {
await api.post(`/devices/${id}/assign-customer`, { customer_id: customer.id });
setDevice((prev) => ({ ...prev, customer_id: customer.id }));
setOwnerCustomer(customer);
setShowAssignSearch(false);
setCustomerSearch("");
setCustomerResults([]);
} catch {} finally { setAssigningCustomer(false); }
};
const handleUnassignCustomer = async () => {
if (!window.confirm("Remove customer assignment?")) return;
setAssigningCustomer(true);
try {
const cid = device?.customer_id;
await api.delete(`/devices/${id}/assign-customer${cid ? `?customer_id=${cid}` : ""}`);
setDevice((prev) => ({ ...prev, customer_id: "" }));
setOwnerCustomer(null);
} catch {} finally { setAssigningCustomer(false); }
};
const requestStrikeCounters = useCallback(async (force = false) => {
if (!device?.device_id) return;
const now = Date.now();
if (!force && now - lastStrikeRequestAtRef.current < 10000) return;
lastStrikeRequestAtRef.current = now;
setRequestingStrikeCounters(true);
try {
await api.post(`/mqtt/command/${device.device_id}`, {
cmd: "system_info",
contents: {
action: "report_status",
},
});
} catch {
setRequestingStrikeCounters(false);
}
}, [device?.device_id]);
const handleDeviceMqttMessage = useCallback((data) => {
if (!device?.device_id) return;
if (data?.type !== "data" || data?.device_serial !== device.device_id) return;
const counters = data?.payload?.payload?.strike_counters;
if (Array.isArray(counters)) {
setLiveStrikeCounters(counters);
setRequestingStrikeCounters(false);
}
}, [device?.device_id]);
useMqttWebSocket({ enabled: true, onMessage: handleDeviceMqttMessage });
useEffect(() => {
if (activeTab === "bells" || activeTab === "warranty") {
requestStrikeCounters(false);
}
}, [activeTab, requestStrikeCounters]);
// --- Control tab state (MUST be before early returns) ---
const [ctrlFirmwareChannel, setCtrlFirmwareChannel] = useState("stable");
const [ctrlHttpEnabled, setCtrlHttpEnabled] = useState(true);
const [ctrlCustomCmd, setCtrlCustomCmd] = useState("");
const [ctrlCustomResponse, setCtrlCustomResponse] = useState("");
const [ctrlMelodies, setCtrlMelodies] = useState([]);
const [ctrlMelodiesLoading, setCtrlMelodiesLoading] = useState(false);
const [ctrlDeviceTime, setCtrlDeviceTime] = useState(() => {
const now = new Date();
return now.toISOString().slice(0, 16);
});
const [ctrlTimezone, setCtrlTimezone] = useState("UTC");
const [ctrlDst, setCtrlDst] = useState(false);
const [ctrlCmdHistory, setCtrlCmdHistory] = useState([]);
const [ctrlCmdHistoryLoading, setCtrlCmdHistoryLoading] = useState(false);
const [ctrlExpandedCmd, setCtrlExpandedCmd] = useState(null);
const [ctrlCmdAutoRefresh, setCtrlCmdAutoRefresh] = useState(false);
const [ctrlCustomHeight, setCtrlCustomHeight] = useState(160);
const fetchCtrlCmdHistory = useCallback(async () => {
if (!device?.device_id) return;
setCtrlCmdHistoryLoading(true);
try {
const data = await api.get(`/mqtt/commands/${device.device_id}?limit=50`);
setCtrlCmdHistory(data.commands || []);
} catch {
// silently fail
} finally {
setCtrlCmdHistoryLoading(false);
}
}, [device?.device_id]);
const fetchCtrlMelodies = useCallback(async () => {
setCtrlMelodiesLoading(true);
setCtrlMelodies([]);
// Placeholder: will send MQTT list_melodies command
setTimeout(() => setCtrlMelodiesLoading(false), 500);
}, []);
useEffect(() => {
if (!ctrlCmdAutoRefresh || !device?.device_id) return;
const interval = setInterval(fetchCtrlCmdHistory, 5000);
return () => clearInterval(interval);
}, [ctrlCmdAutoRefresh, device?.device_id, fetchCtrlCmdHistory]);
// --- Device Notes state (MUST be before early returns) ---
const [deviceNotes, setDeviceNotes] = useState([]);
const [notesLoaded, setNotesLoaded] = useState(false);
const [addingNote, setAddingNote] = useState(false);
const [newNoteText, setNewNoteText] = useState("");
const [savingNote, setSavingNote] = useState(false);
const [editingNoteId, setEditingNoteId] = useState(null);
const [editingNoteText, setEditingNoteText] = useState("");
const loadDeviceNotes = useCallback(async () => {
try {
const data = await api.get(`/devices/${id}/notes`);
setDeviceNotes(data.notes || []);
setNotesLoaded(true);
} catch {
setNotesLoaded(true);
}
}, [id]);
useEffect(() => {
if (id) loadDeviceNotes();
}, [id, loadDeviceNotes]);
// --- Tags state (MUST be before early returns) ---
const [tags, setTags] = useState([]);
const [tagInput, setTagInput] = useState("");
const [savingTags, setSavingTags] = useState(false);
// --- Customer assign state (MUST be before early returns) ---
const [assigningCustomer, setAssigningCustomer] = useState(false);
const [showAssignSearch, setShowAssignSearch] = useState(false);
const [ownerCustomer, setOwnerCustomer] = useState(null);
// --- User assignment state (MUST be before early returns) ---
const [showUserSearch, setShowUserSearch] = useState(false);
const [userSearchQuery, setUserSearchQuery] = useState("");
const [userSearchResults, setUserSearchResults] = useState([]);
const [userSearching, setUserSearching] = useState(false);
const [addingUser, setAddingUser] = useState(null);
const [removingUser, setRemovingUser] = useState(null);
const userSearchInputRef = useRef(null);
const searchUsers = useCallback(async (q) => {
setUserSearching(true);
try {
const data = await api.get(`/devices/${id}/user-search?q=${encodeURIComponent(q)}`);
setUserSearchResults(data.results || []);
} catch {
setUserSearchResults([]);
} finally {
setUserSearching(false);
}
}, [id]);
useEffect(() => {
if (!showUserSearch) return;
const t = setTimeout(() => searchUsers(userSearchQuery), 250);
return () => clearTimeout(t);
}, [userSearchQuery, searchUsers, showUserSearch]);
if (loading) return <div className="text-center py-8" style={{ color: "var(--text-muted)" }}>Loading...</div>;
if (error) return (
<div className="text-sm rounded-md p-3 border" style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}>
{error}
</div>
);
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;
const subscrStart = parseFirestoreDate(sub.subscrStart);
const subscrEnd = subscrStart && sub.subscrDuration ? addDays(subscrStart, sub.subscrDuration) : null;
const subscrDaysLeft = subscrEnd ? daysUntil(subscrEnd) : null;
const subscrProgress = calcPeriodProgress(subscrStart, subscrEnd);
const warrantyStart = parseFirestoreDate(stats.warrantyStart);
const warrantyEnd = warrantyStart && stats.warrantyPeriod ? addDays(warrantyStart, stats.warrantyPeriod) : null;
const warrantyDaysLeft = warrantyEnd ? daysUntil(warrantyEnd) : null;
const warrantyProgress = calcPeriodProgress(warrantyStart, warrantyEnd);
const maintainedOn = parseFirestoreDate(stats.maintainedOn);
const nextMaintenance = maintainedOn && stats.maintainancePeriod ? addDays(maintainedOn, stats.maintainancePeriod) : null;
const maintenanceDaysLeft = nextMaintenance ? daysUntil(nextMaintenance) : null;
const maintenanceProgress = calcMaintenanceProgress(maintainedOn, stats.maintainancePeriod);
const staticIpAddress = Array.isArray(net.ipAddress) ? net.ipAddress.filter(Boolean).join(".") : "";
const hammerStrikesFromLive = Array.isArray(liveStrikeCounters)
? liveStrikeCounters.reduce((sum, n) => (Number.isFinite(n) ? sum + n : sum), 0)
: null;
const randomPlaybacks = playbackPlaceholderForId(id || device.device_id || device.id);
const hwVariant = hwProduct?.name || "VesperPlus";
const hwImage = hwProduct?.photo_url ? `/api${hwProduct.photo_url}` : "/devices/VesperPlus.png";
const locationCard = (
<section className="device-section-card">
{coords ? (
<div className="device-location-grid">
<div>
<div className="device-section-card__title-row">
<h2 className="device-section-card__title">Device Location</h2>
{canEdit && (
<button type="button" onClick={() => setEditingLocation(true)} className="section-edit-btn" title="Edit Device Location">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" width="13" height="13" aria-hidden="true">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
Edit
</button>
)}
</div>
<FieldRow columns={1}>
<Field label="Location Name">{device.device_location}</Field>
<Field label="Coordinates">
<div className="flex flex-wrap items-center gap-2">
<span>{formatCoordinates(coords)}</span>
<a
href={`https://www.google.com/maps?q=${coords.lat},${coords.lng}`}
target="_blank"
rel="noopener noreferrer"
className="px-2 py-0.5 text-xs rounded-md inline-block"
style={{ backgroundColor: "var(--badge-blue-bg)", color: "var(--badge-blue-text)" }}
>
Open in Maps
</a>
</div>
</Field>
<Field label="Nearest Place">{locationName || "-"}</Field>
</FieldRow>
</div>
<div style={{ padding: "0 0 0 0", display: "flex", flex: 1 }}>
<div className="device-map-wrap rounded-md overflow-hidden border" style={{ boxShadow: "0 4px 24px rgba(0,0,0,0.4)", borderColor: "var(--border-primary)", flex: 1, minHeight: 260 }}>
<MapContainer center={[coords.lat, coords.lng]} zoom={13} style={{ height: "100%", width: "100%" }} scrollWheelZoom={false}>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
<Marker position={[coords.lat, coords.lng]} />
</MapContainer>
</div>
</div>
</div>
) : (
<div>
<div className="device-section-card__title-row">
<h2 className="device-section-card__title">Device Location</h2>
{canEdit && (
<button type="button" onClick={() => setEditingLocation(true)} className="section-edit-btn" title="Edit Device Location">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" width="13" height="13" aria-hidden="true">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
Edit
</button>
)}
</div>
<FieldRow columns={1}>
<Field label="Location Name">{device.device_location}</Field>
<Field label="Coordinates">{device.device_location_coordinates || "-"}</Field>
</FieldRow>
</div>
)}
</section>
);
// Warranty status label — Void takes priority over period calculation
const warrantyIsVoid = stats.warrantyActive === false;
const warrantyStatusLabel = warrantyIsVoid
? "Warranty Void"
: warrantyDaysLeft !== null
? (warrantyDaysLeft > 0 ? "Currently Active" : "Expired")
: (stats.warrantyActive ? "Currently Active" : "Expired");
const warrantyStatusColor = warrantyIsVoid
? "#f59e0b"
: (warrantyDaysLeft === null ? stats.warrantyActive : warrantyDaysLeft > 0)
? "var(--success-text)"
: "var(--danger-text)";
// Compact subscription duration — no days, clean units only
function subscrDurationLabel(days) {
if (!days) return null;
if (days % 365 === 0) return `${days / 365} year${days / 365 > 1 ? "s" : ""}`;
if (days % 30 === 0) return `${days / 30} month${days / 30 > 1 ? "s" : ""}`;
return `${days} days`;
}
const dashboardTab = (
<div className="device-tab-stack">
{/* ── Hero row: main card + notes card side-by-side ─────────────── */}
<div className="db-hero-row">
{/* ── Main info card ── */}
<section className="db-hero-card">
{/* 1. Product photo */}
<div className={`db-hero-photo${isOnline ? " db-hero-photo--online" : " db-hero-photo--offline"}`}>
<img src={hwImage} alt={hwVariant} className="db-hero-photo__img" />
</div>
<div className="db-hero-divider" />
{/* 2. Main info — Serial / Hardware / Location */}
<div className="db-hero-section">
<div className="db-row">
<div className="db-info-field">
<span className="db-info-label">SERIAL NUMBER</span>
<span className="db-info-value" style={{ display: "inline-flex", alignItems: "center", gap: 8 }}>
{device.serial_number || device.device_id || "-"}
{(device.serial_number || device.device_id) && (
<button
type="button"
title="Show QR Code"
onClick={() => setQrTarget(device.serial_number || device.device_id)}
className="cursor-pointer hover:opacity-70 transition-opacity"
style={{ color: "var(--text-muted)", background: "none", border: "none", padding: 0, lineHeight: 1 }}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/>
<path d="M14 14h.01M14 18h.01M18 14h.01M18 18h.01M21 14h.01M21 21h.01M14 21h.01"/>
</svg>
</button>
)}
</span>
</div>
</div>
<div className="db-row">
<div className="db-info-field">
<span className="db-info-label">HARDWARE VARIANT</span>
<span className="db-info-value">{hwVariant}</span>
</div>
</div>
<div className="db-row">
<div className="db-info-field">
<span className="db-info-label">LOCATION</span>
<span className="db-info-value">{device.device_location || "-"}</span>
</div>
</div>
</div>
<div className="db-hero-divider" />
{/* 3. Subscription / Warranty */}
<div className="db-hero-section db-hero-section--sub">
{/* Row 1: Tier left, Warranty Status right */}
<div className="db-row db-sub-header-row">
<div className="db-info-field">
<span className="db-info-label">SUBSCRIPTION TIER</span>
<span className="db-info-value">
<span style={{ color: "var(--badge-blue-text)" }}>{sub.subscrTier || "-"}</span>
{sub.subscrDuration ? (
<><span style={{ color: "var(--text-muted)", margin: "0 0.35rem" }}>·</span>
<span style={{ color: "var(--text-secondary)" }}>{subscrDurationLabel(sub.subscrDuration)}</span></>
) : null}
</span>
</div>
<div className="db-info-field">
<span className="db-info-label">WARRANTY STATUS</span>
<span className="db-info-value" style={{ color: warrantyStatusColor }}>{warrantyStatusLabel}</span>
</div>
</div>
{/* Row 2: Subscription progress */}
<div className="db-row">
<div className="db-info-field">
<span className="db-info-label">SUBSCRIPTION PROGRESS</span>
<div className="db-progress-track">
<div className="db-progress-fill" style={{ width: `${subscrProgress ?? 0}%`, backgroundColor: "var(--accent)" }} />
</div>
</div>
</div>
{/* Row 3: Warranty progress */}
<div className="db-row">
<div className="db-info-field">
<span className="db-info-label">WARRANTY PROGRESS</span>
<div className="db-progress-track">
<div className="db-progress-fill" style={{
width: `${warrantyProgress ?? 0}%`,
backgroundColor: (warrantyDaysLeft === null ? stats.warrantyActive : warrantyDaysLeft > 0) ? "#B86716" : "var(--danger)"
}} />
</div>
</div>
</div>
</div>
<div className="db-hero-divider" />
{/* 4. Uptime / Latest Issue — 2 items; sits in rows 1 and 2, row 3 empty */}
<div className="db-hero-section db-hero-section--uptime">
{/* Row 1: Device Uptime */}
<div className="db-row">
<div className="db-info-field">
<span className="db-info-label">DEVICE UPTIME</span>
<span className="db-info-value">
{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";
})()
: <span style={{ color: "var(--text-muted)" }}>Unavailable</span>}
</span>
</div>
</div>
{/* Row 2: Latest Device Issue */}
<div className="db-row">
<div className="db-uptime-issue">
<span className="db-info-label">LATEST DEVICE ISSUE</span>
{mqttStatus?.last_warn_message ? (
<>
<span className="db-issue-source">MQTT · WARN · Health Monitor</span>
<span className="db-issue-body">{mqttStatus.last_warn_message}</span>
</>
) : (
<span className="db-info-value" style={{ color: "var(--text-muted)" }}>No recent issues</span>
)}
</div>
</div>
</div>
</section>
{/* ── Admin Quick Notes card ── */}
<section className="db-notes-card">
<div className="db-notes-header">
<span className="db-info-label">ADMIN QUICK NOTES</span>
{!editingNotes && (
<button
onClick={() => setEditingNotes(true)}
className="db-notes-edit-btn"
type="button"
title="Edit notes"
>
</button>
)}
</div>
{editingNotes ? (
<div style={{ display: "flex", flexDirection: "column", gap: "0.5rem", flex: 1 }}>
<textarea
ref={notesRef}
defaultValue={staffNotes}
autoFocus
className="db-notes-textarea"
style={{ flex: 1 }}
onKeyDown={(e) => {
if (e.key === "Escape") setEditingNotes(false);
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) saveStaffNotes(e.target.value);
}}
/>
<div style={{ display: "flex", gap: "0.5rem", justifyContent: "flex-end" }}>
<button
onClick={() => saveStaffNotes(notesRef.current?.value ?? "")}
disabled={savingNotes}
className="px-2 py-1 text-xs rounded-md"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
type="button"
>
{savingNotes ? "Saving..." : "Save"}
</button>
<button
onClick={() => setEditingNotes(false)}
className="px-2 py-1 text-xs rounded-md border"
style={{ borderColor: "var(--border-primary)", color: "var(--text-secondary)" }}
type="button"
>
Cancel
</button>
</div>
</div>
) : (
<p
className="db-notes-body"
onClick={() => setEditingNotes(true)}
style={{ color: staffNotes ? "var(--text-secondary)" : "var(--text-muted)" }}
>
{staffNotes || "Add a quick note to help identify this device…"}
</p>
)}
</section>
</div>
<DeviceLogsPanel deviceSerial={device.serial_number || device.device_id} />
<div className="dashboard-bottom-grid">
<div className="dashboard-bottom-grid__notes" ref={notesPanelRef}>
<NotesPanel key={notesPanelTab || "all"} deviceId={id} initialTab={notesPanelTab} />
</div>
<div className="dashboard-bottom-grid__users">
<SectionCard title={`App Users (${deviceUsers.length})`}>
{usersLoading ? (
<p className="text-sm" style={{ color: "var(--text-muted)" }}>Loading users...</p>
) : deviceUsers.length === 0 ? (
<p className="text-sm" style={{ color: "var(--text-muted)" }}>No users assigned to this device.</p>
) : (
<div className="space-y-2">
{deviceUsers.map((user, i) => (
<div
key={user.user_id || i}
className="p-3 rounded-md border cursor-pointer hover:opacity-90 transition-colors"
style={{ backgroundColor: "var(--bg-primary)", borderColor: "var(--border-primary)" }}
onClick={() => user.user_id && navigate(`/users/${user.user_id}`)}
>
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3 min-w-0">
<div
className="w-8 h-8 rounded-full overflow-hidden shrink-0"
style={{ backgroundColor: "var(--bg-card-hover)" }}
>
{user.photo_url ? (
<img src={user.photo_url} alt="" style={{ width: "100%", height: "100%", objectFit: "cover" }} />
) : (
<div className="w-full h-full flex items-center justify-center text-xs font-bold" style={{ color: "var(--text-muted)" }}>
{(user.display_name || user.email || "?").charAt(0).toUpperCase()}
</div>
)}
</div>
<div className="min-w-0">
<p className="text-sm font-medium truncate" style={{ color: "var(--text-heading)" }}>
{user.display_name || user.email || "Unknown User"}
</p>
{user.email && user.display_name && (
<p className="text-xs truncate" style={{ color: "var(--text-muted)", opacity: 0.7 }}>{user.email}</p>
)}
</div>
</div>
{user.role && (
<span className="px-2 py-0.5 text-xs rounded-full capitalize shrink-0" style={{ backgroundColor: "var(--badge-blue-bg)", color: "var(--badge-blue-text)" }}>
{user.role}
</span>
)}
</div>
</div>
))}
</div>
)}
</SectionCard>
</div>
</div>
</div>
);
const generalInfoTab = (
<div className="device-tab-stack">
<div className="device-tab-grid">
{locationCard}
<SectionCard title="Basic Attributes" onEdit={canEdit ? () => setEditingAttributes(true) : undefined}>
<FieldRow columns={3}>
<Field label="Bell Guard">
<BoolBadge value={attr.bellGuardOn} yesLabel="Operational" noLabel="Disabled" />
</Field>
<Field label="Warnings">
<BoolBadge value={attr.warningsOn} yesLabel="Active" noLabel="Disabled" />
</Field>
<Field label="Bell Guard Safety">
<BoolBadge value={attr.bellGuardSafetyOn} yesLabel="Armed" noLabel="Disarmed" />
</Field>
</FieldRow>
<FieldRow columns={3}>
<Field label="Has Bells">
<BoolBadge value={attr.hasBells} yesLabel="Yes" noLabel="No" />
</Field>
<Field label="Has Clock">
<BoolBadge value={attr.hasClock} yesLabel="Yes" noLabel="No" />
</Field>
<Field label="Connected Bells">
{formatConnectedBells(attr.totalBells)}
</Field>
</FieldRow>
</SectionCard>
<SectionCard title="Logging" onEdit={canEdit ? () => setEditingLogging(true) : undefined}>
<FieldRow columns={3}>
<Field label="Serial Log Level">
{(() => {
const info = formatLogLevel(attr.serialLogLevel);
if (!info) return UNAVAILABLE_INFO;
return <span title={info.description} style={{ color: info.color }}>{info.text}</span>;
})()}
</Field>
<Field label="SD Log Level">
{(() => {
const info = formatLogLevel(attr.sdLogLevel);
if (!info) return UNAVAILABLE_INFO;
return <span title={info.description} style={{ color: info.color }}>{info.text}</span>;
})()}
</Field>
<Field label="MQTT Log Level">
{(() => {
const info = formatLogLevel(attr.mqttLogLevel);
if (!info) return UNAVAILABLE_INFO;
return <span title={info.description} style={{ color: info.color }}>{info.text}</span>;
})()}
</Field>
</FieldRow>
</SectionCard>
<SectionCard title="Misc" onEdit={canEdit ? () => setEditingMisc(true) : undefined}>
<FieldRow columns={3}>
<Field label="Automated Events">
<BoolBadge value={device.events_on} yesLabel="Enabled" noLabel="Disabled" />
</Field>
<Field label="Church Type">{formatLocale(attr.deviceLocale)}</Field>
<EmptyCell />
</FieldRow>
<FieldRow columns={3}>
<Field label="WebSocket URL">{hasValue(device.websocket_url) ? device.websocket_url : UNAVAILABLE_INFO}</Field>
<EmptyCell />
<EmptyCell />
</FieldRow>
</SectionCard>
<SectionCard title="Network Settings">
<FieldRow columns={3}>
<Field label="Hostname">{hasValue(net.hostname) ? net.hostname : UNAVAILABLE_INFO}</Field>
<Field label="Static IP">{hasValue(staticIpAddress) ? staticIpAddress : UNAVAILABLE_INFO}</Field>
<EmptyCell />
</FieldRow>
</SectionCard>
{/* ── Tags ── */}
<SectionCard title="Tags">
<div className="flex flex-wrap gap-2 mb-3">
{tags.length === 0 && (
<span className="text-xs" style={{ color: "var(--text-muted)" }}>No tags yet.</span>
)}
{tags.map((tag) => (
<span
key={tag}
className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium"
style={{ backgroundColor: "var(--badge-blue-bg)", color: "var(--badge-blue-text)", border: "1px solid var(--badge-blue-text)" }}
>
{tag}
{canEdit && (
<button
type="button"
onClick={() => handleRemoveTag(tag)}
disabled={savingTags}
className="ml-0.5 hover:opacity-70 cursor-pointer disabled:opacity-40"
style={{ lineHeight: 1 }}
>
×
</button>
)}
</span>
))}
</div>
{canEdit && (
<div className="flex gap-2">
<input
type="text"
value={tagInput}
onChange={(e) => 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)" }}
/>
<button
type="button"
onClick={() => handleAddTag(tagInput)}
disabled={!tagInput.trim() || savingTags}
className="px-3 py-1.5 text-sm rounded-md disabled:opacity-50 cursor-pointer"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
>
Add
</button>
</div>
)}
</SectionCard>
{/* ── Owner ── */}
<SectionCard title="Owner">
{device.customer_id ? (
<div>
{ownerCustomer ? (
<div
className="flex items-center gap-3 p-3 rounded-md border mb-3 cursor-pointer hover:opacity-80 transition-opacity"
style={{ backgroundColor: "var(--bg-primary)", borderColor: "var(--border-secondary)" }}
onClick={() => navigate(`/crm/customers/${device.customer_id}`)}
title="View customer"
>
<div className="w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold shrink-0"
style={{ backgroundColor: "var(--badge-blue-bg)", color: "var(--badge-blue-text)" }}>
{(ownerCustomer.name || "?")[0].toUpperCase()}
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium" style={{ color: "var(--text-primary)" }}>{ownerCustomer.name || "—"}</p>
{ownerCustomer.organization && (
<p className="text-xs" style={{ color: "var(--text-muted)" }}>{ownerCustomer.organization}</p>
)}
</div>
<svg className="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" style={{ color: "var(--text-muted)" }}>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</div>
) : (
<p className="text-sm mb-3" style={{ color: "var(--text-muted)" }}>Customer assigned (loading details)</p>
)}
{canEdit && (
<div className="flex gap-2">
<button
type="button"
onClick={() => setShowAssignSearch(true)}
className="text-xs px-3 py-1.5 rounded-md cursor-pointer hover:opacity-80"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)", border: "1px solid var(--border-primary)" }}
>
Reassign
</button>
<button
type="button"
onClick={handleUnassignCustomer}
disabled={assigningCustomer}
className="text-xs px-3 py-1.5 rounded-md cursor-pointer disabled:opacity-50 hover:opacity-80"
style={{ backgroundColor: "var(--danger-bg)", color: "var(--danger-text)", border: "1px solid var(--danger)" }}
>
Remove
</button>
</div>
)}
</div>
) : (
<div>
<p className="text-sm mb-3" style={{ color: "var(--text-muted)" }}>No customer assigned yet.</p>
{canEdit && (
<button
type="button"
onClick={() => setShowAssignSearch(true)}
className="text-sm px-3 py-1.5 rounded-md cursor-pointer hover:opacity-80"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)", border: "1px solid var(--border-primary)" }}
>
Assign to Customer
</button>
)}
</div>
)}
{showAssignSearch && (
<CustomerAssignModal
deviceId={id}
onSelect={(c) => { setShowAssignSearch(false); handleAssignCustomer(c); }}
onCancel={() => setShowAssignSearch(false)}
/>
)}
</SectionCard>
{/* ── Device Notes ── */}
<SectionCard title="Device Notes">
{!notesLoaded ? (
<p className="text-xs" style={{ color: "var(--text-muted)" }}>Loading</p>
) : (
<>
{deviceNotes.length === 0 && !addingNote && (
<p className="text-xs mb-3" style={{ color: "var(--text-muted)" }}>No notes for this device.</p>
)}
<div className="space-y-3 mb-3">
{deviceNotes.map((note) => (
<div
key={note.id}
className="p-3 rounded-md border"
style={{ backgroundColor: "var(--bg-primary)", borderColor: "var(--border-secondary)" }}
>
{editingNoteId === note.id ? (
<div className="space-y-2">
<textarea
value={editingNoteText}
onChange={(e) => setEditingNoteText(e.target.value)}
autoFocus
rows={3}
className="w-full px-2 py-1.5 rounded text-sm border"
style={{ backgroundColor: "var(--bg-input)", borderColor: "var(--border-input)", color: "var(--text-primary)", resize: "vertical" }}
/>
<div className="flex gap-2">
<button
type="button"
onClick={() => handleUpdateNote(note.id)}
disabled={savingNote}
className="text-xs px-2.5 py-1 rounded-md cursor-pointer disabled:opacity-50"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
>
{savingNote ? "Saving…" : "Save"}
</button>
<button
type="button"
onClick={() => setEditingNoteId(null)}
className="text-xs px-2.5 py-1 rounded-md cursor-pointer"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)" }}
>
Cancel
</button>
</div>
</div>
) : (
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<p className="text-sm whitespace-pre-wrap" style={{ color: "var(--text-secondary)" }}>{note.content}</p>
<p className="text-xs mt-1" style={{ color: "var(--text-muted)" }}>
{note.created_by}{note.created_at ? ` · ${new Date(note.created_at).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" })}` : ""}
</p>
</div>
{canEdit && (
<div className="flex gap-1.5 shrink-0">
<button
type="button"
onClick={() => { setEditingNoteId(note.id); setEditingNoteText(note.content); }}
className="text-xs px-2 py-0.5 rounded cursor-pointer hover:opacity-80"
style={{ color: "var(--text-muted)" }}
>
Edit
</button>
<button
type="button"
onClick={() => handleDeleteNote(note.id)}
className="text-xs px-2 py-0.5 rounded cursor-pointer hover:opacity-80"
style={{ color: "var(--danger-text)" }}
>
Delete
</button>
</div>
)}
</div>
)}
</div>
))}
</div>
{canEdit && (
addingNote ? (
<div className="space-y-2">
<textarea
value={newNoteText}
onChange={(e) => setNewNoteText(e.target.value)}
autoFocus
rows={3}
placeholder="Write a note…"
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)", resize: "vertical" }}
/>
<div className="flex gap-2">
<button
type="button"
onClick={handleAddNote}
disabled={savingNote || !newNoteText.trim()}
className="text-sm px-3 py-1.5 rounded-md cursor-pointer disabled:opacity-50"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
>
{savingNote ? "Saving…" : "Add Note"}
</button>
<button
type="button"
onClick={() => { setAddingNote(false); setNewNoteText(""); }}
className="text-sm px-3 py-1.5 rounded-md cursor-pointer"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)" }}
>
Cancel
</button>
</div>
</div>
) : (
<button
type="button"
onClick={() => setAddingNote(true)}
className="text-sm px-3 py-1.5 rounded-md cursor-pointer"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)", border: "1px solid var(--border-primary)" }}
>
+ Add Note
</button>
)
)}
</>
)}
</SectionCard>
</div>
</div>
);
const bellMechanismsTab = (
<div className="device-tab-stack">
<SectionCard title="Overview">
<div className="device-inline-facts device-inline-facts--overview">
<div>
<span className="device-inline-facts__label">Bell Commander</span>
<BoolBadge value={attr.hasBells} yesLabel="Operational" noLabel="Disabled" />
</div>
<div>
<span className="device-inline-facts__label">Bell Guard</span>
<BoolBadge value={attr.bellGuardOn} yesLabel="Operational" noLabel="Disabled" />
</div>
<div>
<span className="device-inline-facts__label">Connected Bells</span>
<span>{formatConnectedBells(attr.totalBells)}</span>
</div>
</div>
</SectionCard>
{canEdit && (
<div className="bell-master-strip">
<div className="bell-master-strip__left">
<span className="bell-master-strip__warning-badge">CAUTION</span>
<span className="text-sm font-medium" style={{ color: "var(--text-primary)" }}>Bell Commander</span>
<ToggleSwitch
value={attr.hasBells ?? false}
onChange={async (val) => {
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"
/>
</div>
<div className="bell-master-strip__right">
<button
type="button"
onClick={() => setEditingBellOutputs(true)}
className="px-4 py-2 text-sm rounded-md font-medium cursor-pointer hover:opacity-90"
style={{ backgroundColor: "var(--accent)", color: "#000" }}
>
Edit Outputs
</button>
</div>
</div>
)}
{attr.bellOutputs?.length ? (
<div className="bell-mechanism-grid">
{attr.bellOutputs.map((output, i) => {
const hammingMs = attr.hammerTimings?.[i];
const strikerSize = getStrikerSize(hammingMs);
const ordinal = ORDINAL_NAMES[i] ?? `${i + 1}th`;
return (
<article key={i} className="bell-mechanism-card">
{/* Left column */}
<div className="bell-mechanism-card__left">
<div className="bell-mechanism-card__left-top">
<h3 className="bell-mechanism-card__title">{ordinal} Bell Mechanism</h3>
<hr className="bell-mechanism-card__divider" />
<div className="bell-mechanism-card__info">
<p>
{Number.isFinite(output) && Number(output) === 0
? <strong className="bell-mechanism-card__highlight" style={{ color: "var(--text-muted)" }}>Currently Disabled</strong>
: <>Assigned to{" "}<strong className="bell-mechanism-card__highlight">{Number.isFinite(output) ? formatOutput(output) : UNAVAILABLE_INFO}</strong></>
}
</p>
<p>
with a relay timing of{" "}
<strong className="bell-mechanism-card__highlight">
{hammingMs != null ? `${hammingMs} ms` : UNAVAILABLE_INFO}
</strong>
</p>
<p>
using a{" "}
<strong className="bell-mechanism-card__highlight">
{strikerSize ? `Size ${strikerSize}` : UNAVAILABLE_INFO}
</strong>{" "}
Striker Mechanism
</p>
<p>
has been struck{" "}
<strong className="bell-mechanism-card__highlight bell-mechanism-card__highlight--strikes">
{Number.isFinite(liveStrikeCounters?.[i])
? `${liveStrikeCounters[i]} Times`
: requestingStrikeCounters
? "Loading..."
: UNAVAILABLE_INFO}
</strong>
</p>
</div>
</div>
</div>
{/* Right column */}
<div className="bell-mechanism-card__right">
<div className="bell-mechanism-card__right-top">
{strikerSize && (
<img
className="bell-mechanism-card__striker-img"
src={`/devices/Strikers/striker_size_${strikerSize}.png`}
alt={`Size ${strikerSize} striker`}
/>
)}
</div>
<div className="bell-mechanism-card__cert">
<span className="bell-mechanism-card__cert-text">
Manuf. Certified<br />
<span className="bell-mechanism-card__cert-brand">BellSystems</span> Striker
</span>
<img
className="bell-mechanism-card__cert-badge"
src="/image-assets/certified_logo.png"
alt="Certified"
/>
</div>
</div>
</article>
);
})}
</div>
) : (
<p className="text-sm" style={{ color: "var(--text-muted)" }}>No bell mechanism data available.</p>
)}
</div>
);
const clockAlertsTab = (
<div className="device-tab-stack">
<div className="device-tab-grid">
<SectionCard title="Clock Settings" onEdit={canEdit ? () => setEditingClockSettings(true) : undefined}>
<FieldRow columns={3}>
<Field label="Clock Controller">
<BoolBadge value={attr.hasClock} yesLabel="Enabled" noLabel="Disabled" />
</Field>
<EmptyCell />
<EmptyCell />
</FieldRow>
<FieldRow columns={3}>
<Field label="ODD Output">{formatOutput(clock.clockOutputs?.[0])}</Field>
<Field label="EVEN Output">{formatOutput(clock.clockOutputs?.[1])}</Field>
<EmptyCell />
</FieldRow>
<FieldRow columns={3}>
<Field label="Run Pulse">
{clock.clockTimings?.[0] != null ? formatMsWithSeconds(clock.clockTimings[0]) : UNAVAILABLE_INFO}
</Field>
<Field label="Pause Pulse">
{clock.clockTimings?.[1] != null ? formatMsWithSeconds(clock.clockTimings[1]) : UNAVAILABLE_INFO}
</Field>
<EmptyCell />
</FieldRow>
</SectionCard>
<SectionCard title="Alert Settings" onEdit={canEdit ? () => setEditingAlerts(true) : undefined}>
<FieldRow columns={3}>
<Field label="Status">
<BoolBadge value={clock.ringAlertsMasterOn} yesLabel="Alerts Enabled" noLabel="Alerts Disabled" />
</Field>
<Field label="Alerts Type">
{clock.ringAlerts === "multi"
? "Hour Indicating"
: clock.ringAlerts === "single"
? "Single Fire"
: clock.ringAlerts === "off" || clock.ringAlerts === "disabled"
? "Deactivated"
: UNAVAILABLE_INFO}
</Field>
<Field label="Alert Tempo">
{Number.isFinite(clock.ringIntervals) ? formatMsWithSeconds(clock.ringIntervals) : UNAVAILABLE_INFO}
</Field>
</FieldRow>
<FieldRow columns={3}>
<Field label="Hour Bell">{formatBell(clock.hourAlertsBell)}</Field>
<Field label="Half-Hour Bell">{formatBell(clock.halfhourAlertsBell)}</Field>
<Field label="Quarter Bell">{formatBell(clock.quarterAlertsBell)}</Field>
</FieldRow>
<FieldRow columns={3}>
<Field label="Day Silence">
{formatSilenceRange(clock.isDaySilenceOn, clock.daySilenceFrom, clock.daySilenceTo)}
</Field>
<Field label="Night Silence">
{formatSilenceRange(clock.isNightSilenceOn, clock.nightSilenceFrom, clock.nightSilenceTo)}
</Field>
<EmptyCell />
</FieldRow>
</SectionCard>
<SectionCard title="Backlight" onEdit={canEdit ? () => setEditingBacklight(true) : undefined}>
<FieldRow columns={3}>
<Field label="Functionality">
<BoolBadge value={clock.isBacklightAutomationOn} yesLabel="Automated" noLabel="Disabled" />
</Field>
<Field label="Output">{formatOutputWithDisabledZero(clock.backlightOutput)}</Field>
<Field label="Period">
{formatSilencePeriod(clock.isBacklightAutomationOn, clock.backlightTurnOnTime, clock.backlightTurnOffTime)}
</Field>
</FieldRow>
</SectionCard>
</div>
</div>
);
const warrantySubscriptionTab = (
<div className="device-tab-stack">
<div className="device-tab-grid">
<SectionCard title="Subscription" onEdit={canEdit ? () => setEditingSubscription(true) : undefined}>
<ProgressBar value={subscrProgress ?? 0} tone="var(--accent)" />
<div className="mt-6" />
<FieldRow columns={3}>
<Field label="Tier">
<span className="device-status-text device-status-text--info capitalize">
{hasValue(sub.subscrTier) ? sub.subscrTier : UNAVAILABLE_INFO}
</span>
</Field>
<Field label="Time Remaining">
{subscrDaysLeft == null ? UNAVAILABLE_INFO : subscrDaysLeft <= 0 ? "Expired" : formatDaysVerbose(subscrDaysLeft)}
</Field>
<EmptyCell />
</FieldRow>
<FieldRow columns={3}>
<Field label="Max Users">{Number.isFinite(sub.maxUsers) ? formatCount(sub.maxUsers, "User") : UNAVAILABLE_INFO}</Field>
<Field label="Max Outputs">{Number.isFinite(sub.maxOutputs) ? formatCount(sub.maxOutputs, "Output") : UNAVAILABLE_INFO}</Field>
<EmptyCell />
</FieldRow>
<FieldRow columns={3}>
<Field label="Start Date">{formatAnyDateVerbose(sub.subscrStart)}</Field>
<Field label="End Date">{subscrEnd ? formatDateNice(subscrEnd) : UNAVAILABLE_INFO}</Field>
<Field label="Duration">{Number.isFinite(sub.subscrDuration) && sub.subscrDuration > 0 ? formatDaysVerbose(sub.subscrDuration) : UNAVAILABLE_INFO}</Field>
</FieldRow>
</SectionCard>
<SectionCard title="Warranty" onEdit={canEdit ? () => setEditingWarranty(true) : undefined}>
<ProgressBar value={warrantyProgress ?? 0} tone={warrantyDaysLeft <= 0 ? "var(--danger)" : "var(--accent)"} />
<div className="mt-6" />
<FieldRow columns={3}>
<Field label="Warranty Status">
<span className={`device-status-text ${warrantyIsVoid ? "device-status-text--warning" : (warrantyDaysLeft !== null ? warrantyDaysLeft > 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")}
</span>
</Field>
<Field label="Time Remaining">
{warrantyDaysLeft == null ? UNAVAILABLE_INFO : warrantyDaysLeft <= 0 ? "Expired" : formatDaysVerbose(warrantyDaysLeft)}
</Field>
<EmptyCell />
</FieldRow>
<FieldRow columns={3}>
<Field label="Start Date">{formatAnyDateVerbose(stats.warrantyStart)}</Field>
<Field label="Expiry Date">{warrantyEnd ? formatDateNice(warrantyEnd) : UNAVAILABLE_INFO}</Field>
<Field label="Period">{Number.isFinite(stats.warrantyPeriod) && stats.warrantyPeriod > 0 ? formatDaysVerbose(stats.warrantyPeriod) : UNAVAILABLE_INFO}</Field>
</FieldRow>
</SectionCard>
<SectionCard title="Maintenance">
<ProgressBar value={maintenanceProgress ?? 0} tone={maintenanceDaysLeft <= 0 ? "var(--danger)" : "#f59e0b"} />
<div className="mt-6" />
<FieldRow columns={3}>
<Field label="Last Maintained">{formatAnyDateVerbose(stats.maintainedOn)}</Field>
<Field label="Maintenance Period">
{Number.isFinite(stats.maintainancePeriod) && stats.maintainancePeriod > 0 ? formatDaysVerbose(stats.maintainancePeriod) : UNAVAILABLE_INFO}
</Field>
<Field label="Next Scheduled">
{nextMaintenance ? `${formatDateNice(nextMaintenance)} (${maintenanceDaysLeft <= 0 ? "overdue" : formatRelativeTime(maintenanceDaysLeft)})` : UNAVAILABLE_INFO}
</Field>
</FieldRow>
</SectionCard>
</div>
<div className="device-tab-grid">
<SectionCard title="Statistics">
<FieldRow columns={3}>
<Field label="Total Playbacks">
{randomPlaybacks} Times
</Field>
<Field label="Hammer Strikes">
{Number.isFinite(hammerStrikesFromLive)
? (
<span>
Striked{" "}
<strong style={{ color: hammerStrikesFromLive > 0 ? "#f59e0b" : "var(--success-text)" }}>
{hammerStrikesFromLive}
</strong>{" "}
Times
</span>
)
: requestingStrikeCounters
? "Loading..."
: UNAVAILABLE_INFO}
</Field>
<Field label="Warnings Given">
{Number.isFinite(stats.totalWarningsGiven)
? stats.totalWarningsGiven === 0
? <span style={{ color: "var(--success-text)" }}>No Warnings</span>
: stats.totalWarningsGiven === 1
? <span style={{ color: "#f59e0b" }}>1 Warning given</span>
: <span style={{ color: "var(--danger-text)" }}>{stats.totalWarningsGiven} Warnings given</span>
: UNAVAILABLE_INFO}
</Field>
</FieldRow>
<FieldRow columns={3}>
<Field label="Total Melodies">
{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}
</Field>
<Field label="Favorite Melodies">
{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}
</Field>
<EmptyCell />
</FieldRow>
</SectionCard>
</div>
</div>
);
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 = (
<div className="device-tab-stack">
{/* ── Grid of control cards ── */}
<div className="device-tab-grid">
{/* 1. Restart */}
<SectionCard title="Restart Device">
<p className="text-sm mb-5" style={{ color: "var(--text-secondary)", lineHeight: 1.6 }}>
Send a restart command to the device. The device will reboot and reconnect automatically.
</p>
<button style={ctrlBtn(true)} type="button" onClick={() => {}}>
Restart Device
</button>
</SectionCard>
{/* 2. Firmware Update */}
<SectionCard title="Firmware Control">
<div style={{ display: "flex", justifyContent: "space-between", gap: "2rem", flexWrap: "wrap" }}>
{/* Info column */}
<div style={{ display: "flex", flexDirection: "column", gap: "1.1rem" }}>
<div style={ctrlFieldGroup}>
<span style={ctrlLabel}>Device Firmware</span>
<span style={{ fontSize: "1rem", color: "var(--text-primary)" }}>
{device?.device_firmware_version || "—"}
</span>
</div>
<div style={ctrlFieldGroup}>
<span style={ctrlLabel}>Firmware Family</span>
<span style={{ fontSize: "1rem", color: "var(--text-primary)" }}>
{device?.device_firmware_family || "Bell Core"}
</span>
</div>
<div style={ctrlFieldGroup}>
<span style={ctrlLabel}>Last Updated On</span>
<span style={{ fontSize: "1rem", color: "var(--text-primary)" }}>
{device?.device_firmware_updated ? formatDate(device.device_firmware_updated) : "—"}
</span>
</div>
</div>
{/* Controls column — pushed to far right */}
<div style={{ display: "flex", flexDirection: "column", gap: "1.25rem", alignItems: "flex-end" }}>
<div style={{ ...ctrlFieldGroup, alignItems: "flex-end" }}>
<span style={ctrlLabel}>Change Branch</span>
<select
value={ctrlFirmwareChannel}
onChange={(e) => setCtrlFirmwareChannel(e.target.value)}
style={ctrlSelect}
>
<option value="stable">Stable</option>
<option value="beta">Beta</option>
<option value="alpha">Alpha</option>
</select>
</div>
<button style={ctrlBtn()} type="button" onClick={() => {}}>
Force Update Now
</button>
</div>
</div>
</SectionCard>
{/* 3. Test Fire Bells */}
<SectionCard title="Test Fire Bells">
<p className="text-xs mb-4" style={{ color: "var(--text-muted)" }}>
Click any bell button to fire it once. Use "Fire All" to trigger each bell in sequence.
</p>
{totalBells === 0 ? (
<p className="text-sm" style={{ color: "var(--text-secondary)" }}>No bells configured on this device.</p>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
<div style={{ display: "flex", flexWrap: "wrap", gap: "0.6rem" }}>
{bellNumbers.map((n) => (
<button
key={n}
style={ctrlBtn()}
type="button"
onClick={() => {}}
title={`Test fire ${ORDINAL_CTRL[n - 1] ?? n + "th"} bell`}
>
{ORDINAL_CTRL[n - 1] ?? `${n}th`} Bell
</button>
))}
</div>
<div style={{ borderTop: "1px solid var(--border-primary)", paddingTop: "0.85rem" }}>
<button style={{ ...ctrlBtn(), border: "1px solid var(--accent)", color: "var(--accent)" }} type="button" onClick={() => {}}>
Fire All Bells in Sequence
</button>
</div>
</div>
)}
</SectionCard>
{/* 4. HTTP Server */}
<SectionCard title="HTTP Server">
<p className="text-sm mb-5" style={{ color: "var(--text-secondary)", lineHeight: 1.6 }}>
Enable or disable the HTTP server on this device. Turning it off will make the web interface inaccessible.
</p>
<div style={ctrlFieldGroup}>
<span style={ctrlLabel}>HTTP Server Status</span>
<span style={{
fontSize: "1rem",
fontWeight: 600,
color: ctrlHttpEnabled ? "var(--success-text, #68d391)" : "var(--text-muted)",
}}>
{ctrlHttpEnabled ? "Running" : "Disabled"}
</span>
</div>
<div style={{ display: "flex", gap: "0.75rem", marginTop: "1.25rem", flexWrap: "wrap" }}>
<button
style={{ ...ctrlBtn(), border: "1px solid var(--accent)", color: "var(--accent)" }}
type="button"
onClick={() => setCtrlHttpEnabled(true)}
>
Switch On
</button>
<button
style={ctrlBtn(true)}
type="button"
onClick={() => setCtrlHttpEnabled(false)}
>
Switch Off
</button>
</div>
</SectionCard>
{/* 5. SD Card */}
<SectionCard title="SD Card">
<p className="text-sm mb-5" style={{ color: "var(--text-secondary)", lineHeight: 1.6 }}>
List all files stored on the device's SD card, or permanently delete all contents including melodies, logs, and configuration files.
</p>
<div style={{ display: "flex", gap: "0.75rem", flexWrap: "wrap" }}>
<button
style={{ ...ctrlBtn(), border: "1px solid var(--accent)", color: "var(--accent)" }}
type="button"
onClick={() => {}}
>
List Files
</button>
<button style={ctrlBtn(true)} type="button" onClick={() => {}}>
Delete All Contents
</button>
</div>
</SectionCard>
{/* 6. Time Sync */}
<SectionCard title="Time & Timezone Sync">
<div style={{ display: "flex", flexDirection: "column", gap: "1.1rem" }}>
<div style={ctrlFieldGroup}>
<span style={ctrlLabel}>Device Date & Time</span>
<input
type="datetime-local"
value={ctrlDeviceTime}
onChange={(e) => setCtrlDeviceTime(e.target.value)}
style={{
...ctrlSelect,
minWidth: 200,
padding: "0.55rem 0.85rem",
}}
/>
</div>
<div style={ctrlFieldGroup}>
<span style={ctrlLabel}>Timezone</span>
<select value={ctrlTimezone} onChange={(e) => setCtrlTimezone(e.target.value)} style={ctrlSelect}>
{TIMEZONES.map((tz) => (
<option key={tz} value={tz}>{tz}</option>
))}
</select>
</div>
<div style={{ display: "flex", alignItems: "center", gap: "0.75rem" }}>
<ToggleSwitch
value={ctrlDst}
onChange={(val) => setCtrlDst(val)}
onLabel="DST On"
offLabel="DST Off"
/>
</div>
<div style={{ display: "flex", gap: "0.75rem", flexWrap: "wrap", paddingTop: "0.25rem" }}>
<button style={ctrlBtn()} type="button" onClick={() => setCtrlDeviceTime(new Date().toISOString().slice(0, 16))}>
Use Current Time
</button>
<button style={{ ...ctrlBtn(), border: "1px solid var(--accent)", color: "var(--accent)" }} type="button" onClick={() => {}}>
Send Time Sync
</button>
</div>
</div>
</SectionCard>
</div>{/* end device-tab-grid */}
{/* 7. Device Melodies List — full width */}
<SectionCard title="Device Melodies (SD Card)">
<div style={{ display: "flex", alignItems: "center", gap: "1rem", marginBottom: "1rem", flexWrap: "wrap" }}>
<button style={ctrlBtn()} type="button" onClick={fetchCtrlMelodies}>
{ctrlMelodiesLoading ? "Loading..." : "Fetch Melody List"}
</button>
{ctrlMelodies.length > 0 && (
<button style={ctrlBtn(true)} type="button" onClick={() => {}}>
Delete All
</button>
)}
</div>
{ctrlMelodiesLoading ? (
<p className="text-xs" style={{ color: "var(--text-muted)" }}>Requesting melody list from device...</p>
) : ctrlMelodies.length === 0 ? (
<p className="text-xs" style={{ color: "var(--text-muted)" }}>No melodies loaded yet. Press "Fetch Melody List" to request them from the device.</p>
) : (
<div className="rounded-md overflow-hidden border" style={{ borderColor: "var(--border-primary)" }}>
<table className="w-full text-sm">
<thead>
<tr style={{ backgroundColor: "var(--bg-primary)", borderBottom: "1px solid var(--border-primary)" }}>
<th className="px-4 py-2.5 text-left font-medium" style={{ color: "var(--text-secondary)" }}>#</th>
<th className="px-4 py-2.5 text-left font-medium" style={{ color: "var(--text-secondary)" }}>Filename</th>
<th className="px-4 py-2.5 text-left font-medium" style={{ color: "var(--text-secondary)" }}>Size</th>
<th className="px-4 py-2.5 text-right font-medium" style={{ color: "var(--text-secondary)" }}>Actions</th>
</tr>
</thead>
<tbody>
{ctrlMelodies.map((mel, i) => (
<tr key={mel.name || i} style={{ borderBottom: i < ctrlMelodies.length - 1 ? "1px solid var(--border-primary)" : "none" }}>
<td className="px-4 py-2.5 font-mono text-xs" style={{ color: "var(--text-muted)" }}>{i + 1}</td>
<td className="px-4 py-2.5 font-mono text-xs" style={{ color: "var(--text-primary)" }}>{mel.name}</td>
<td className="px-4 py-2.5 text-xs" style={{ color: "var(--text-muted)" }}>{mel.size ? `${mel.size} bytes` : "—"}</td>
<td className="px-4 py-2.5 text-right">
<button style={{ ...ctrlBtn(true), padding: "0.3rem 0.75rem", fontSize: "0.75rem" }} type="button" onClick={() => {}}>
Delete
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</SectionCard>
{/* 8. Custom Command */}
<SectionCard title="Send Custom Command">
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "1rem" }}>
<div style={ctrlFieldGroup}>
<span style={ctrlLabel}>Command</span>
<textarea
value={ctrlCustomCmd}
onChange={(e) => setCtrlCustomCmd(e.target.value)}
placeholder={`Enter command JSON...\ne.g. {"cmd": "ping", "contents": {}}`}
className="w-full px-3 py-2 rounded-md text-xs border font-mono"
style={{
backgroundColor: "var(--bg-primary)",
borderColor: "var(--border-primary)",
color: "var(--text-primary)",
resize: "vertical",
height: ctrlCustomHeight,
minHeight: 120,
}}
onMouseUp={(e) => { if (e.target.style.height) setCtrlCustomHeight(parseInt(e.target.style.height) || ctrlCustomHeight); }}
/>
</div>
<div style={ctrlFieldGroup}>
<span style={ctrlLabel}>Response</span>
<textarea
readOnly
value={ctrlCustomResponse}
placeholder="Response will appear here..."
className="w-full px-3 py-2 rounded-md text-xs border font-mono"
style={{
backgroundColor: "var(--bg-primary)",
borderColor: "var(--border-primary)",
color: "var(--text-muted)",
resize: "none",
height: ctrlCustomHeight,
minHeight: 120,
}}
/>
</div>
</div>
<div style={{ marginTop: "1rem" }}>
<button
style={{ ...ctrlBtn(), border: "1px solid var(--accent)", color: "var(--accent)" }}
type="button"
onClick={() => {}}
>
Send Command
</button>
</div>
</SectionCard>
{/* 9. Logs — full width, scrollable */}
<DeviceLogsPanel deviceSerial={device?.device_id} />
{/* 10. Command History — full width */}
<SectionCard title="Command History">
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: "1rem", gap: "1rem", flexWrap: "wrap" }}>
<span className="text-xs" style={{ color: "var(--text-muted)" }}>{ctrlCmdHistory.length} commands</span>
<div style={{ display: "flex", alignItems: "center", gap: "0.75rem" }}>
<ToggleSwitch value={ctrlCmdAutoRefresh} onChange={setCtrlCmdAutoRefresh} onLabel="Auto-refresh" offLabel="Auto-refresh" />
<button
onClick={fetchCtrlCmdHistory}
type="button"
className="px-3 rounded-md text-xs hover:opacity-80 cursor-pointer"
style={{ height: 30, backgroundColor: "var(--badge-blue-bg)", color: "var(--badge-blue-text)", border: "none" }}
>
Refresh
</button>
</div>
</div>
{ctrlCmdHistoryLoading ? (
<p className="text-xs" style={{ color: "var(--text-muted)" }}>Loading...</p>
) : ctrlCmdHistory.length === 0 ? (
<p className="text-xs" style={{ color: "var(--text-muted)" }}>
No commands sent to this device yet.{" "}
<button
onClick={fetchCtrlCmdHistory}
className="hover:opacity-80 cursor-pointer"
style={{ color: "var(--accent)", textDecoration: "underline" }}
type="button"
>
Load History
</button>
</p>
) : (
<div className="rounded-md overflow-hidden border" style={{ borderColor: "var(--border-primary)" }}>
<div style={{ maxHeight: 320, overflowY: "auto" }}>
<table className="w-full text-xs">
<thead style={{ position: "sticky", top: 0, zIndex: 1 }}>
<tr style={{ backgroundColor: "var(--bg-primary)", borderBottom: "1px solid var(--border-primary)" }}>
<th className="px-3 py-2.5 text-left font-medium" style={{ color: "var(--text-secondary)" }}>Time</th>
<th className="px-3 py-2.5 text-left font-medium" style={{ color: "var(--text-secondary)" }}>Command</th>
<th className="px-3 py-2.5 text-left font-medium" style={{ color: "var(--text-secondary)" }}>Status</th>
<th className="px-3 py-2.5 text-left font-medium" style={{ color: "var(--text-secondary)" }}>Details</th>
</tr>
</thead>
<tbody>
{ctrlCmdHistory.map((cmd, index) => {
const s = CTRL_STATUS_STYLES[cmd.status] || CTRL_STATUS_STYLES.pending;
const isExpanded = ctrlExpandedCmd === cmd.id;
return (
<>
<tr
key={cmd.id}
className="cursor-pointer hover:opacity-80"
style={{ borderBottom: (!isExpanded && index < ctrlCmdHistory.length - 1) ? "1px solid var(--border-primary)" : "none" }}
onClick={() => setCtrlExpandedCmd(isExpanded ? null : cmd.id)}
>
<td className="px-3 py-2 font-mono" style={{ color: "var(--text-muted)" }}>
{cmd.sent_at?.replace("T", " ").substring(0, 19)}
</td>
<td className="px-3 py-2 font-mono" style={{ color: "var(--text-heading)" }}>
{cmd.command_name}
</td>
<td className="px-3 py-2">
<span className="px-2 py-0.5 text-xs rounded-full" style={{ backgroundColor: s.bg, color: s.color }}>
{cmd.status}
</span>
</td>
<td className="px-3 py-2" style={{ color: "var(--text-muted)" }}>
{cmd.responded_at ? `Replied ${cmd.responded_at.replace("T", " ").substring(0, 19)}` : "Awaiting response..."}
</td>
</tr>
{isExpanded && (
<tr key={`${cmd.id}-exp`} style={{ borderBottom: index < ctrlCmdHistory.length - 1 ? "1px solid var(--border-primary)" : "none" }}>
<td colSpan={4} className="px-3 py-3" style={{ backgroundColor: "var(--bg-primary)" }}>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "1rem" }}>
<div>
<p className="text-xs font-medium mb-1" style={{ color: "var(--text-secondary)" }}>Sent Payload</p>
<pre className="text-xs p-2 rounded overflow-auto font-mono" style={{ maxHeight: 160, backgroundColor: "var(--bg-card)", color: "var(--text-primary)" }}>
{ctrlFormatPayload(cmd.command_payload) || "—"}
</pre>
</div>
<div>
<p className="text-xs font-medium mb-1" style={{ color: "var(--text-secondary)" }}>Response</p>
<pre className="text-xs p-2 rounded overflow-auto font-mono" style={{ maxHeight: 160, backgroundColor: "var(--bg-card)", color: "var(--text-primary)" }}>
{ctrlFormatPayload(cmd.response_payload) || "Waiting..."}
</pre>
</div>
</div>
</td>
</tr>
)}
</>
);
})}
</tbody>
</table>
</div>
</div>
)}
</SectionCard>
</div>
);
// ── Manage tab ──────────────────────────────────────────────────────────────
const manageTab = (
<div className="device-tab-stack">
{/* Issues & Notes — full width */}
<NotesPanel key={`manage-${id}`} deviceId={id} />
{/* User Assignment */}
<section className="rounded-lg border p-6 mt-4" style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold" style={{ color: "var(--text-heading)" }}>
App Users ({deviceUsers.length})
</h2>
{canEdit && (
<button
type="button"
onClick={() => { setShowUserSearch(true); setUserSearchQuery(""); setUserSearchResults([]); setTimeout(() => userSearchInputRef.current?.focus(), 50); }}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-md cursor-pointer hover:opacity-90 transition-opacity"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
>
+ Add User
</button>
)}
</div>
{usersLoading ? (
<p className="text-sm" style={{ color: "var(--text-muted)" }}>Loading users</p>
) : deviceUsers.length === 0 ? (
<p className="text-sm" style={{ color: "var(--text-muted)" }}>No users assigned. Users are added when they claim the device via the app, or you can add them manually.</p>
) : (
<div className="space-y-2">
{deviceUsers.map((u) => (
<div
key={u.user_id}
className="flex items-center gap-3 px-3 py-2.5 rounded-md border"
style={{ backgroundColor: "var(--bg-primary)", borderColor: "var(--border-secondary)" }}
>
{u.photo_url ? (
<img src={u.photo_url} alt="" className="w-8 h-8 rounded-full object-cover shrink-0" />
) : (
<div className="w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold shrink-0"
style={{ backgroundColor: "var(--badge-blue-bg)", color: "var(--badge-blue-text)" }}>
{(u.display_name || u.email || "?")[0].toUpperCase()}
</div>
)}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium" style={{ color: "var(--text-primary)" }}>{u.display_name || "—"}</p>
{u.email && <p className="text-xs" style={{ color: "var(--text-muted)" }}>{u.email}</p>}
</div>
{u.role && (
<span className="text-xs px-2 py-0.5 rounded-full shrink-0"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-muted)" }}>
{u.role}
</span>
)}
{canEdit && (
<button
type="button"
disabled={removingUser === u.user_id}
onClick={async () => {
setRemovingUser(u.user_id);
try {
await api.delete(`/devices/${id}/user-list/${u.user_id}`);
setDeviceUsers((prev) => prev.filter((x) => x.user_id !== u.user_id));
} catch (err) {
setError(err.message);
} finally {
setRemovingUser(null);
}
}}
className="text-xs px-2 py-0.5 rounded cursor-pointer hover:opacity-80 disabled:opacity-40 shrink-0"
style={{ color: "var(--danger-text)" }}
>
{removingUser === u.user_id ? "…" : "Remove"}
</button>
)}
</div>
))}
</div>
)}
{/* User search modal */}
{showUserSearch && (
<div
className="fixed inset-0 z-50 flex items-center justify-center"
style={{ backgroundColor: "rgba(0,0,0,0.6)" }}
>
<div
className="rounded-xl border p-6 w-full max-w-md"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
>
<h2 className="text-base font-bold mb-4" style={{ color: "var(--text-heading)" }}>
Add User
</h2>
<div style={{ position: "relative" }} className="mb-3">
<input
ref={userSearchInputRef}
type="text"
value={userSearchQuery}
onChange={(e) => 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 && (
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-xs" style={{ color: "var(--text-muted)" }}></span>
)}
</div>
<div
className="rounded-md border overflow-y-auto mb-4"
style={{ borderColor: "var(--border-secondary)", maxHeight: 260, minHeight: 48 }}
>
{userSearchResults.length === 0 ? (
<p className="px-3 py-3 text-sm" style={{ color: "var(--text-muted)" }}>
{userSearching ? "Searching…" : userSearchQuery ? "No users found." : "Type to search users…"}
</p>
) : (
userSearchResults.map((u) => {
const alreadyAdded = deviceUsers.some((du) => du.user_id === u.id);
return (
<button
key={u.id}
type="button"
disabled={alreadyAdded || addingUser === u.id}
onClick={async () => {
setAddingUser(u.id);
try {
await api.post(`/devices/${id}/user-list`, { user_id: u.id });
setDeviceUsers((prev) => [...prev, {
user_id: u.id,
display_name: u.display_name,
email: u.email,
photo_url: u.photo_url,
role: "",
}]);
setShowUserSearch(false);
} catch (err) {
setError(err.message);
} finally {
setAddingUser(null);
}
}}
className="w-full text-left px-3 py-2.5 text-sm border-b last:border-b-0 transition-colors cursor-pointer disabled:opacity-50"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-secondary)", color: "var(--text-primary)" }}
>
<span className="font-medium">{u.display_name || u.email || u.id}</span>
{u.email && <span className="block text-xs" style={{ color: "var(--text-muted)" }}>{u.email}</span>}
{alreadyAdded && <span className="ml-2 text-xs" style={{ color: "var(--success-text)" }}>Already added</span>}
</button>
);
})
)}
</div>
<div className="flex justify-end">
<button
type="button"
onClick={() => setShowUserSearch(false)}
className="px-4 py-1.5 text-sm rounded-md hover:opacity-80 cursor-pointer"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)" }}
>
Cancel
</button>
</div>
</div>
</div>
)}
</section>
</div>
);
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 (
<div>
<div className="flex items-center justify-between mb-6">
<div>
<button onClick={() => navigate("/devices")} className="text-sm hover:underline mb-2 inline-block" style={{ color: "var(--accent)" }}>
&larr; Back to Devices
</button>
<div className="flex items-center gap-4">
<h1 className="text-2xl font-bold" style={{ color: "var(--text-heading)" }}>
{device.device_name || "Unnamed Device"}
</h1>
<div style={{ width: 1, height: 24, backgroundColor: "var(--border-primary)" }} />
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
<span className="w-3 h-3 rounded-full inline-block" style={{ backgroundColor: isOnline ? "var(--success-text)" : "var(--text-muted)" }} />
<span className="text-sm font-semibold" style={{ color: isOnline ? "var(--success-text)" : "var(--text-muted)" }}>
{isOnline ? "Online" : "Offline"}
{mqttStatus && (
<span className="ml-2 text-xs font-normal" style={{ color: "var(--text-muted)" }}>
{formatSecondsAgo(mqttStatus.seconds_since_heartbeat)}
</span>
)}
</span>
</div>
{unresolvedIssues > 0 && (
<>
<div style={{ width: 1, height: 24, backgroundColor: "var(--border-primary)" }} />
<button
onClick={() => {
setActiveTab("dashboard");
setNotesPanelTab("issues");
notesPanelRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
}}
className="flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-md cursor-pointer hover:opacity-80 transition-opacity"
style={{ backgroundColor: "var(--danger-bg)", color: "var(--danger-text)", border: "none" }}
type="button"
>
<svg
aria-hidden="true"
viewBox="0 0 24 24"
className="w-3.5 h-3.5"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.72 3h16.92a2 2 0 0 0 1.72-3l-8.47-14.14a2 2 0 0 0-3.42 0z" />
<line x1="12" y1="9" x2="12" y2="13" />
<line x1="12" y1="17" x2="12.01" y2="17" />
</svg>
{unresolvedIssues} Issue{unresolvedIssues !== 1 ? "s" : ""} Unresolved
</button>
</>
)}
</div>
</div>
{canEdit && (
<div className="flex gap-2">
<button
onClick={() => setShowDelete(true)}
className="px-4 py-2 text-sm rounded-md transition-colors"
style={{ backgroundColor: "var(--danger)", color: "var(--text-white)" }}
type="button"
>
Delete
</button>
</div>
)}
</div>
<section className="device-tabs-wrap">
<div className="device-tabs-bar">
{TAB_DEFS.map((tab, index) => (
<div key={tab.id} className="device-tab-item">
<TabButton active={activeTab === tab.id} tone={tab.tone} onClick={() => setActiveTab(tab.id)}>
{tab.label}
</TabButton>
{index < TAB_DEFS.length - 1 && <span className="device-tab-divider" aria-hidden="true" />}
</div>
))}
</div>
</section>
<div className="mt-5">{renderTabContent()}</div>
<ConfirmDialog
open={showDelete}
title="Delete Device"
message={`Are you sure you want to delete "${device.device_name || "this device"}" (${device.serial_number || device.device_id})? This action cannot be undone.`}
onConfirm={handleDelete}
onCancel={() => setShowDelete(false)}
/>
<LocationModal
open={editingLocation}
onClose={() => setEditingLocation(false)}
onSaved={loadData}
device={device}
coords={coords}
id={id}
/>
<AttributesModal
open={editingAttributes}
onClose={() => setEditingAttributes(false)}
onSaved={loadData}
device={device}
attr={attr}
id={id}
/>
<LoggingModal
open={editingLogging}
onClose={() => setEditingLogging(false)}
onSaved={loadData}
attr={attr}
id={id}
/>
<MiscModal
open={editingMisc}
onClose={() => setEditingMisc(false)}
onSaved={loadData}
device={device}
attr={attr}
id={id}
/>
<BellOutputsModal
open={editingBellOutputs}
onClose={() => setEditingBellOutputs(false)}
onSaved={loadData}
attr={attr}
clock={clock}
sub={sub}
id={id}
/>
<ClockSettingsModal
open={editingClockSettings}
onClose={() => setEditingClockSettings(false)}
onSaved={loadData}
attr={attr}
clock={clock}
sub={sub}
id={id}
/>
<AlertsModal
open={editingAlerts}
onClose={() => setEditingAlerts(false)}
onSaved={loadData}
attr={attr}
clock={clock}
id={id}
/>
<BacklightModal
open={editingBacklight}
onClose={() => setEditingBacklight(false)}
onSaved={loadData}
attr={attr}
clock={clock}
sub={sub}
id={id}
/>
<SubscriptionModal
open={editingSubscription}
onClose={() => setEditingSubscription(false)}
onSaved={loadData}
sub={sub}
id={id}
/>
<WarrantyModal
open={editingWarranty}
onClose={() => setEditingWarranty(false)}
onSaved={loadData}
stats={stats}
id={id}
/>
<QrModal value={qrTarget} onClose={() => setQrTarget(null)} />
</div>
);
}