update: overhauled firmware ui. Added public flash page.
This commit is contained in:
@@ -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)" }}>✕</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)}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user