- 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>
3962 lines
170 KiB
JavaScript
3962 lines
170 KiB
JavaScript
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)" }}>✕</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='© <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)" }}>✕</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='© <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)" }}>
|
||
← 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>
|
||
);
|
||
}
|
||
|
||
|