feat: Phase 3 manufacturing + firmware management

This commit is contained in:
2026-02-27 02:47:08 +02:00
parent 2f610633c4
commit 32a2634739
25 changed files with 2266 additions and 52 deletions

View File

@@ -24,6 +24,10 @@ import NoteForm from "./equipment/NoteForm";
import StaffList from "./settings/StaffList";
import StaffDetail from "./settings/StaffDetail";
import StaffForm from "./settings/StaffForm";
import DeviceInventory from "./manufacturing/DeviceInventory";
import BatchCreator from "./manufacturing/BatchCreator";
import DeviceInventoryDetail from "./manufacturing/DeviceInventoryDetail";
import FirmwareManager from "./firmware/FirmwareManager";
function ProtectedRoute({ children }) {
const { user, loading } = useAuth();
@@ -149,6 +153,12 @@ export default function App() {
<Route path="equipment/notes/:id" element={<PermissionGate section="equipment"><NoteDetail /></PermissionGate>} />
<Route path="equipment/notes/:id/edit" element={<PermissionGate section="equipment" action="edit"><NoteForm /></PermissionGate>} />
{/* Manufacturing */}
<Route path="manufacturing" element={<PermissionGate section="manufacturing"><DeviceInventory /></PermissionGate>} />
<Route path="manufacturing/batch/new" element={<PermissionGate section="manufacturing" action="add"><BatchCreator /></PermissionGate>} />
<Route path="manufacturing/devices/:sn" element={<PermissionGate section="manufacturing"><DeviceInventoryDetail /></PermissionGate>} />
<Route path="firmware" element={<PermissionGate section="manufacturing"><FirmwareManager /></PermissionGate>} />
{/* Settings - Staff Management */}
<Route path="settings/staff" element={<RoleGate roles={["sysadmin", "admin"]}><StaffList /></RoleGate>} />
<Route path="settings/staff/new" element={<RoleGate roles={["sysadmin", "admin"]}><StaffForm /></RoleGate>} />

View File

@@ -0,0 +1,488 @@
import { useState, useEffect, useRef } from "react";
import { useAuth } from "../auth/AuthContext";
import api from "../api/client";
const BOARD_TYPES = [
{ value: "vs", label: "Vesper (VS)" },
{ value: "vp", label: "Vesper+ (VP)" },
{ value: "vx", label: "VesperPro (VX)" },
];
const CHANNELS = ["stable", "beta", "alpha", "testing"];
const CHANNEL_STYLES = {
stable: { bg: "var(--success-bg)", color: "var(--success-text)" },
beta: { bg: "var(--badge-blue-bg)", color: "var(--badge-blue-text)" },
alpha: { bg: "#2e1a00", color: "#fb923c" },
testing: { bg: "var(--bg-card-hover)", color: "var(--text-muted)" },
};
function ChannelBadge({ channel }) {
const style = CHANNEL_STYLES[channel] || CHANNEL_STYLES.testing;
return (
<span
className="px-2 py-0.5 text-xs rounded-full capitalize font-medium"
style={{ backgroundColor: style.bg, color: style.color }}
>
{channel}
</span>
);
}
function formatBytes(bytes) {
if (!bytes) return "—";
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
}
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;
}
}
export default function FirmwareManager() {
const { hasPermission } = useAuth();
const canAdd = hasPermission("manufacturing", "add");
const canDelete = hasPermission("manufacturing", "delete");
const [firmware, setFirmware] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [hwTypeFilter, setHwTypeFilter] = useState("");
const [channelFilter, setChannelFilter] = useState("");
const [showUpload, setShowUpload] = useState(false);
const [uploadHwType, setUploadHwType] = useState("vs");
const [uploadChannel, setUploadChannel] = useState("stable");
const [uploadVersion, setUploadVersion] = useState("");
const [uploadNotes, setUploadNotes] = useState("");
const [uploadFile, setUploadFile] = useState(null);
const [uploading, setUploading] = useState(false);
const [uploadError, setUploadError] = useState("");
const fileInputRef = useRef(null);
const [deleteTarget, setDeleteTarget] = useState(null);
const [deleting, setDeleting] = useState(false);
const fetchFirmware = async () => {
setLoading(true);
setError("");
try {
const params = new URLSearchParams();
if (hwTypeFilter) params.set("hw_type", hwTypeFilter);
if (channelFilter) params.set("channel", channelFilter);
const qs = params.toString();
const data = await api.get(`/firmware${qs ? `?${qs}` : ""}`);
setFirmware(data.firmware);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchFirmware();
}, [hwTypeFilter, channelFilter]);
const handleUpload = async (e) => {
e.preventDefault();
if (!uploadFile) return;
setUploadError("");
setUploading(true);
try {
const formData = new FormData();
formData.append("hw_type", uploadHwType);
formData.append("channel", uploadChannel);
formData.append("version", uploadVersion);
if (uploadNotes) formData.append("notes", uploadNotes);
formData.append("file", uploadFile);
const token = localStorage.getItem("access_token");
const response = await fetch("/api/firmware/upload", {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
body: formData,
});
if (!response.ok) {
const err = await response.json().catch(() => ({}));
throw new Error(err.detail || "Upload failed");
}
setShowUpload(false);
setUploadVersion("");
setUploadNotes("");
setUploadFile(null);
if (fileInputRef.current) fileInputRef.current.value = "";
await fetchFirmware();
} catch (err) {
setUploadError(err.message);
} finally {
setUploading(false);
}
};
const handleDelete = async () => {
if (!deleteTarget) return;
setDeleting(true);
try {
await api.delete(`/firmware/${deleteTarget.id}`);
setDeleteTarget(null);
await fetchFirmware();
} catch (err) {
setError(err.message);
} finally {
setDeleting(false);
}
};
const BOARD_TYPE_LABELS = { vs: "Vesper", vp: "Vesper+", vx: "VesperPro" };
return (
<div>
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold" style={{ color: "var(--text-heading)" }}>
Firmware
</h1>
<p className="text-sm mt-0.5" style={{ color: "var(--text-muted)" }}>
{firmware.length} version{firmware.length !== 1 ? "s" : ""}
{hwTypeFilter || channelFilter ? " (filtered)" : ""}
</p>
</div>
{canAdd && (
<button
onClick={() => setShowUpload(true)}
className="px-4 py-2 text-sm rounded-md font-medium hover:opacity-90 transition-opacity cursor-pointer"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
>
+ Upload Firmware
</button>
)}
</div>
{/* Upload form */}
{showUpload && (
<div
className="rounded-lg border p-5 mb-5"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
>
<h2 className="text-base font-semibold mb-4" style={{ color: "var(--text-heading)" }}>
Upload New Firmware
</h2>
{uploadError && (
<div
className="text-sm rounded-md p-3 mb-3 border"
style={{
backgroundColor: "var(--danger-bg)",
borderColor: "var(--danger)",
color: "var(--danger-text)",
}}
>
{uploadError}
</div>
)}
<form onSubmit={handleUpload} className="space-y-3">
<div className="grid grid-cols-3 gap-3">
<div>
<label className="block text-xs font-medium mb-1" style={{ color: "var(--text-muted)" }}>
Board Type
</label>
<select
value={uploadHwType}
onChange={(e) => setUploadHwType(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)",
}}
>
{BOARD_TYPES.map((bt) => (
<option key={bt.value} value={bt.value}>{bt.label}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium mb-1" style={{ color: "var(--text-muted)" }}>
Channel
</label>
<select
value={uploadChannel}
onChange={(e) => setUploadChannel(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)",
}}
>
{CHANNELS.map((c) => (
<option key={c} value={c}>{c}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium mb-1" style={{ color: "var(--text-muted)" }}>
Version
</label>
<input
type="text"
value={uploadVersion}
onChange={(e) => setUploadVersion(e.target.value)}
placeholder="1.4.2"
required
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>
</div>
<div>
<label className="block text-xs font-medium mb-1" style={{ color: "var(--text-muted)" }}>
firmware.bin
</label>
<input
ref={fileInputRef}
type="file"
accept=".bin"
required
onChange={(e) => setUploadFile(e.target.files[0] || null)}
className="w-full text-sm"
style={{ color: "var(--text-primary)" }}
/>
</div>
<div>
<label className="block text-xs font-medium mb-1" style={{ color: "var(--text-muted)" }}>
Release Notes (optional)
</label>
<textarea
value={uploadNotes}
onChange={(e) => setUploadNotes(e.target.value)}
rows={2}
placeholder="What changed in this version?"
className="w-full px-3 py-2 rounded-md text-sm border resize-none"
style={{
backgroundColor: "var(--bg-input)",
borderColor: "var(--border-input)",
color: "var(--text-primary)",
}}
/>
</div>
<div className="flex gap-3 pt-1">
<button
type="submit"
disabled={uploading}
className="px-4 py-2 text-sm rounded-md font-medium hover:opacity-90 transition-opacity cursor-pointer disabled:opacity-50"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
>
{uploading ? "Uploading…" : "Upload"}
</button>
<button
type="button"
onClick={() => { setShowUpload(false); setUploadError(""); }}
className="px-4 py-2 text-sm rounded-md hover:opacity-80 cursor-pointer"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)" }}
>
Cancel
</button>
</div>
</form>
</div>
)}
{/* Filters */}
<div className="flex gap-3 mb-4">
<select
value={hwTypeFilter}
onChange={(e) => setHwTypeFilter(e.target.value)}
className="px-3 py-2 rounded-md text-sm border"
style={{
backgroundColor: "var(--bg-input)",
borderColor: "var(--border-input)",
color: "var(--text-primary)",
}}
>
<option value="">All Types</option>
{BOARD_TYPES.map((bt) => (
<option key={bt.value} value={bt.value}>{bt.label}</option>
))}
</select>
<select
value={channelFilter}
onChange={(e) => setChannelFilter(e.target.value)}
className="px-3 py-2 rounded-md text-sm border"
style={{
backgroundColor: "var(--bg-input)",
borderColor: "var(--border-input)",
color: "var(--text-primary)",
}}
>
<option value="">All Channels</option>
{CHANNELS.map((c) => (
<option key={c} value={c}>{c}</option>
))}
</select>
</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>
)}
{/* Table */}
<div
className="rounded-lg border overflow-hidden"
style={{ borderColor: "var(--border-primary)" }}
>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr style={{ backgroundColor: "var(--bg-secondary)", borderBottom: "1px solid var(--border-primary)" }}>
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-muted)" }}>Type</th>
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-muted)" }}>Channel</th>
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-muted)" }}>Version</th>
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-muted)" }}>Size</th>
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-muted)" }}>SHA-256</th>
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-muted)" }}>Uploaded</th>
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-muted)" }}>Latest</th>
{canDelete && (
<th className="px-4 py-3" style={{ color: "var(--text-muted)" }} />
)}
</tr>
</thead>
<tbody>
{loading ? (
<tr>
<td colSpan={canDelete ? 8 : 7} className="px-4 py-8 text-center text-sm" style={{ color: "var(--text-muted)" }}>
Loading
</td>
</tr>
) : firmware.length === 0 ? (
<tr>
<td colSpan={canDelete ? 8 : 7} className="px-4 py-8 text-center text-sm" style={{ color: "var(--text-muted)" }}>
No firmware versions found.{" "}
{canAdd && (
<button
onClick={() => setShowUpload(true)}
className="underline cursor-pointer"
style={{ color: "var(--text-link)" }}
>
Upload the first one.
</button>
)}
</td>
</tr>
) : (
firmware.map((fw) => (
<tr
key={fw.id}
style={{ borderBottom: "1px solid var(--border-secondary)" }}
>
<td className="px-4 py-3" style={{ color: "var(--text-secondary)" }}>
{BOARD_TYPE_LABELS[fw.hw_type] || fw.hw_type}
</td>
<td className="px-4 py-3">
<ChannelBadge channel={fw.channel} />
</td>
<td className="px-4 py-3 font-mono text-xs" style={{ color: "var(--text-primary)" }}>
{fw.version}
</td>
<td className="px-4 py-3 text-xs" style={{ color: "var(--text-muted)" }}>
{formatBytes(fw.size_bytes)}
</td>
<td className="px-4 py-3 font-mono text-xs" style={{ color: "var(--text-muted)" }} title={fw.sha256}>
{fw.sha256 ? fw.sha256.slice(0, 12) + "…" : "—"}
</td>
<td className="px-4 py-3 text-xs" style={{ color: "var(--text-muted)" }}>
{formatDate(fw.uploaded_at)}
</td>
<td className="px-4 py-3">
{fw.is_latest && (
<span
className="px-2 py-0.5 text-xs rounded-full font-medium"
style={{ backgroundColor: "var(--success-bg)", color: "var(--success-text)" }}
>
latest
</span>
)}
</td>
{canDelete && (
<td className="px-4 py-3 text-right">
<button
onClick={() => setDeleteTarget(fw)}
className="text-xs hover:opacity-80 cursor-pointer"
style={{ color: "var(--danger)" }}
>
Delete
</button>
</td>
)}
</tr>
))
)}
</tbody>
</table>
</div>
</div>
{/* Delete confirmation */}
{deleteTarget && (
<div className="fixed inset-0 flex items-center justify-center z-50" style={{ backgroundColor: "rgba(0,0,0,0.6)" }}>
<div
className="rounded-lg border p-6 max-w-sm w-full mx-4"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
>
<h3 className="text-base font-semibold mb-2" style={{ color: "var(--text-heading)" }}>
Delete Firmware
</h3>
<p className="text-sm mb-5" style={{ color: "var(--text-secondary)" }}>
Delete{" "}
<span className="font-mono" style={{ color: "var(--text-primary)" }}>
{BOARD_TYPE_LABELS[deleteTarget.hw_type] || deleteTarget.hw_type} v{deleteTarget.version} ({deleteTarget.channel})
</span>
? This cannot be undone.
</p>
<div className="flex gap-3">
<button
onClick={handleDelete}
disabled={deleting}
className="px-4 py-2 text-sm rounded-md hover:opacity-90 transition-opacity cursor-pointer disabled:opacity-50"
style={{ backgroundColor: "var(--danger-btn)", color: "var(--text-white)" }}
>
{deleting ? "Deleting…" : "Delete"}
</button>
<button
onClick={() => setDeleteTarget(null)}
className="px-4 py-2 text-sm rounded-md hover:opacity-80 cursor-pointer"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)" }}
>
Cancel
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -26,6 +26,15 @@ const navItems = [
],
},
{ to: "/equipment/notes", label: "Issues and Notes", permission: "equipment" },
{
label: "Manufacturing",
permission: "manufacturing",
children: [
{ to: "/manufacturing", label: "Device Inventory" },
{ to: "/manufacturing/batch/new", label: "New Batch" },
{ to: "/firmware", label: "Firmware" },
],
},
];
const linkClass = (isActive, locked) =>

View File

@@ -0,0 +1,237 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import api from "../api/client";
const BOARD_TYPES = [
{ value: "vs", label: "Vesper (VS)" },
{ value: "vp", label: "Vesper+ (VP)" },
{ value: "vx", label: "VesperPro (VX)" },
];
export default function BatchCreator() {
const navigate = useNavigate();
const [boardType, setBoardType] = useState("vs");
const [boardVersion, setBoardVersion] = useState("01");
const [quantity, setQuantity] = useState(1);
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const [result, setResult] = useState(null);
const [copied, setCopied] = useState(false);
const handleSubmit = async (e) => {
e.preventDefault();
setError("");
setResult(null);
setSaving(true);
try {
const data = await api.post("/manufacturing/batch", {
board_type: boardType,
board_version: boardVersion,
quantity: Number(quantity),
});
setResult(data);
} catch (err) {
setError(err.message);
} finally {
setSaving(false);
}
};
const copyAll = () => {
if (!result) return;
navigator.clipboard.writeText(result.serial_numbers.join("\n"));
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
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" style={{ color: "var(--text-heading)" }}>
New Batch
</h1>
</div>
{!result ? (
<div
className="rounded-lg border p-6"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
>
<h2 className="text-base font-semibold mb-5" style={{ color: "var(--text-heading)" }}>
Batch Parameters
</h2>
{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>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium mb-1.5" style={{ color: "var(--text-secondary)" }}>
Board Type
</label>
<select
value={boardType}
onChange={(e) => setBoardType(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)",
}}
>
{BOARD_TYPES.map((bt) => (
<option key={bt.value} value={bt.value}>
{bt.label}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1.5" style={{ color: "var(--text-secondary)" }}>
Board Version
</label>
<input
type="text"
value={boardVersion}
onChange={(e) => setBoardVersion(e.target.value)}
placeholder="01"
maxLength={2}
pattern="\d{2}"
required
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)",
}}
/>
<p className="text-xs mt-1" style={{ color: "var(--text-muted)" }}>
2-digit zero-padded version number (e.g. 01, 02)
</p>
</div>
<div>
<label className="block text-sm font-medium mb-1.5" style={{ color: "var(--text-secondary)" }}>
Quantity
</label>
<input
type="number"
value={quantity}
onChange={(e) => setQuantity(e.target.value)}
min={1}
max={100}
required
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>
<div className="flex gap-3 pt-2">
<button
type="submit"
disabled={saving}
className="px-5 py-2 text-sm rounded-md font-medium hover:opacity-90 transition-opacity cursor-pointer disabled:opacity-50"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
>
{saving ? "Generating…" : `Generate ${quantity} Serial Number${quantity > 1 ? "s" : ""}`}
</button>
<button
type="button"
onClick={() => navigate("/manufacturing")}
className="px-4 py-2 text-sm rounded-md hover:opacity-80 transition-opacity cursor-pointer"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)" }}
>
Cancel
</button>
</div>
</form>
</div>
) : (
<div
className="rounded-lg border p-6"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
>
<div className="flex items-start justify-between mb-4">
<div>
<h2 className="text-base font-semibold" style={{ color: "var(--text-heading)" }}>
Batch Created
</h2>
<p className="text-sm mt-0.5" style={{ color: "var(--text-muted)" }}>
{result.batch_id} · {result.serial_numbers.length} device{result.serial_numbers.length !== 1 ? "s" : ""}
</p>
</div>
<span
className="px-2 py-0.5 text-xs rounded-full font-medium"
style={{ backgroundColor: "var(--badge-blue-bg)", color: "var(--badge-blue-text)" }}
>
{BOARD_TYPES.find((b) => b.value === result.board_type)?.label || result.board_type} v{result.board_version}
</span>
</div>
<div
className="rounded-md border p-3 mb-4 font-mono text-xs overflow-y-auto"
style={{
backgroundColor: "var(--bg-primary)",
borderColor: "var(--border-secondary)",
color: "var(--text-primary)",
maxHeight: "280px",
}}
>
{result.serial_numbers.map((sn) => (
<div key={sn} className="py-0.5">
{sn}
</div>
))}
</div>
<div className="flex gap-3">
<button
onClick={copyAll}
className="px-4 py-2 text-sm rounded-md hover:opacity-90 transition-opacity cursor-pointer"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
>
{copied ? "Copied!" : "Copy All"}
</button>
<button
onClick={() => navigate("/manufacturing")}
className="px-4 py-2 text-sm rounded-md hover:opacity-80 transition-opacity cursor-pointer"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)" }}
>
View Inventory
</button>
<button
onClick={() => { setResult(null); setQuantity(1); }}
className="px-4 py-2 text-sm rounded-md hover:opacity-80 transition-opacity cursor-pointer"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)" }}
>
New Batch
</button>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,244 @@
import { useState, useEffect } from "react";
import { 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)" },
};
function StatusBadge({ status }) {
const style = STATUS_STYLES[status] || STATUS_STYLES.manufactured;
return (
<span
className="px-2 py-0.5 text-xs rounded-full capitalize font-medium"
style={{ backgroundColor: style.bg, color: style.color }}
>
{status}
</span>
);
}
export default function DeviceInventory() {
const navigate = useNavigate();
const { hasPermission } = useAuth();
const canAdd = hasPermission("manufacturing", "add");
const [devices, setDevices] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState("");
const [hwTypeFilter, setHwTypeFilter] = useState("");
const fetchDevices = async () => {
setLoading(true);
setError("");
try {
const params = new URLSearchParams();
if (search) params.set("search", search);
if (statusFilter) params.set("status", statusFilter);
if (hwTypeFilter) params.set("hw_type", hwTypeFilter);
params.set("limit", "200");
const qs = params.toString();
const data = await api.get(`/manufacturing/devices${qs ? `?${qs}` : ""}`);
setDevices(data.devices);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchDevices();
}, [search, statusFilter, hwTypeFilter]);
const formatDate = (iso) => {
if (!iso) return "—";
try {
return new Date(iso).toLocaleDateString("en-US", {
year: "numeric", month: "short", day: "numeric",
});
} catch {
return iso;
}
};
return (
<div>
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold" style={{ color: "var(--text-heading)" }}>
Device Inventory
</h1>
<p className="text-sm mt-0.5" style={{ color: "var(--text-muted)" }}>
{devices.length} device{devices.length !== 1 ? "s" : ""}
{statusFilter || hwTypeFilter || search ? " (filtered)" : ""}
</p>
</div>
{canAdd && (
<button
onClick={() => navigate("/manufacturing/batch/new")}
className="px-4 py-2 text-sm rounded-md font-medium hover:opacity-90 transition-opacity cursor-pointer"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
>
+ New Batch
</button>
)}
</div>
{/* Filters */}
<div className="flex flex-wrap gap-3 mb-4">
<input
type="text"
placeholder="Search serial number, batch, owner…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="px-3 py-2 rounded-md text-sm border flex-1 min-w-48"
style={{
backgroundColor: "var(--bg-input)",
borderColor: "var(--border-input)",
color: "var(--text-primary)",
}}
/>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="px-3 py-2 rounded-md text-sm border"
style={{
backgroundColor: "var(--bg-input)",
borderColor: "var(--border-input)",
color: "var(--text-primary)",
}}
>
<option value="">All Statuses</option>
<option value="manufactured">Manufactured</option>
<option value="flashed">Flashed</option>
<option value="provisioned">Provisioned</option>
<option value="sold">Sold</option>
<option value="claimed">Claimed</option>
<option value="decommissioned">Decommissioned</option>
</select>
<select
value={hwTypeFilter}
onChange={(e) => setHwTypeFilter(e.target.value)}
className="px-3 py-2 rounded-md text-sm border"
style={{
backgroundColor: "var(--bg-input)",
borderColor: "var(--border-input)",
color: "var(--text-primary)",
}}
>
<option value="">All Types</option>
<option value="vs">Vesper (VS)</option>
<option value="vp">Vesper+ (VP)</option>
<option value="vx">VesperPro (VX)</option>
</select>
</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>
)}
<div
className="rounded-lg border overflow-hidden"
style={{ borderColor: "var(--border-primary)" }}
>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr style={{ backgroundColor: "var(--bg-secondary)", borderBottom: "1px solid var(--border-primary)" }}>
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-muted)" }}>Serial Number</th>
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-muted)" }}>Type</th>
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-muted)" }}>Version</th>
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-muted)" }}>Status</th>
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-muted)" }}>Batch</th>
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-muted)" }}>Created</th>
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-muted)" }}>Owner</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr>
<td colSpan={7} className="px-4 py-8 text-center text-sm" style={{ color: "var(--text-muted)" }}>
Loading
</td>
</tr>
) : devices.length === 0 ? (
<tr>
<td colSpan={7} className="px-4 py-8 text-center text-sm" style={{ color: "var(--text-muted)" }}>
No devices found.
{canAdd && (
<span>
{" "}
<button
onClick={() => navigate("/manufacturing/batch/new")}
className="underline cursor-pointer"
style={{ color: "var(--text-link)" }}
>
Create a batch
</button>{" "}
to get started.
</span>
)}
</td>
</tr>
) : (
devices.map((device) => (
<tr
key={device.id}
onClick={() => navigate(`/manufacturing/devices/${device.serial_number}`)}
className="cursor-pointer transition-colors"
style={{ borderBottom: "1px solid var(--border-secondary)" }}
onMouseEnter={(e) => (e.currentTarget.style.backgroundColor = "var(--bg-card-hover)")}
onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = "")}
>
<td className="px-4 py-3 font-mono text-xs" style={{ color: "var(--text-primary)" }}>
{device.serial_number}
</td>
<td className="px-4 py-3" style={{ color: "var(--text-secondary)" }}>
{BOARD_TYPE_LABELS[device.hw_type] || device.hw_type}
</td>
<td className="px-4 py-3" style={{ color: "var(--text-muted)" }}>
v{device.hw_version}
</td>
<td className="px-4 py-3">
<StatusBadge status={device.mfg_status} />
</td>
<td className="px-4 py-3 font-mono text-xs" style={{ color: "var(--text-muted)" }}>
{device.mfg_batch_id || "—"}
</td>
<td className="px-4 py-3 text-xs" style={{ color: "var(--text-muted)" }}>
{formatDate(device.created_at)}
</td>
<td className="px-4 py-3 text-xs" style={{ color: "var(--text-muted)" }}>
{device.owner || "—"}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</div>
);
}

View 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>
);
}