feat: Phase 3 manufacturing + firmware management
This commit is contained in:
331
frontend/src/manufacturing/DeviceInventoryDetail.jsx
Normal file
331
frontend/src/manufacturing/DeviceInventoryDetail.jsx
Normal file
@@ -0,0 +1,331 @@
|
||||
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 = { vs: "Vesper", vp: "Vesper+", vx: "VesperPro" };
|
||||
|
||||
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",
|
||||
];
|
||||
|
||||
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="text-xs font-medium uppercase tracking-wide mb-0.5" style={{ color: "var(--text-muted)" }}>
|
||||
{label}
|
||||
</p>
|
||||
<p
|
||||
className={`text-sm ${mono ? "font-mono" : ""}`}
|
||||
style={{ color: "var(--text-primary)" }}
|
||||
>
|
||||
{value || "—"}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DeviceInventoryDetail() {
|
||||
const { sn } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { hasPermission } = useAuth();
|
||||
const canEdit = hasPermission("manufacturing", "edit");
|
||||
|
||||
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 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 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 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>
|
||||
<button
|
||||
onClick={() => navigate("/manufacturing")}
|
||||
className="text-sm mb-4 hover:opacity-80 cursor-pointer"
|
||||
style={{ color: "var(--text-muted)" }}
|
||||
>
|
||||
← Device Inventory
|
||||
</button>
|
||||
<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 className="max-w-2xl">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<button
|
||||
onClick={() => navigate("/manufacturing")}
|
||||
className="text-sm hover:opacity-80 transition-opacity cursor-pointer"
|
||||
style={{ color: "var(--text-muted)" }}
|
||||
>
|
||||
← Device Inventory
|
||||
</button>
|
||||
<span style={{ color: "var(--text-muted)" }}>/</span>
|
||||
<h1 className="text-xl font-bold font-mono" style={{ color: "var(--text-heading)" }}>
|
||||
{device?.serial_number}
|
||||
</h1>
|
||||
</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="rounded-lg border p-5 mb-4"
|
||||
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
|
||||
>
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wide mb-4" style={{ color: "var(--text-muted)" }}>
|
||||
Device Identity
|
||||
</h2>
|
||||
<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={device?.hw_version ? `v${device.hw_version}` : null} />
|
||||
<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} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status card */}
|
||||
<div
|
||||
className="rounded-lg border p-5 mb-4"
|
||||
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>
|
||||
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="rounded-lg border p-5"
|
||||
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
|
||||
>
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wide mb-3" style={{ color: "var(--text-muted)" }}>
|
||||
Actions
|
||||
</h2>
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user