import { useState, useEffect, useRef, useCallback } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { useAuth } from "../auth/AuthContext";
import api from "../api/client";
const BOARD_TYPE_LABELS = {
vesper: "Vesper", vesper_plus: "Vesper+", vesper_pro: "Vesper Pro",
chronos: "Chronos", chronos_pro: "Chronos Pro", agnus_mini: "Agnus Mini", agnus: "Agnus",
};
const LIFECYCLE = [
{ key: "manufactured", label: "Manufactured", icon: "π©", color: "#94a3b8", bg: "#1e2a3a", accent: "#64748b" },
{ key: "flashed", label: "Flashed", icon: "β‘", color: "#60a5fa", bg: "#1e3a5f", accent: "#3b82f6" },
{ key: "provisioned", label: "Provisioned", icon: "π‘", color: "#34d399", bg: "#064e3b", accent: "#10b981" },
{ key: "sold", label: "Sold", icon: "π¦", color: "#c084fc", bg: "#2e1065", accent: "#a855f7" },
{ key: "claimed", label: "Claimed", icon: "β
", color: "#fb923c", bg: "#431407", accent: "#f97316" },
{ key: "decommissioned", label: "Decommissioned", icon: "π", color: "#f87171", bg: "#450a0a", accent: "#ef4444" },
];
const STEP_INDEX = Object.fromEntries(LIFECYCLE.map((s, i) => [s.key, i]));
const PROTECTED_STATUSES = ["sold", "claimed"];
function formatHwVersion(v) {
if (!v) return "β";
if (/^\d+\.\d+/.test(v)) return `Rev ${v}`;
const n = parseInt(v, 10);
if (!isNaN(n)) return `Rev ${n}.0`;
return `Rev ${v}`;
}
function formatDate(iso) {
if (!iso) return "β";
try {
return new Date(iso).toLocaleString("en-US", {
year: "numeric", month: "short", day: "numeric",
hour: "2-digit", minute: "2-digit",
});
} catch { return iso; }
}
function toDatetimeLocal(iso) {
if (!iso) return "";
try {
const d = new Date(iso);
const pad = (n) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
} catch { return ""; }
}
function Field({ label, value, mono = false }) {
return (
);
}
// βββ User Search Modal (for Claimed) ββββββββββββββββββββββββββββββββββββββββββ
function UserSearchModal({ onSelect, onCancel, existingUsers = [] }) {
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(`/users?search=${encodeURIComponent(q)}&limit=20`);
setResults(data.users || data || []);
} catch {
setResults([]);
} finally {
setSearching(false);
}
}, []);
useEffect(() => {
const t = setTimeout(() => search(query), 300);
return () => clearTimeout(t);
}, [query, search]);
return (
β
Set as Claimed
Assign a User
A device is "Claimed" when a registered user has been assigned to it. Search and select the user to assign.
{/* Keep existing users option */}
{existingUsers.length > 0 && (
Already assigned
{existingUsers.map((u) => (
))}
or assign a different user
)}
setQuery(e.target.value)}
placeholder="Search by name or emailβ¦"
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 && β¦}
{results.length === 0 ? (
{searching ? "Searchingβ¦" : query ? "No users found." : "Type to search usersβ¦"}
) : results.map((u) => (
))}
);
}
// βββ Customer Search Modal βββββββββββββββββββββββββββββββββββββββββββββββββ
function CustomerSearchModal({ 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(`/manufacturing/customers/search?q=${encodeURIComponent(q)}`);
setResults(data.results || []);
} catch {
setResults([]);
} finally {
setSearching(false);
}
}, []);
useEffect(() => {
const t = setTimeout(() => search(query), 250);
return () => clearTimeout(t);
}, [query, search]);
return (
Assign to Customer
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 && β¦}
{results.length === 0 ? (
{searching ? "Searchingβ¦" : query ? "No customers found." : "Type to search customersβ¦"}
) : results.map((c) => {
const fullName = [c.name, c.surname].filter(Boolean).join(" ") || c.email || c.id;
return (
);
})}
);
}
// βββ Delete Device Modal ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
function DeleteModal({ device, onConfirm, onCancel, deleting }) {
const isProtected = PROTECTED_STATUSES.includes(device?.mfg_status);
const [typed, setTyped] = useState("");
const confirmed = !isProtected || typed === device?.serial_number;
return (
{isProtected ? "β Delete Protected Device" : "Delete Device"}
{isProtected ? (
<>
This device has status {device.mfg_status} and is linked to a customer. Deleting it is irreversible.
To confirm, type the serial number exactly:
{device.serial_number}
setTyped(e.target.value)} onPaste={(e) => e.preventDefault()}
placeholder="Type serial number hereβ¦" autoComplete="off"
className="w-full px-3 py-2 rounded-md text-sm border mb-4 font-mono"
style={{ backgroundColor: "var(--bg-input)", borderColor: typed === device.serial_number ? "var(--accent)" : "var(--border-input)", color: "var(--text-primary)" }} />
>
) : (
Are you sure you want to delete {device?.serial_number}? This cannot be undone.
)}
);
}
// βββ Lifecycle Entry Edit Modal ββββββββββββββββββββββββββββββββββββββββββββββββ
function LifecycleEditModal({ entry, stepMeta, isCurrent, onSave, onDelete, onCancel }) {
const isNew = !entry;
const [dateVal, setDateVal] = useState(() => isNew ? toDatetimeLocal(new Date().toISOString()) : toDatetimeLocal(entry?.date));
const [note, setNote] = useState(entry?.note || "");
const [saving, setSaving] = useState(false);
const [deleting, setDeleting] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const handleSave = async () => {
setSaving(true);
try {
const isoDate = dateVal ? new Date(dateVal).toISOString() : new Date().toISOString();
await onSave(isoDate, note);
} finally {
setSaving(false);
}
};
const handleDelete = async () => {
setDeleting(true);
try {
await onDelete();
} finally {
setDeleting(false);
setConfirmDelete(false);
}
};
return (
{/* Header */}
{stepMeta.icon}
{isNew ? "Create Step" : "Edit Step"}
{stepMeta.label}
{/* Date field */}
setDateVal(e.target.value)}
className="w-full px-3 py-2 rounded-lg text-sm border"
style={{ backgroundColor: "var(--bg-input)", borderColor: "var(--border-input)", color: "var(--text-primary)" }}
/>
{/* Note field */}
{/* Delete confirm */}
{confirmDelete && (
Delete this step record permanently?
)}
{/* Delete button β only for existing entries that are NOT current */}
{!isNew && !isCurrent && !confirmDelete ? (
) :
}
);
}
// βββ Lifecycle Ladder ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
function LifecycleLadder({ device, canEdit, onStatusChange, onEditEntry, statusError }) {
const currentIndex = STEP_INDEX[device?.mfg_status] ?? 0;
const history = device?.lifecycle_history || [];
const [hoveredIndex, setHoveredIndex] = useState(null);
// Map status_id -> last entry in history
const historyMap = {};
history.forEach((entry) => {
historyMap[entry.status_id] = entry;
});
return (
{statusError && (
{statusError}
)}
{LIFECYCLE.map((step, i) => {
const isCurrent = i === currentIndex;
const isPast = i < currentIndex;
const isFuture = i > currentIndex;
const isLast = i === LIFECYCLE.length - 1;
const entry = historyMap[step.key];
const hasData = !!entry;
const isHovered = hoveredIndex === i;
return (
{/* Left rail */}
{/* Step circle β clicking handled on the card now */}
{isPast && (
)}
{step.icon}
{/* Connector line */}
{!isLast && (
)}
{/* Right clickable card */}
setHoveredIndex(i)}
onMouseLeave={() => setHoveredIndex(null)}
onClick={(e) => {
// Don't trigger if clicking the EDIT button (it has its own handler)
if (e.target.closest("[data-edit-btn]")) return;
if (!canEdit) return;
onStatusChange(step.key);
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
if (!canEdit) return;
onStatusChange(step.key);
}
}}
style={{
flex: 1,
marginLeft: 10,
marginBottom: isLast ? 0 : 6,
padding: "10px 12px",
borderRadius: 10,
border: `1px solid ${
isCurrent ? step.accent + "60"
: isHovered && canEdit ? step.accent + "35"
: isPast ? step.accent + "25"
: "var(--border-secondary)"
}`,
backgroundColor: isCurrent
? step.bg
: isHovered && canEdit
? step.bg + "55"
: isPast
? step.bg + "35"
: "transparent",
opacity: isFuture && !isHovered ? 0.38 : 1,
transition: "all 0.18s",
cursor: canEdit ? "pointer" : "default",
position: "relative",
display: "flex",
alignItems: hasData ? "flex-start" : "center",
justifyContent: "space-between",
gap: 8,
minHeight: 44,
}}
>
{/* Content */}
{/* Label row */}
{step.label}
{isCurrent && (
CURRENT
)}
{/* Date + set_by */}
{hasData && (
{formatDate(entry.date)}
{entry.set_by && Β· {entry.set_by}}
)}
{/* Note */}
{entry?.note && (
"{entry.note}"
)}
{/* EDIT button β only visible on hover */}
{canEdit && (
)}
);
})}
);
}
// βββ Main Component βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
export default function DeviceInventoryDetail() {
const { sn } = useParams();
const navigate = useNavigate();
const { hasPermission } = useAuth();
const canEdit = hasPermission("manufacturing", "edit");
const canDelete = hasPermission("manufacturing", "delete");
const [device, setDevice] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [statusSaving, setStatusSaving] = useState(false);
const [statusError, setStatusError] = useState("");
const [nvsDownloading, setNvsDownloading] = useState(false);
const [assignedCustomer, setAssignedCustomer] = useState(null);
const [assignSaving, setAssignSaving] = useState(false);
const [assignError, setAssignError] = useState("");
const [showCustomerModal, setShowCustomerModal] = useState(false);
// Claimed user assignment
const [showUserModal, setShowUserModal] = useState(false);
const [userSaving, setUserSaving] = useState(false);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [deleting, setDeleting] = useState(false);
// Resolved user info for the assigned users section
const [resolvedUsers, setResolvedUsers] = useState([]); // [{uid, display_name, email}]
// Lifecycle edit modal: { stepIndex, entry|null }
const [editModalData, setEditModalData] = useState(null);
const loadDevice = async () => {
setLoading(true);
setError("");
try {
const data = await api.get(`/manufacturing/devices/${sn}`);
setDevice(data);
if (data.customer_id) fetchCustomerDetails(data.customer_id);
if (data.user_list?.length) fetchResolvedUsers(data.user_list);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
const fetchCustomerDetails = async (customerId) => {
try {
const data = await api.get(`/manufacturing/customers/${customerId}`);
setAssignedCustomer(data || null);
} catch {
setAssignedCustomer(null);
}
};
const fetchResolvedUsers = async (uidList) => {
const results = await Promise.all(
uidList.map(async (uid) => {
try {
const u = await api.get(`/users/${uid}`);
return { uid, display_name: u.display_name || "", email: u.email || "" };
} catch {
return { uid, display_name: "", email: "" };
}
})
);
setResolvedUsers(results);
};
useEffect(() => { loadDevice(); }, [sn]);
// ββ Status change ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
const handleStatusChange = async (newStatus) => {
if (newStatus === device?.mfg_status) return; // already current
if (newStatus === "claimed") {
// Open user-assign flow: user must pick a user first
setShowUserModal(true);
return;
}
if (newStatus === "sold" && !device?.customer_id) {
setShowCustomerModal(true);
return;
}
setStatusError("");
setStatusSaving(true);
try {
const updated = await api.request(`/manufacturing/devices/${sn}/status`, {
method: "PATCH",
body: JSON.stringify({ status: newStatus }),
});
setDevice(updated);
} catch (err) {
setStatusError(err.message);
} finally {
setStatusSaving(false);
}
};
// ββ Claimed: assign user then set status βββββββββββββββββββββββββββββββββββ
const handleClaimedUserSelect = async (user, keepExisting = false) => {
setShowUserModal(false);
setUserSaving(true);
setStatusError("");
try {
// 1. Add user to device's user_list (skip if keeping the existing user)
if (!keepExisting) {
await api.request(`/devices/${device.id}/users`, {
method: "POST",
body: JSON.stringify({ user_id: user.id || user.uid }),
});
}
// 2. Set status to claimed (backend guard now passes since user_list is non-empty)
const updated = await api.request(`/manufacturing/devices/${sn}/status`, {
method: "PATCH",
body: JSON.stringify({ status: "claimed", force_claimed: true }),
});
setDevice(updated);
if (updated.user_list?.length) fetchResolvedUsers(updated.user_list);
} catch (err) {
setStatusError(err.message);
} finally {
setUserSaving(false);
}
};
// ββ Customer assign ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
const handleSelectCustomer = async (customer) => {
setShowCustomerModal(false);
setAssignError("");
setAssignSaving(true);
try {
const updated = await api.request(`/manufacturing/devices/${sn}/assign`, {
method: "POST",
body: JSON.stringify({ customer_id: customer.id }),
});
setDevice(updated);
setAssignedCustomer(customer);
} catch (err) {
setAssignError(err.message);
} finally {
setAssignSaving(false);
}
};
// ββ NVS download βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
const downloadNvs = async () => {
setNvsDownloading(true);
try {
const token = localStorage.getItem("access_token");
const response = await fetch(`/api/manufacturing/devices/${sn}/nvs.bin`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!response.ok) {
const err = await response.json().catch(() => ({}));
throw new Error(err.detail || "Download failed");
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${sn}_nvs.bin`;
a.click();
URL.revokeObjectURL(url);
} catch (err) {
setError(err.message);
} finally {
setNvsDownloading(false);
}
};
// ββ Device delete ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
const handleDelete = async () => {
setDeleting(true);
try {
const isProtected = PROTECTED_STATUSES.includes(device?.mfg_status);
const url = `/manufacturing/devices/${sn}${isProtected ? "?force=true" : ""}`;
await api.request(url, { method: "DELETE" });
navigate("/manufacturing");
} catch (err) {
setError(err.message);
setShowDeleteModal(false);
} finally {
setDeleting(false);
}
};
// ββ Lifecycle edit helpers βββββββββββββββββββββββββββββββββββββββββββββββββ
// Build step_key -> last array index in lifecycle_history
const history = device?.lifecycle_history || [];
const historyIndexMap = {};
history.forEach((entry, idx) => { historyIndexMap[entry.status_id] = idx; });
const handleEditEntryByStep = (stepIndex, entry) => {
setEditModalData({ stepIndex, entry });
};
const handleSaveLifecycle = async (stepIndex, date, note) => {
const stepKey = LIFECYCLE[stepIndex]?.key;
const arrayIndex = historyIndexMap[stepKey];
if (arrayIndex === undefined) {
// No existing entry β create on the fly
const updated = await api.request(`/manufacturing/devices/${sn}/lifecycle`, {
method: "POST",
body: JSON.stringify({ status_id: stepKey, date, note }),
});
setDevice(updated);
} else {
// Edit existing
const updated = await api.request(`/manufacturing/devices/${sn}/lifecycle`, {
method: "PATCH",
body: JSON.stringify({ index: arrayIndex, date, note }),
});
setDevice(updated);
}
setEditModalData(null);
};
const handleDeleteLifecycleStep = async (stepIndex) => {
const stepKey = LIFECYCLE[stepIndex]?.key;
const arrayIndex = historyIndexMap[stepKey];
if (arrayIndex === undefined) { setEditModalData(null); return; }
const updated = await api.request(`/manufacturing/devices/${sn}/lifecycle/${arrayIndex}`, {
method: "DELETE",
});
setDevice(updated);
setEditModalData(null);
};
// ββ Early returns ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
if (loading) {
return (
);
}
if (error && !device) {
return (
{error}
);
}
const currentStepKey = device?.mfg_status;
const editStepMeta = editModalData ? LIFECYCLE[editModalData.stepIndex] : null;
const editIsCurrent = editModalData ? LIFECYCLE[editModalData.stepIndex]?.key === currentStepKey : false;
const userList = device?.user_list || [];
return (
{/* Title row */}
{device?.serial_number}
{device?.device_name && (
{device.device_name}
)}
{canDelete && (
)}
{error && (
{error}
)}
{/* 2-column 50/50 grid */}
{/* ββ LEFT COLUMN ββββββββββββββββββββββββββββββββββββββββββββββββββββ */}
{/* Device Identity */}
Device Identity
{device?.device_name && }
{/* Assign to Customer */}
{canEdit && (
Assign to Customer
{assignError && (
{assignError}
)}
{device?.customer_id ? (
{(assignedCustomer?.name || "?")[0].toUpperCase()}
{[assignedCustomer?.name, assignedCustomer?.surname].filter(Boolean).join(" ") || "Customer"}
{assignedCustomer?.organization &&
{assignedCustomer.organization}
}
{assignedCustomer?.city &&
{assignedCustomer.city}
}
) : (
No customer assigned. Assigning a customer will set the device status to Sold.
)}
)}
{/* Assigned Users */}
Assigned Users
{userList.length === 0 ? (
No users assigned to this device.
) : (
{userList.map((uid) => {
const resolved = resolvedUsers.find((u) => u.uid === uid);
const displayName = resolved?.display_name || "";
const email = resolved?.email || "";
const initials = (displayName || email || uid)[0]?.toUpperCase() || "U";
return (
{initials}
{displayName
?
{displayName}
:
{uid}
}
{email &&
{email}
}
);
})}
)}
{/* ββ RIGHT COLUMN: Lifecycle βββββββββββββββββββββββββββββββββββββββββ */}
Product Lifecycle
{(statusSaving || userSaving) && (
Savingβ¦
)}
{/* NVS Binary */}
NVS Binary
Encodes serial_number, hw_family, hw_version. Flash at 0x9000.
{/* ββ Modals ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */}
{showCustomerModal && (
setShowCustomerModal(false)} />
)}
{showUserModal && (
setShowUserModal(false)}
existingUsers={resolvedUsers}
/>
)}
{showDeleteModal && (
setShowDeleteModal(false)} deleting={deleting} />
)}
{editModalData && editStepMeta && (
handleSaveLifecycle(editModalData.stepIndex, date, note)}
onDelete={() => handleDeleteLifecycleStep(editModalData.stepIndex)}
onCancel={() => setEditModalData(null)}
/>
)}
);
}