486 lines
18 KiB
JavaScript
486 lines
18 KiB
JavaScript
import { useState, useEffect } 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 STATUS_STYLES = {
|
|
manufactured: { bg: "var(--bg-card-hover)", color: "var(--text-muted)" },
|
|
flashed: { bg: "var(--badge-blue-bg)", color: "var(--badge-blue-text)" },
|
|
provisioned: { bg: "#0a2e2a", color: "#4dd6c8" },
|
|
sold: { bg: "#1e1036", color: "#c084fc" },
|
|
claimed: { bg: "#2e1a00", color: "#fb923c" },
|
|
decommissioned:{ bg: "var(--danger-bg)", color: "var(--danger-text)" },
|
|
};
|
|
|
|
const STATUS_OPTIONS = [
|
|
"manufactured", "flashed", "provisioned", "sold", "claimed", "decommissioned",
|
|
];
|
|
|
|
// Statuses that require serial confirmation before delete
|
|
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 StatusBadge({ status }) {
|
|
const style = STATUS_STYLES[status] || STATUS_STYLES.manufactured;
|
|
return (
|
|
<span
|
|
className="px-2.5 py-1 text-sm rounded-full capitalize font-medium"
|
|
style={{ backgroundColor: style.bg, color: style.color }}
|
|
>
|
|
{status}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
function Field({ label, value, mono = false }) {
|
|
return (
|
|
<div>
|
|
<p className="ui-field-label mb-0.5">{label}</p>
|
|
<p className={`text-sm ${mono ? "font-mono" : ""}`} style={{ color: "var(--text-primary)" }}>
|
|
{value || "—"}
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Delete Confirmation 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 (
|
|
<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(--danger)" }}
|
|
>
|
|
<h2 className="text-base font-bold mb-2" style={{ color: "var(--danger-text)" }}>
|
|
{isProtected ? "⚠ Delete Protected Device" : "Delete Device"}
|
|
</h2>
|
|
|
|
{isProtected ? (
|
|
<>
|
|
<p className="text-sm mb-3" style={{ color: "var(--text-secondary)" }}>
|
|
This device has status <strong>{device.mfg_status}</strong> and is linked to a customer.
|
|
Deleting it is irreversible and may break the customer's access.
|
|
</p>
|
|
<p className="text-sm mb-3" style={{ color: "var(--text-secondary)" }}>
|
|
To confirm, type the serial number exactly:
|
|
</p>
|
|
<p className="font-mono text-sm mb-3 px-3 py-2 rounded" style={{ backgroundColor: "var(--bg-primary)", color: "var(--text-primary)" }}>
|
|
{device.serial_number}
|
|
</p>
|
|
<input
|
|
type="text"
|
|
value={typed}
|
|
onChange={(e) => 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)",
|
|
}}
|
|
/>
|
|
</>
|
|
) : (
|
|
<p className="text-sm mb-4" style={{ color: "var(--text-secondary)" }}>
|
|
Are you sure you want to delete <strong className="font-mono">{device?.serial_number}</strong>?
|
|
This cannot be undone.
|
|
</p>
|
|
)}
|
|
|
|
<div className="flex gap-3 justify-end">
|
|
<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>
|
|
<button
|
|
onClick={onConfirm}
|
|
disabled={!confirmed || deleting}
|
|
className="px-4 py-1.5 text-sm rounded-md font-medium hover:opacity-90 cursor-pointer disabled:opacity-40"
|
|
style={{ backgroundColor: "var(--danger)", color: "#fff" }}
|
|
>
|
|
{deleting ? "Deleting…" : "Delete Device"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── 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 [editingStatus, setEditingStatus] = useState(false);
|
|
const [newStatus, setNewStatus] = useState("");
|
|
const [statusNote, setStatusNote] = useState("");
|
|
const [statusSaving, setStatusSaving] = useState(false);
|
|
const [statusError, setStatusError] = useState("");
|
|
|
|
const [nvsDownloading, setNvsDownloading] = useState(false);
|
|
|
|
const [assignEmail, setAssignEmail] = useState("");
|
|
const [assignName, setAssignName] = useState("");
|
|
const [assignSaving, setAssignSaving] = useState(false);
|
|
const [assignError, setAssignError] = useState("");
|
|
const [assignSuccess, setAssignSuccess] = useState(false);
|
|
|
|
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
|
const [deleting, setDeleting] = useState(false);
|
|
|
|
const loadDevice = async () => {
|
|
setLoading(true);
|
|
setError("");
|
|
try {
|
|
const data = await api.get(`/manufacturing/devices/${sn}`);
|
|
setDevice(data);
|
|
} catch (err) {
|
|
setError(err.message);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => { loadDevice(); }, [sn]);
|
|
|
|
const handleStatusSave = async () => {
|
|
setStatusError("");
|
|
setStatusSaving(true);
|
|
try {
|
|
const updated = await api.request(`/manufacturing/devices/${sn}/status`, {
|
|
method: "PATCH",
|
|
body: JSON.stringify({ status: newStatus, note: statusNote || null }),
|
|
});
|
|
setDevice(updated);
|
|
setEditingStatus(false);
|
|
setStatusNote("");
|
|
} catch (err) {
|
|
setStatusError(err.message);
|
|
} finally {
|
|
setStatusSaving(false);
|
|
}
|
|
};
|
|
|
|
const handleAssign = async () => {
|
|
setAssignError("");
|
|
setAssignSaving(true);
|
|
try {
|
|
const updated = await api.request(`/manufacturing/devices/${sn}/assign`, {
|
|
method: "POST",
|
|
body: JSON.stringify({ customer_email: assignEmail, customer_name: assignName || null }),
|
|
});
|
|
setDevice(updated);
|
|
setAssignSuccess(true);
|
|
setAssignEmail("");
|
|
setAssignName("");
|
|
} catch (err) {
|
|
setAssignError(err.message);
|
|
} finally {
|
|
setAssignSaving(false);
|
|
}
|
|
};
|
|
|
|
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);
|
|
}
|
|
};
|
|
|
|
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);
|
|
}
|
|
};
|
|
|
|
const 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; }
|
|
};
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="flex items-center justify-center py-20">
|
|
<p className="text-sm" style={{ color: "var(--text-muted)" }}>Loading…</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (error && !device) {
|
|
return (
|
|
<div>
|
|
<div
|
|
className="text-sm rounded-md p-4 border"
|
|
style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}
|
|
>
|
|
{error}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div style={{ maxWidth: 640 }}>
|
|
{/* Title row */}
|
|
<div className="flex items-start justify-between mb-6">
|
|
<div>
|
|
<h1 className="text-2xl font-bold font-mono" style={{ color: "var(--text-heading)" }}>
|
|
{device?.serial_number}
|
|
</h1>
|
|
{device?.device_name && (
|
|
<p className="text-sm mt-0.5" style={{ color: "var(--text-muted)" }}>{device.device_name}</p>
|
|
)}
|
|
</div>
|
|
{canDelete && (
|
|
<button
|
|
onClick={() => setShowDeleteModal(true)}
|
|
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-md font-medium hover:opacity-80 cursor-pointer transition-opacity"
|
|
style={{ backgroundColor: "var(--danger-bg)", color: "var(--danger-text)", border: "1px solid var(--danger)" }}
|
|
>
|
|
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
|
</svg>
|
|
Delete Device
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{error && (
|
|
<div className="text-sm rounded-md p-3 mb-4 border"
|
|
style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}>
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
{/* Identity card */}
|
|
<div className="ui-section-card mb-4">
|
|
<div className="ui-section-card__title-row">
|
|
<h2 className="ui-section-card__title">Device Identity</h2>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<Field label="Serial Number" value={device?.serial_number} mono />
|
|
<Field label="Board Type" value={BOARD_TYPE_LABELS[device?.hw_type] || device?.hw_type} />
|
|
<Field label="HW Version" value={formatHwVersion(device?.hw_version)} />
|
|
<Field label="Batch ID" value={device?.mfg_batch_id} mono />
|
|
<Field label="Created At" value={formatDate(device?.created_at)} />
|
|
<Field label="Owner" value={device?.owner} />
|
|
{device?.device_name && (
|
|
<Field label="Device Name" value={device.device_name} />
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Status card */}
|
|
<div className="ui-section-card mb-4">
|
|
<div className="ui-section-card__title-row">
|
|
<h2 className="ui-section-card__title">Status</h2>
|
|
{canEdit && !editingStatus && (
|
|
<button
|
|
onClick={() => { setNewStatus(device.mfg_status); setEditingStatus(true); }}
|
|
className="text-xs hover:opacity-80 cursor-pointer"
|
|
style={{ color: "var(--text-link)" }}
|
|
>
|
|
Change
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{!editingStatus ? (
|
|
<StatusBadge status={device?.mfg_status} />
|
|
) : (
|
|
<div className="space-y-3">
|
|
{statusError && (
|
|
<div className="text-xs rounded p-2 border"
|
|
style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}>
|
|
{statusError}
|
|
</div>
|
|
)}
|
|
<select
|
|
value={newStatus}
|
|
onChange={(e) => setNewStatus(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)" }}
|
|
>
|
|
{STATUS_OPTIONS.map((s) => <option key={s} value={s}>{s}</option>)}
|
|
</select>
|
|
<input
|
|
type="text"
|
|
placeholder="Optional note…"
|
|
value={statusNote}
|
|
onChange={(e) => setStatusNote(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)" }}
|
|
/>
|
|
<div className="flex gap-2">
|
|
<button
|
|
onClick={handleStatusSave}
|
|
disabled={statusSaving}
|
|
className="px-4 py-1.5 text-sm rounded-md hover:opacity-90 transition-opacity cursor-pointer disabled:opacity-50"
|
|
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
|
|
>
|
|
{statusSaving ? "Saving…" : "Save"}
|
|
</button>
|
|
<button
|
|
onClick={() => { setEditingStatus(false); setStatusError(""); }}
|
|
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>
|
|
|
|
{/* Actions card */}
|
|
<div className="ui-section-card mb-4">
|
|
<div className="ui-section-card__title-row">
|
|
<h2 className="ui-section-card__title">Actions</h2>
|
|
</div>
|
|
<div className="flex flex-wrap gap-3">
|
|
<button
|
|
onClick={downloadNvs}
|
|
disabled={nvsDownloading}
|
|
className="px-4 py-2 text-sm rounded-md hover:opacity-90 transition-opacity cursor-pointer disabled:opacity-50 flex items-center gap-2"
|
|
style={{ backgroundColor: "var(--badge-blue-bg)", color: "var(--badge-blue-text)" }}
|
|
>
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
|
</svg>
|
|
{nvsDownloading ? "Generating…" : "Download NVS Binary"}
|
|
</button>
|
|
</div>
|
|
<p className="text-xs mt-2" style={{ color: "var(--text-muted)" }}>
|
|
NVS binary encodes device_uid, hw_type, hw_version. Flash at 0x9000.
|
|
</p>
|
|
</div>
|
|
|
|
{/* Assign to Customer card */}
|
|
{canEdit && ["provisioned", "flashed"].includes(device?.mfg_status) && (
|
|
<div className="ui-section-card">
|
|
<div className="ui-section-card__title-row">
|
|
<h2 className="ui-section-card__title">Assign to Customer</h2>
|
|
</div>
|
|
{assignSuccess ? (
|
|
<div className="text-sm rounded-md p-3 border"
|
|
style={{ backgroundColor: "#0a2e2a", borderColor: "#4dd6c8", color: "#4dd6c8" }}>
|
|
Device assigned and invitation email sent to <strong>{device?.owner}</strong>.
|
|
</div>
|
|
) : (
|
|
<div className="space-y-3">
|
|
{assignError && (
|
|
<div className="text-xs rounded p-2 border"
|
|
style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}>
|
|
{assignError}
|
|
</div>
|
|
)}
|
|
<input
|
|
type="email"
|
|
placeholder="Customer email address"
|
|
value={assignEmail}
|
|
onChange={(e) => setAssignEmail(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)" }}
|
|
/>
|
|
<input
|
|
type="text"
|
|
placeholder="Customer name (optional)"
|
|
value={assignName}
|
|
onChange={(e) => setAssignName(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)" }}
|
|
/>
|
|
<button
|
|
onClick={handleAssign}
|
|
disabled={assignSaving || !assignEmail.trim()}
|
|
className="px-4 py-1.5 text-sm rounded-md hover:opacity-90 transition-opacity cursor-pointer disabled:opacity-50"
|
|
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
|
|
>
|
|
{assignSaving ? "Sending…" : "Assign & Send Invite"}
|
|
</button>
|
|
<p className="text-xs" style={{ color: "var(--text-muted)" }}>
|
|
Sets device status to <em>sold</em> and emails the customer their serial number.
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Delete modal */}
|
|
{showDeleteModal && (
|
|
<DeleteModal
|
|
device={device}
|
|
onConfirm={handleDelete}
|
|
onCancel={() => setShowDeleteModal(false)}
|
|
deleting={deleting}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|