update: overhauled firmware ui. Added public flash page.

This commit is contained in:
2026-03-18 17:49:40 +02:00
parent 4381a6681d
commit d0ac4f1d91
45 changed files with 6798 additions and 1723 deletions

View File

@@ -1556,6 +1556,7 @@ const TAB_DEFS = [
{ 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" },
];
@@ -1574,6 +1575,104 @@ function calcMaintenanceProgress(lastDate, periodDays) {
return Math.max(0, Math.min(100, (elapsed / total) * 100));
}
// ─── Customer Assign Modal ────────────────────────────────────────────────────
function CustomerAssignModal({ deviceId, onSelect, onCancel }) {
const [query, setQuery] = useState("");
const [results, setResults] = useState([]);
const [searching, setSearching] = useState(false);
const inputRef = useRef(null);
useEffect(() => { inputRef.current?.focus(); }, []);
const search = useCallback(async (q) => {
setSearching(true);
try {
const data = await api.get(`/devices/${deviceId}/customer-search?q=${encodeURIComponent(q)}`);
setResults(data.results || []);
} catch {
setResults([]);
} finally {
setSearching(false);
}
}, [deviceId]);
useEffect(() => {
const t = setTimeout(() => search(query), 250);
return () => clearTimeout(t);
}, [query, search]);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" style={{ backgroundColor: "rgba(0,0,0,0.6)" }}>
<div
className="rounded-xl border w-full max-w-lg flex flex-col"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)", maxHeight: "80vh" }}
>
<div className="flex items-center justify-between px-6 pt-5 pb-4 border-b" style={{ borderColor: "var(--border-secondary)" }}>
<h3 className="text-base font-semibold" style={{ color: "var(--text-heading)" }}>Assign to Customer</h3>
<button type="button" onClick={onCancel} className="text-lg leading-none hover:opacity-70 cursor-pointer" style={{ color: "var(--text-muted)" }}>&#x2715;</button>
</div>
<div className="px-6 pt-4 pb-2">
<div style={{ position: "relative" }}>
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search by name, email, phone, org, tags…"
className="w-full px-3 py-2 rounded-md text-sm border"
style={{ backgroundColor: "var(--bg-input)", borderColor: "var(--border-input)", color: "var(--text-primary)" }}
/>
{searching && (
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-xs" style={{ color: "var(--text-muted)" }}></span>
)}
</div>
</div>
<div className="overflow-y-auto flex-1 px-6 pb-4">
<div className="rounded-md border overflow-hidden" style={{ borderColor: "var(--border-secondary)", minHeight: 60 }}>
{results.length === 0 ? (
<p className="px-3 py-3 text-sm" style={{ color: "var(--text-muted)" }}>
{searching ? "Searching…" : query ? "No customers found." : "Type to search customers…"}
</p>
) : (
results.map((c) => (
<button
key={c.id}
type="button"
onClick={() => onSelect(c)}
className="w-full text-left px-3 py-2.5 text-sm hover:opacity-80 cursor-pointer border-b last:border-b-0 transition-colors"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-secondary)", color: "var(--text-primary)" }}
>
<span className="font-medium block">
{[c.name, c.surname].filter(Boolean).join(" ")}
{c.city && (
<>
<span className="mx-1.5" style={{ color: "var(--text-muted)", fontSize: "8px", verticalAlign: "middle" }}></span>
<span style={{ color: "var(--text-muted)", fontWeight: 400 }}>{c.city}</span>
</>
)}
</span>
{c.organization && (
<span className="text-xs block" style={{ color: "var(--text-muted)" }}>{c.organization}</span>
)}
</button>
))
)}
</div>
</div>
<div className="flex justify-end px-6 py-4 border-t" style={{ borderColor: "var(--border-secondary)" }}>
<button
onClick={onCancel}
className="px-4 py-1.5 text-sm rounded-md hover:opacity-80 cursor-pointer"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)" }}
>
Cancel
</button>
</div>
</div>
</div>
);
}
export default function DeviceDetail() {
const { id } = useParams();
const navigate = useNavigate();
@@ -1630,18 +1729,29 @@ export default function DeviceDetail() {
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
if (d.device_id) {
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 === d.device_id);
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 || []);
@@ -1650,9 +1760,9 @@ export default function DeviceDetail() {
}).finally(() => setUsersLoading(false));
// Fetch manufacturing record + product catalog to resolve hw image
if (d.device_id) {
if (deviceSN) {
Promise.all([
api.get(`/manufacturing/devices/${d.device_id}`).catch(() => null),
api.get(`/manufacturing/devices/${deviceSN}`).catch(() => null),
api.get("/crm/products").catch(() => null),
]).then(([mfgItem, productsRes]) => {
const hwType = mfgItem?.hw_type || "";
@@ -1719,6 +1829,85 @@ export default function DeviceDetail() {
}
};
// --- 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();
@@ -1800,6 +1989,66 @@ export default function DeviceDetail() {
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)" }}>
@@ -1952,7 +2201,7 @@ export default function DeviceDetail() {
<div className="db-row">
<div className="db-info-field">
<span className="db-info-label">SERIAL NUMBER</span>
<span className="db-info-value">{device.device_id || "-"}</span>
<span className="db-info-value">{device.serial_number || device.device_id || "-"}</span>
</div>
</div>
<div className="db-row">
@@ -2115,7 +2364,7 @@ export default function DeviceDetail() {
</div>
<DeviceLogsPanel deviceSerial={device.device_id} />
<DeviceLogsPanel deviceSerial={device.serial_number || device.device_id} />
<div className="dashboard-bottom-grid">
<div className="dashboard-bottom-grid__notes" ref={notesPanelRef}>
@@ -2253,6 +2502,260 @@ export default function DeviceDetail() {
<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>
);
@@ -3039,12 +3542,182 @@ export default function DeviceDetail() {
</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;
};
@@ -3136,7 +3809,7 @@ export default function DeviceDetail() {
<ConfirmDialog
open={showDelete}
title="Delete Device"
message={`Are you sure you want to delete "${device.device_name || "this device"}" (${device.device_id})? This action cannot be undone.`}
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)}
/>