style: Updated the overall UI of the provisining pages

This commit is contained in:
2026-02-27 12:23:17 +02:00
parent 47570257bd
commit 7585e43b52
8 changed files with 1922 additions and 848 deletions

View File

@@ -5,14 +5,22 @@ from enum import Enum
class BoardType(str, Enum): class BoardType(str, Enum):
vs = "vs" # Vesper vs = "vs" # Vesper
vp = "vp" # Vesper+ vp = "vp" # Vesper Plus
vx = "vx" # Vesper Pro vx = "vx" # Vesper Pro
cb = "cb" # Chronos
cp = "cp" # Chronos Pro
am = "am" # Agnus Mini
ab = "ab" # Agnus
BOARD_TYPE_LABELS = { BOARD_TYPE_LABELS = {
"vs": "Vesper", "vs": "Vesper",
"vp": "Vesper+", "vp": "Vesper Plus",
"vx": "Vesper Pro", "vx": "Vesper Pro",
"cb": "Chronos",
"cp": "Chronos Pro",
"am": "Agnus Mini",
"ab": "Agnus",
} }
@@ -27,7 +35,11 @@ class MfgStatus(str, Enum):
class BatchCreate(BaseModel): class BatchCreate(BaseModel):
board_type: BoardType board_type: BoardType
board_version: str = Field(..., pattern=r"^\d{2}$", description="2-digit zero-padded version, e.g. '01'") board_version: str = Field(
...,
pattern=r"^\d+(\.\d+)*$",
description="SemVer-style version string, e.g. '1.0' or legacy '01'",
)
quantity: int = Field(..., ge=1, le=100) quantity: int = Field(..., ge=1, le=100)
@@ -49,6 +61,7 @@ class DeviceInventoryItem(BaseModel):
created_at: Optional[str] = None created_at: Optional[str] = None
owner: Optional[str] = None owner: Optional[str] = None
assigned_to: Optional[str] = None assigned_to: Optional[str] = None
device_name: Optional[str] = None
class DeviceInventoryListResponse(BaseModel): class DeviceInventoryListResponse(BaseModel):

View File

@@ -1,4 +1,4 @@
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Query, HTTPException
from fastapi.responses import Response from fastapi.responses import Response
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
from typing import Optional from typing import Optional
@@ -13,6 +13,7 @@ from manufacturing.models import (
) )
from manufacturing import service from manufacturing import service
from manufacturing import audit from manufacturing import audit
from shared.exceptions import NotFoundError
router = APIRouter(prefix="/api/manufacturing", tags=["manufacturing"]) router = APIRouter(prefix="/api/manufacturing", tags=["manufacturing"])
@@ -129,6 +130,41 @@ async def assign_device(
return result return result
@router.delete("/devices/{sn}", status_code=204)
async def delete_device(
sn: str,
force: bool = Query(False, description="Required to delete sold/claimed devices"),
user: TokenPayload = Depends(require_permission("manufacturing", "delete")),
):
"""Delete a device. Sold/claimed devices require force=true."""
try:
service.delete_device(sn, force=force)
except NotFoundError:
raise HTTPException(status_code=404, detail="Device not found")
except PermissionError as e:
raise HTTPException(status_code=403, detail=str(e))
await audit.log_action(
admin_user=user.email,
action="device_deleted",
serial_number=sn,
detail={"force": force},
)
@router.delete("/devices", status_code=200)
async def delete_unprovisioned(
user: TokenPayload = Depends(require_permission("manufacturing", "delete")),
):
"""Delete all devices with status 'manufactured' (never provisioned)."""
deleted = service.delete_unprovisioned_devices()
await audit.log_action(
admin_user=user.email,
action="bulk_delete_unprovisioned",
detail={"count": len(deleted), "serial_numbers": deleted},
)
return {"deleted": deleted, "count": len(deleted)}
@router.get("/devices/{sn}/firmware.bin") @router.get("/devices/{sn}/firmware.bin")
def redirect_firmware( def redirect_firmware(
sn: str, sn: str,

View File

@@ -46,6 +46,7 @@ def _doc_to_inventory_item(doc) -> DeviceInventoryItem:
created_at=created_str, created_at=created_str,
owner=data.get("owner"), owner=data.get("owner"),
assigned_to=data.get("assigned_to"), assigned_to=data.get("assigned_to"),
device_name=data.get("device_name") or None,
) )
@@ -217,6 +218,47 @@ def get_stats() -> ManufacturingStats:
return ManufacturingStats(counts=counts, recent_activity=recent) return ManufacturingStats(counts=counts, recent_activity=recent)
PROTECTED_STATUSES = {"sold", "claimed"}
def delete_device(sn: str, force: bool = False) -> None:
"""Delete a device by serial number.
Raises PermissionError if the device is sold/claimed and force is not set.
The frontend uses force=True only after the user confirms by typing the SN.
"""
db = get_db()
docs = list(db.collection(COLLECTION).where("serial_number", "==", sn).limit(1).stream())
if not docs:
raise NotFoundError("Device")
data = docs[0].to_dict() or {}
status = data.get("mfg_status", "manufactured")
if status in PROTECTED_STATUSES and not force:
raise PermissionError(
f"Device {sn} has status '{status}' and cannot be deleted without explicit confirmation."
)
docs[0].reference.delete()
def delete_unprovisioned_devices() -> list[str]:
"""Delete all devices with status 'manufactured' (never flashed/provisioned).
Returns the list of deleted serial numbers.
"""
db = get_db()
docs = list(db.collection(COLLECTION).where("mfg_status", "==", "manufactured").stream())
deleted = []
for doc in docs:
data = doc.to_dict() or {}
sn = data.get("serial_number", "")
doc.reference.delete()
deleted.append(sn)
return deleted
def get_firmware_url(sn: str) -> str: def get_firmware_url(sn: str) -> str:
"""Return the FastAPI download URL for the latest stable firmware for this device's hw_type.""" """Return the FastAPI download URL for the latest stable firmware for this device's hw_type."""
from firmware.service import get_latest from firmware.service import get_latest

View File

@@ -17,6 +17,23 @@ L.Icon.Default.mergeOptions({
shadowUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png", shadowUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png",
}); });
// --- Helpers ---
function formatSecondsAgo(seconds) {
if (seconds == null) return null;
if (seconds < 60) return `${seconds}s ago`;
if (seconds < 3600) {
const m = Math.round(seconds / 60);
return `${m}m ago`;
}
if (seconds < 86400) {
const h = Math.round(seconds / 3600);
return `${h}h ago`;
}
const d = Math.round(seconds / 86400);
return `${d}d ago`;
}
// --- Helper components --- // --- Helper components ---
function Field({ label, children }) { function Field({ label, children }) {
@@ -3023,7 +3040,7 @@ export default function DeviceDetail() {
{isOnline ? "Online" : "Offline"} {isOnline ? "Online" : "Offline"}
{mqttStatus && ( {mqttStatus && (
<span className="ml-2 text-xs font-normal" style={{ color: "var(--text-muted)" }}> <span className="ml-2 text-xs font-normal" style={{ color: "var(--text-muted)" }}>
{mqttStatus.seconds_since_heartbeat}s ago {formatSecondsAgo(mqttStatus.seconds_since_heartbeat)}
</span> </span>
)} )}
</span> </span>

View File

@@ -3,15 +3,19 @@ import { useNavigate } from "react-router-dom";
import api from "../api/client"; import api from "../api/client";
const BOARD_TYPES = [ const BOARD_TYPES = [
{ value: "vs", label: "Vesper (VS)" }, { value: "vs", name: "VESPER", codename: "vesper-basic" },
{ value: "vp", label: "Vesper+ (VP)" }, { value: "vp", name: "VESPER PLUS", codename: "vesper-plus" },
{ value: "vx", label: "VesperPro (VX)" }, { value: "vx", name: "VESPER PRO", codename: "vesper-pro" },
{ value: "cb", name: "CHRONOS", codename: "chronos-basic" },
{ value: "cp", name: "CHRONOS PRO", codename: "chronos-pro" },
{ value: "am", name: "AGNUS MINI", codename: "agnus-mini" },
{ value: "ab", name: "AGNUS", codename: "agnus-basic" },
]; ];
export default function BatchCreator() { export default function BatchCreator() {
const navigate = useNavigate(); const navigate = useNavigate();
const [boardType, setBoardType] = useState("vs"); const [boardType, setBoardType] = useState(null);
const [boardVersion, setBoardVersion] = useState("01"); const [boardVersion, setBoardVersion] = useState("1.0");
const [quantity, setQuantity] = useState(1); const [quantity, setQuantity] = useState(1);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
@@ -20,13 +24,14 @@ export default function BatchCreator() {
const handleSubmit = async (e) => { const handleSubmit = async (e) => {
e.preventDefault(); e.preventDefault();
if (!boardType) return;
setError(""); setError("");
setResult(null); setResult(null);
setSaving(true); setSaving(true);
try { try {
const data = await api.post("/manufacturing/batch", { const data = await api.post("/manufacturing/batch", {
board_type: boardType, board_type: boardType,
board_version: boardVersion, board_version: boardVersion.trim(),
quantity: Number(quantity), quantity: Number(quantity),
}); });
setResult(data); setResult(data);
@@ -44,21 +49,17 @@ export default function BatchCreator() {
setTimeout(() => setCopied(false), 2000); setTimeout(() => setCopied(false), 2000);
}; };
const inputStyle = {
backgroundColor: "var(--bg-input)",
borderColor: "var(--border-input)",
color: "var(--text-primary)",
};
return ( return (
<div className="max-w-2xl"> <div style={{ maxWidth: 640 }}>
<div className="flex items-center gap-3 mb-6"> <h1 className="text-xl font-bold mb-6" style={{ color: "var(--text-heading)" }}>
<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 New Batch
</h1> </h1>
</div>
{!result ? ( {!result ? (
<div <div
@@ -70,65 +71,69 @@ export default function BatchCreator() {
</h2> </h2>
{error && ( {error && (
<div <div className="text-sm rounded-md p-3 mb-4 border"
className="text-sm rounded-md p-3 mb-4 border" style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}>
style={{
backgroundColor: "var(--danger-bg)",
borderColor: "var(--danger)",
color: "var(--danger-text)",
}}
>
{error} {error}
</div> </div>
)} )}
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-5">
{/* Board Type tiles */}
<div> <div>
<label className="block text-sm font-medium mb-1.5" style={{ color: "var(--text-secondary)" }}> <label className="block text-sm font-medium mb-2" style={{ color: "var(--text-secondary)" }}>
Board Type Board Type
</label> </label>
<select <div className="grid grid-cols-2 gap-2">
value={boardType} {BOARD_TYPES.map((bt) => {
onChange={(e) => setBoardType(e.target.value)} const isSel = boardType === bt.value;
className="w-full px-3 py-2 rounded-md text-sm border" return (
<button
key={bt.value}
type="button"
onClick={() => setBoardType(bt.value)}
className="rounded-lg border p-3 text-left cursor-pointer transition-all"
style={{ style={{
backgroundColor: "var(--bg-input)", backgroundColor: isSel ? "#0a1f0a" : "var(--bg-card-hover)",
borderColor: "var(--border-input)", borderColor: isSel ? "#22c55e" : "var(--border-primary)",
color: "var(--text-primary)", boxShadow: isSel ? "0 0 0 1px #22c55e" : "none",
}} }}
> >
{BOARD_TYPES.map((bt) => ( <p className="text-xs font-bold tracking-wide"
<option key={bt.value} value={bt.value}> style={{ color: isSel ? "#22c55e" : "var(--text-heading)" }}>
{bt.label} {bt.name}
</option> </p>
))} <p className="text-xs font-mono mt-0.5" style={{ color: "var(--text-muted)" }}>
</select> {bt.codename}
</p>
</button>
);
})}
</div>
</div> </div>
{/* Board Revision */}
<div> <div>
<label className="block text-sm font-medium mb-1.5" style={{ color: "var(--text-secondary)" }}> <label className="block text-sm font-medium mb-1.5" style={{ color: "var(--text-secondary)" }}>
Board Version Board Revision
</label> </label>
<div className="flex items-center gap-2">
<span className="text-sm font-medium" style={{ color: "var(--text-muted)" }}>Rev</span>
<input <input
type="text" type="text"
value={boardVersion} value={boardVersion}
onChange={(e) => setBoardVersion(e.target.value)} onChange={(e) => setBoardVersion(e.target.value)}
placeholder="01" placeholder="1.0"
maxLength={2}
pattern="\d{2}"
required required
className="w-full px-3 py-2 rounded-md text-sm border" className="px-3 py-2 rounded-md text-sm border w-32"
style={{ style={inputStyle}
backgroundColor: "var(--bg-input)",
borderColor: "var(--border-input)",
color: "var(--text-primary)",
}}
/> />
</div>
<p className="text-xs mt-1" style={{ color: "var(--text-muted)" }}> <p className="text-xs mt-1" style={{ color: "var(--text-muted)" }}>
2-digit zero-padded version number (e.g. 01, 02) Use semantic versioning: 1.0, 1.1, 2.0, etc.
</p> </p>
</div> </div>
{/* Quantity */}
<div> <div>
<label className="block text-sm font-medium mb-1.5" style={{ color: "var(--text-secondary)" }}> <label className="block text-sm font-medium mb-1.5" style={{ color: "var(--text-secondary)" }}>
Quantity Quantity
@@ -141,22 +146,18 @@ export default function BatchCreator() {
max={100} max={100}
required required
className="w-full px-3 py-2 rounded-md text-sm border" className="w-full px-3 py-2 rounded-md text-sm border"
style={{ style={inputStyle}
backgroundColor: "var(--bg-input)",
borderColor: "var(--border-input)",
color: "var(--text-primary)",
}}
/> />
</div> </div>
<div className="flex gap-3 pt-2"> <div className="flex gap-3 pt-2">
<button <button
type="submit" type="submit"
disabled={saving} disabled={saving || !boardType}
className="px-5 py-2 text-sm rounded-md font-medium hover:opacity-90 transition-opacity cursor-pointer disabled:opacity-50" className="px-5 py-2 text-sm rounded-md font-medium hover:opacity-90 transition-opacity cursor-pointer disabled:opacity-40"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }} style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
> >
{saving ? "Generating…" : `Generate ${quantity} Serial Number${quantity > 1 ? "s" : ""}`} {saving ? "Generating…" : `Generate ${quantity} Serial Number${Number(quantity) > 1 ? "s" : ""}`}
</button> </button>
<button <button
type="button" type="button"
@@ -187,7 +188,7 @@ export default function BatchCreator() {
className="px-2 py-0.5 text-xs rounded-full font-medium" className="px-2 py-0.5 text-xs rounded-full font-medium"
style={{ backgroundColor: "var(--badge-blue-bg)", color: "var(--badge-blue-text)" }} 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} {BOARD_TYPES.find((b) => b.value === result.board_type)?.name || result.board_type} Rev {result.board_version}
</span> </span>
</div> </div>
@@ -201,9 +202,7 @@ export default function BatchCreator() {
}} }}
> >
{result.serial_numbers.map((sn) => ( {result.serial_numbers.map((sn) => (
<div key={sn} className="py-0.5"> <div key={sn} className="py-0.5">{sn}</div>
{sn}
</div>
))} ))}
</div> </div>
@@ -223,7 +222,7 @@ export default function BatchCreator() {
View Inventory View Inventory
</button> </button>
<button <button
onClick={() => { setResult(null); setQuantity(1); }} onClick={() => { setResult(null); setQuantity(1); setBoardType(null); }}
className="px-4 py-2 text-sm rounded-md hover:opacity-80 transition-opacity cursor-pointer" 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)" }} style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)" }}
> >

View File

@@ -1,9 +1,21 @@
import { useState, useEffect } from "react"; import { useState, useEffect, useRef } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useAuth } from "../auth/AuthContext"; import { useAuth } from "../auth/AuthContext";
import api from "../api/client"; import api from "../api/client";
const BOARD_TYPE_LABELS = { vs: "Vesper", vp: "Vesper+", vx: "VesperPro" }; // ─── constants ────────────────────────────────────────────────────────────────
const BOARD_TYPES = [
{ value: "vs", name: "VESPER", codename: "vesper-basic" },
{ value: "vp", name: "VESPER PLUS", codename: "vesper-plus" },
{ value: "vx", name: "VESPER PRO", codename: "vesper-pro" },
{ value: "cb", name: "CHRONOS", codename: "chronos-basic" },
{ value: "cp", name: "CHRONOS PRO", codename: "chronos-pro" },
{ value: "am", name: "AGNUS MINI", codename: "agnus-mini" },
{ value: "ab", name: "AGNUS", codename: "agnus-basic" },
];
const BOARD_TYPE_LABELS = Object.fromEntries(BOARD_TYPES.map((b) => [b.value, b.name]));
const STATUS_STYLES = { const STATUS_STYLES = {
manufactured: { bg: "var(--bg-card-hover)", color: "var(--text-muted)" }, manufactured: { bg: "var(--bg-card-hover)", color: "var(--text-muted)" },
@@ -14,6 +26,50 @@ const STATUS_STYLES = {
decommissioned:{ bg: "var(--danger-bg)", color: "var(--danger-text)" }, decommissioned:{ bg: "var(--danger-bg)", color: "var(--danger-text)" },
}; };
const PROTECTED_STATUSES = ["sold", "claimed"];
// ─── column definitions ───────────────────────────────────────────────────────
const ALL_COLUMNS = [
{ id: "serial", label: "Serial Number", default: true },
{ id: "type", label: "Type", default: true },
{ id: "version", label: "Version", default: true },
{ id: "status", label: "Status", default: true },
{ id: "batch", label: "Batch", default: true },
{ id: "created", label: "Created", default: true },
{ id: "owner", label: "Owner", default: true },
{ id: "name", label: "Device Name", default: false },
];
const COL_STORAGE_KEY = "mfg_inventory_columns";
const COL_ORDER_KEY = "mfg_inventory_col_order";
function loadColumnPrefs() {
try {
const vis = JSON.parse(localStorage.getItem(COL_STORAGE_KEY) || "null");
const order = JSON.parse(localStorage.getItem(COL_ORDER_KEY) || "null");
const visible = vis || Object.fromEntries(ALL_COLUMNS.map((c) => [c.id, c.default]));
const orderedIds = order || ALL_COLUMNS.map((c) => c.id);
// Merge any new columns not in saved order
for (const c of ALL_COLUMNS) {
if (!orderedIds.includes(c.id)) orderedIds.push(c.id);
}
return { visible, orderedIds };
} catch {
return {
visible: Object.fromEntries(ALL_COLUMNS.map((c) => [c.id, c.default])),
orderedIds: ALL_COLUMNS.map((c) => c.id),
};
}
}
function saveColumnPrefs(visible, orderedIds) {
localStorage.setItem(COL_STORAGE_KEY, JSON.stringify(visible));
localStorage.setItem(COL_ORDER_KEY, JSON.stringify(orderedIds));
}
// ─── helper components ────────────────────────────────────────────────────────
function StatusBadge({ status }) { function StatusBadge({ status }) {
const style = STATUS_STYLES[status] || STATUS_STYLES.manufactured; const style = STATUS_STYLES[status] || STATUS_STYLES.manufactured;
return ( return (
@@ -26,10 +82,398 @@ function StatusBadge({ status }) {
); );
} }
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).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" });
} catch { return iso; }
}
// ─── Column Toggle Dropdown ───────────────────────────────────────────────────
function ColumnToggle({ visible, orderedIds, onChange, onReorder }) {
const [open, setOpen] = useState(false);
const [dragging, setDragging] = useState(null);
const ref = useRef(null);
// Close on outside click
useEffect(() => {
const handler = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, []);
const handleDragStart = (id) => setDragging(id);
const handleDragOver = (e, id) => {
e.preventDefault();
if (dragging && dragging !== id) {
const next = [...orderedIds];
const from = next.indexOf(dragging);
const to = next.indexOf(id);
next.splice(from, 1);
next.splice(to, 0, dragging);
onReorder(next);
}
};
const handleDragEnd = () => setDragging(null);
const visibleCount = Object.values(visible).filter(Boolean).length;
return (
<div className="relative" ref={ref}>
<button
onClick={() => setOpen((v) => !v)}
className="flex items-center gap-1.5 px-3 py-2 text-sm rounded-md border hover:opacity-80 cursor-pointer transition-opacity"
style={{
backgroundColor: "var(--bg-input)",
borderColor: "var(--border-input)",
color: "var(--text-secondary)",
}}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
Columns ({visibleCount})
</button>
{open && (
<div
className="absolute right-0 top-full mt-1 z-20 rounded-lg border shadow-lg p-2"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)", width: 200 }}
>
<p className="text-xs font-medium px-2 py-1 mb-1" style={{ color: "var(--text-muted)" }}>
Drag to reorder
</p>
{orderedIds.map((id) => {
const col = ALL_COLUMNS.find((c) => c.id === id);
if (!col) return null;
return (
<div
key={id}
draggable
onDragStart={() => handleDragStart(id)}
onDragOver={(e) => handleDragOver(e, id)}
onDragEnd={handleDragEnd}
className="flex items-center gap-2 px-2 py-1.5 rounded cursor-grab select-none"
style={{
backgroundColor: dragging === id ? "var(--bg-card-hover)" : "transparent",
opacity: dragging === id ? 0.5 : 1,
}}
>
<span style={{ color: "var(--text-muted)", fontSize: 10 }}></span>
<label className="flex items-center gap-2 cursor-pointer flex-1">
<input
type="checkbox"
checked={!!visible[id]}
onChange={(e) => onChange(id, e.target.checked)}
className="cursor-pointer"
onClick={(e) => e.stopPropagation()}
/>
<span className="text-sm" style={{ color: "var(--text-secondary)" }}>{col.label}</span>
</label>
</div>
);
})}
</div>
)}
</div>
);
}
// ─── Add Device Modal ─────────────────────────────────────────────────────────
function AddDeviceModal({ onClose, onCreated }) {
const [boardType, setBoardType] = useState(null);
const [boardVersion, setBoardVersion] = useState("1.0");
const [creating, setCreating] = useState(false);
const [error, setError] = useState("");
const handleCreate = async () => {
if (!boardType) return;
setError("");
setCreating(true);
try {
const batch = await api.post("/manufacturing/batch", {
board_type: boardType,
board_version: boardVersion.trim(),
quantity: 1,
});
onCreated(batch.serial_numbers[0]);
onClose();
} catch (err) {
setError(err.message);
} finally {
setCreating(false);
}
};
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-lg"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}>
<h2 className="text-base font-bold mb-4" style={{ color: "var(--text-heading)" }}>Add Single Device</h2>
<p className="text-sm mb-3" style={{ color: "var(--text-secondary)" }}>Board Type</p>
<div className="grid grid-cols-2 gap-2 mb-4">
{BOARD_TYPES.map((bt) => {
const isSel = boardType === bt.value;
return (
<button
key={bt.value}
onClick={() => setBoardType(bt.value)}
className="rounded-lg border p-3 text-left cursor-pointer transition-all"
style={{
backgroundColor: isSel ? "#0a1f0a" : "var(--bg-card-hover)",
borderColor: isSel ? "#22c55e" : "var(--border-primary)",
}}
>
<p className="text-xs font-bold tracking-wide" style={{ color: isSel ? "#22c55e" : "var(--text-heading)" }}>
{bt.name}
</p>
<p className="text-xs font-mono mt-0.5" style={{ color: "var(--text-muted)" }}>{bt.codename}</p>
</button>
);
})}
</div>
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
Board Revision
</label>
<div className="flex items-center gap-2 mb-4">
<span className="text-sm" style={{ color: "var(--text-muted)" }}>Rev</span>
<input
type="text"
value={boardVersion}
onChange={(e) => setBoardVersion(e.target.value)}
placeholder="1.0"
className="px-3 py-2 rounded-md text-sm border w-28"
style={{ backgroundColor: "var(--bg-input)", borderColor: "var(--border-input)", color: "var(--text-primary)" }}
/>
</div>
{error && (
<div className="text-xs rounded p-2 border mb-3"
style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}>
{error}
</div>
)}
<div className="flex gap-3 justify-end">
<button onClick={onClose} className="px-4 py-1.5 text-sm rounded-md cursor-pointer hover:opacity-80"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)" }}>
Cancel
</button>
<button
onClick={handleCreate}
disabled={!boardType || creating}
className="px-4 py-1.5 text-sm rounded-md font-medium cursor-pointer hover:opacity-90 disabled:opacity-40"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
>
{creating ? "Creating…" : "Create Device"}
</button>
</div>
</div>
</div>
);
}
// ─── Delete Not Provisioned Modal ─────────────────────────────────────────────
function DeleteUnprovisionedModal({ onClose, onDeleted }) {
const [scanning, setScanning] = useState(true);
const [candidates, setCandidates] = useState([]);
const [deleting, setDeleting] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
(async () => {
try {
const data = await api.get("/manufacturing/devices?status=manufactured&limit=500");
setCandidates(data.devices || []);
} catch (err) {
setError(err.message);
} finally {
setScanning(false);
}
})();
}, []);
const handleDelete = async () => {
setDeleting(true);
setError("");
try {
const result = await api.request("/manufacturing/devices", { method: "DELETE" });
onDeleted(result.count || 0);
onClose();
} catch (err) {
setError(err.message);
} finally {
setDeleting(false);
}
};
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-lg"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--danger)" }}>
<h2 className="text-base font-bold mb-2" style={{ color: "var(--danger-text)" }}>
Delete Not Provisioned Devices
</h2>
<p className="text-sm mb-4" style={{ color: "var(--text-secondary)" }}>
The following devices have status <strong>manufactured</strong> and have never been flashed or provisioned.
They will be permanently deleted.
</p>
{scanning ? (
<p className="text-sm py-4 text-center" style={{ color: "var(--text-muted)" }}>Scanning</p>
) : candidates.length === 0 ? (
<p className="text-sm py-4 text-center" style={{ color: "var(--text-muted)" }}>
No unprovisioned devices found.
</p>
) : (
<div className="rounded-md border overflow-hidden mb-4" style={{ borderColor: "var(--border-primary)", maxHeight: 240, overflowY: "auto" }}>
<table className="w-full text-xs">
<thead>
<tr style={{ backgroundColor: "var(--bg-secondary)", borderBottom: "1px solid var(--border-primary)" }}>
<th className="px-3 py-2 text-left font-medium" style={{ color: "var(--text-muted)" }}>Serial</th>
<th className="px-3 py-2 text-left font-medium" style={{ color: "var(--text-muted)" }}>Type</th>
<th className="px-3 py-2 text-left font-medium" style={{ color: "var(--text-muted)" }}>Created</th>
</tr>
</thead>
<tbody>
{candidates.map((d) => (
<tr key={d.id} style={{ borderBottom: "1px solid var(--border-secondary)" }}>
<td className="px-3 py-2 font-mono" style={{ color: "var(--text-primary)" }}>{d.serial_number}</td>
<td className="px-3 py-2" style={{ color: "var(--text-secondary)" }}>{BOARD_TYPE_LABELS[d.hw_type] || d.hw_type}</td>
<td className="px-3 py-2" style={{ color: "var(--text-muted)" }}>{formatDate(d.created_at)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{error && (
<div className="text-xs rounded p-2 border mb-3"
style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}>
{error}
</div>
)}
<div className="flex gap-3 justify-end">
<button onClick={onClose} className="px-4 py-1.5 text-sm rounded-md cursor-pointer hover:opacity-80"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)" }}>
Cancel
</button>
<button
onClick={handleDelete}
disabled={deleting || scanning || candidates.length === 0}
className="px-4 py-1.5 text-sm rounded-md font-medium cursor-pointer hover:opacity-90 disabled:opacity-40"
style={{ backgroundColor: "var(--danger)", color: "#fff" }}
>
{deleting ? "Deleting…" : `Delete ${candidates.length} Device${candidates.length !== 1 ? "s" : ""}`}
</button>
</div>
</div>
</div>
);
}
// ─── Multi-select Delete Modal ────────────────────────────────────────────────
function MultiDeleteModal({ selected, devices, onClose, onDeleted }) {
const [deleting, setDeleting] = useState(false);
const [error, setError] = useState("");
const selectedDevices = devices.filter((d) => selected.has(d.id));
const hasProtected = selectedDevices.some((d) => PROTECTED_STATUSES.includes(d.mfg_status));
const handleDelete = async () => {
setDeleting(true);
setError("");
try {
// Only delete non-protected devices from this modal
const toDelete = selectedDevices.filter((d) => !PROTECTED_STATUSES.includes(d.mfg_status));
await Promise.all(
toDelete.map((d) =>
api.request(`/manufacturing/devices/${d.serial_number}`, { method: "DELETE" })
)
);
onDeleted(toDelete.map((d) => d.id));
onClose();
} catch (err) {
setError(err.message);
} finally {
setDeleting(false);
}
};
const safeCount = selectedDevices.filter((d) => !PROTECTED_STATUSES.includes(d.mfg_status)).length;
const protectedCount = selectedDevices.length - safeCount;
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-3" style={{ color: "var(--danger-text)" }}>
Delete {selectedDevices.length} Device{selectedDevices.length !== 1 ? "s" : ""}
</h2>
{hasProtected && (
<div className="text-sm rounded-md p-3 border mb-3"
style={{ backgroundColor: "#2e1a00", borderColor: "#fb923c", color: "#fb923c" }}>
{protectedCount} selected device{protectedCount !== 1 ? "s are" : " is"} sold/claimed and will be <strong>skipped</strong>.
To delete those, open each device individually.
</div>
)}
<p className="text-sm mb-4" style={{ color: "var(--text-secondary)" }}>
{safeCount > 0
? `${safeCount} device${safeCount !== 1 ? "s" : ""} will be permanently deleted.`
: "No deletable devices in your selection (all are sold/claimed)."}
</p>
{error && (
<div className="text-xs rounded p-2 border mb-3"
style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}>
{error}
</div>
)}
<div className="flex gap-3 justify-end">
<button onClick={onClose} className="px-4 py-1.5 text-sm rounded-md cursor-pointer hover:opacity-80"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)" }}>
Cancel
</button>
<button
onClick={handleDelete}
disabled={deleting || safeCount === 0}
className="px-4 py-1.5 text-sm rounded-md font-medium cursor-pointer hover:opacity-90 disabled:opacity-40"
style={{ backgroundColor: "var(--danger)", color: "#fff" }}
>
{deleting ? "Deleting…" : `Delete ${safeCount}`}
</button>
</div>
</div>
</div>
);
}
// ─── Main Component ───────────────────────────────────────────────────────────
export default function DeviceInventory() { export default function DeviceInventory() {
const navigate = useNavigate(); const navigate = useNavigate();
const { hasPermission } = useAuth(); const { hasPermission } = useAuth();
const canAdd = hasPermission("manufacturing", "add"); const canAdd = hasPermission("manufacturing", "add");
const canDelete = hasPermission("manufacturing", "delete");
const [devices, setDevices] = useState([]); const [devices, setDevices] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -39,6 +483,19 @@ export default function DeviceInventory() {
const [statusFilter, setStatusFilter] = useState(""); const [statusFilter, setStatusFilter] = useState("");
const [hwTypeFilter, setHwTypeFilter] = useState(""); const [hwTypeFilter, setHwTypeFilter] = useState("");
// Column preferences
const [colPrefs, setColPrefs] = useState(loadColumnPrefs);
// Selection
const [selected, setSelected] = useState(new Set());
const allIds = devices.map((d) => d.id);
const allSelected = allIds.length > 0 && allIds.every((id) => selected.has(id));
// Modals
const [showAddDevice, setShowAddDevice] = useState(false);
const [showDeleteUnprovisioned, setShowDeleteUnprovisioned] = useState(false);
const [showMultiDelete, setShowMultiDelete] = useState(false);
const fetchDevices = async () => { const fetchDevices = async () => {
setLoading(true); setLoading(true);
setError(""); setError("");
@@ -51,6 +508,8 @@ export default function DeviceInventory() {
const qs = params.toString(); const qs = params.toString();
const data = await api.get(`/manufacturing/devices${qs ? `?${qs}` : ""}`); const data = await api.get(`/manufacturing/devices${qs ? `?${qs}` : ""}`);
setDevices(data.devices); setDevices(data.devices);
// Clear selection on refresh
setSelected(new Set());
} catch (err) { } catch (err) {
setError(err.message); setError(err.message);
} finally { } finally {
@@ -58,67 +517,108 @@ export default function DeviceInventory() {
} }
}; };
useEffect(() => { useEffect(() => { fetchDevices(); }, [search, statusFilter, hwTypeFilter]);
fetchDevices();
}, [search, statusFilter, hwTypeFilter]);
const formatDate = (iso) => { const updateColVisible = (id, visible) => {
if (!iso) return "—"; const next = { ...colPrefs.visible, [id]: visible };
try { setColPrefs((p) => ({ ...p, visible: next }));
return new Date(iso).toLocaleDateString("en-US", { saveColumnPrefs(next, colPrefs.orderedIds);
year: "numeric", month: "short", day: "numeric", };
const updateColOrder = (orderedIds) => {
setColPrefs((p) => ({ ...p, orderedIds }));
saveColumnPrefs(colPrefs.visible, orderedIds);
};
const toggleRow = (id, e) => {
e.stopPropagation();
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
}); });
} catch { };
return iso;
const toggleAll = () => {
if (allSelected) setSelected(new Set());
else setSelected(new Set(allIds));
};
const visibleCols = colPrefs.orderedIds
.map((id) => ALL_COLUMNS.find((c) => c.id === id))
.filter((c) => c && colPrefs.visible[c.id]);
const renderCell = (col, device) => {
switch (col.id) {
case "serial": return <span className="font-mono text-xs" style={{ color: "var(--text-primary)" }}>{device.serial_number}</span>;
case "type": return <span style={{ color: "var(--text-secondary)" }}>{BOARD_TYPE_LABELS[device.hw_type] || device.hw_type}</span>;
case "version": return <span style={{ color: "var(--text-muted)" }}>{formatHwVersion(device.hw_version)}</span>;
case "status": return <StatusBadge status={device.mfg_status} />;
case "batch": return <span className="font-mono text-xs" style={{ color: "var(--text-muted)" }}>{device.mfg_batch_id || "—"}</span>;
case "created": return <span className="text-xs" style={{ color: "var(--text-muted)" }}>{formatDate(device.created_at)}</span>;
case "owner": return <span className="text-xs" style={{ color: "var(--text-muted)" }}>{device.owner || "—"}</span>;
case "name": return <span className="text-xs" style={{ color: "var(--text-muted)" }}>{device.device_name || "—"}</span>;
default: return null;
} }
}; };
return ( return (
<div> <div>
{/* Header */}
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
<div> <div>
<h1 className="text-2xl font-bold" style={{ color: "var(--text-heading)" }}> <h1 className="text-2xl font-bold" style={{ color: "var(--text-heading)" }}>Device Inventory</h1>
Device Inventory
</h1>
<p className="text-sm mt-0.5" style={{ color: "var(--text-muted)" }}> <p className="text-sm mt-0.5" style={{ color: "var(--text-muted)" }}>
{devices.length} device{devices.length !== 1 ? "s" : ""} {devices.length} device{devices.length !== 1 ? "s" : ""}
{statusFilter || hwTypeFilter || search ? " (filtered)" : ""} {statusFilter || hwTypeFilter || search ? " (filtered)" : ""}
</p> </p>
</div> </div>
<div className="flex items-center gap-2">
{canDelete && (
<button
onClick={() => setShowDeleteUnprovisioned(true)}
className="px-3 py-2 text-sm 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)" }}
>
Delete Not Provisioned
</button>
)}
{canAdd && (
<button
onClick={() => setShowAddDevice(true)}
className="px-3 py-2 text-sm rounded-md font-medium hover:opacity-90 cursor-pointer transition-opacity"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)", border: "1px solid var(--border-primary)" }}
>
+ Add Device
</button>
)}
{canAdd && ( {canAdd && (
<button <button
onClick={() => navigate("/manufacturing/batch/new")} onClick={() => navigate("/manufacturing/batch/new")}
className="px-4 py-2 text-sm rounded-md font-medium hover:opacity-90 transition-opacity cursor-pointer" className="px-4 py-2 text-sm rounded-md font-medium hover:opacity-90 cursor-pointer transition-opacity"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }} style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
> >
+ New Batch + New Batch
</button> </button>
)} )}
</div> </div>
</div>
{/* Filters */} {/* Filters + column toggle */}
<div className="flex flex-wrap gap-3 mb-4"> <div className="flex flex-wrap gap-3 mb-4">
<input <input
type="text" type="text"
placeholder="Search serial number, batch, owner…" placeholder="Search serial, batch, owner…"
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
className="px-3 py-2 rounded-md text-sm border flex-1 min-w-48" className="px-3 py-2 rounded-md text-sm border flex-1 min-w-48"
style={{ style={{ backgroundColor: "var(--bg-input)", borderColor: "var(--border-input)", color: "var(--text-primary)" }}
backgroundColor: "var(--bg-input)",
borderColor: "var(--border-input)",
color: "var(--text-primary)",
}}
/> />
<select <select
value={statusFilter} value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)} onChange={(e) => setStatusFilter(e.target.value)}
className="px-3 py-2 rounded-md text-sm border" className="px-3 py-2 rounded-md text-sm border"
style={{ style={{ backgroundColor: "var(--bg-input)", borderColor: "var(--border-input)", color: "var(--text-primary)" }}
backgroundColor: "var(--bg-input)",
borderColor: "var(--border-input)",
color: "var(--text-primary)",
}}
> >
<option value="">All Statuses</option> <option value="">All Statuses</option>
<option value="manufactured">Manufactured</option> <option value="manufactured">Manufactured</option>
@@ -132,113 +632,161 @@ export default function DeviceInventory() {
value={hwTypeFilter} value={hwTypeFilter}
onChange={(e) => setHwTypeFilter(e.target.value)} onChange={(e) => setHwTypeFilter(e.target.value)}
className="px-3 py-2 rounded-md text-sm border" className="px-3 py-2 rounded-md text-sm border"
style={{ style={{ backgroundColor: "var(--bg-input)", borderColor: "var(--border-input)", color: "var(--text-primary)" }}
backgroundColor: "var(--bg-input)",
borderColor: "var(--border-input)",
color: "var(--text-primary)",
}}
> >
<option value="">All Types</option> <option value="">All Types</option>
<option value="vs">Vesper (VS)</option> {BOARD_TYPES.map((bt) => (
<option value="vp">Vesper+ (VP)</option> <option key={bt.value} value={bt.value}>{bt.name}</option>
<option value="vx">VesperPro (VX)</option> ))}
</select> </select>
<ColumnToggle
visible={colPrefs.visible}
orderedIds={colPrefs.orderedIds}
onChange={updateColVisible}
onReorder={updateColOrder}
/>
</div> </div>
{error && ( {/* Multi-select action bar */}
{selected.size > 0 && canDelete && (
<div <div
className="text-sm rounded-md p-3 mb-4 border" className="flex items-center justify-between px-4 py-2 rounded-md mb-3"
style={{ style={{ backgroundColor: "var(--bg-card)", border: "1px solid var(--border-primary)" }}
backgroundColor: "var(--danger-bg)",
borderColor: "var(--danger)",
color: "var(--danger-text)",
}}
> >
<span className="text-sm" style={{ color: "var(--text-secondary)" }}>
{selected.size} device{selected.size !== 1 ? "s" : ""} selected
</span>
<div className="flex gap-2">
<button
onClick={() => setSelected(new Set())}
className="px-3 py-1.5 text-xs rounded-md cursor-pointer hover:opacity-80"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)" }}
>
Clear
</button>
<button
onClick={() => setShowMultiDelete(true)}
className="px-3 py-1.5 text-xs rounded-md font-medium cursor-pointer hover:opacity-90"
style={{ backgroundColor: "var(--danger)", color: "#fff" }}
>
Delete Selected
</button>
</div>
</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} {error}
</div> </div>
)} )}
<div {/* Table */}
className="rounded-lg border overflow-hidden" <div className="rounded-lg border overflow-hidden" style={{ borderColor: "var(--border-primary)" }}>
style={{ borderColor: "var(--border-primary)" }}
>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr style={{ backgroundColor: "var(--bg-secondary)", borderBottom: "1px solid var(--border-primary)" }}> <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> {/* Checkbox column */}
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-muted)" }}>Type</th> <th className="px-3 py-3 w-10">
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-muted)" }}>Version</th> <input
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-muted)" }}>Status</th> type="checkbox"
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-muted)" }}>Batch</th> checked={allSelected}
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-muted)" }}>Created</th> onChange={toggleAll}
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-muted)" }}>Owner</th> className="cursor-pointer"
/>
</th>
{visibleCols.map((col) => (
<th key={col.id} className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-muted)" }}>
{col.label}
</th>
))}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{loading ? ( {loading ? (
<tr> <tr>
<td colSpan={7} className="px-4 py-8 text-center text-sm" style={{ color: "var(--text-muted)" }}> <td colSpan={visibleCols.length + 1} className="px-4 py-8 text-center text-sm" style={{ color: "var(--text-muted)" }}>
Loading Loading
</td> </td>
</tr> </tr>
) : devices.length === 0 ? ( ) : devices.length === 0 ? (
<tr> <tr>
<td colSpan={7} className="px-4 py-8 text-center text-sm" style={{ color: "var(--text-muted)" }}> <td colSpan={visibleCols.length + 1} className="px-4 py-8 text-center text-sm" style={{ color: "var(--text-muted)" }}>
No devices found. 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> </td>
</tr> </tr>
) : ( ) : (
devices.map((device) => ( devices.map((device) => {
const isSelected = selected.has(device.id);
return (
<tr <tr
key={device.id} key={device.id}
onClick={() => navigate(`/manufacturing/devices/${device.serial_number}`)}
className="cursor-pointer transition-colors" className="cursor-pointer transition-colors"
style={{ borderBottom: "1px solid var(--border-secondary)" }} style={{
onMouseEnter={(e) => (e.currentTarget.style.backgroundColor = "var(--bg-card-hover)")} borderBottom: "1px solid var(--border-secondary)",
onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = "")} backgroundColor: isSelected ? "var(--bg-card)" : "",
}}
onMouseEnter={(e) => { if (!isSelected) e.currentTarget.style.backgroundColor = "var(--bg-card-hover)"; }}
onMouseLeave={(e) => { if (!isSelected) e.currentTarget.style.backgroundColor = ""; }}
> >
<td className="px-4 py-3 font-mono text-xs" style={{ color: "var(--text-primary)" }}> {/* Checkbox */}
{device.serial_number} <td className="px-3 py-3" onClick={(e) => toggleRow(device.id, e)}>
<input
type="checkbox"
checked={isSelected}
onChange={() => {}}
className="cursor-pointer"
/>
</td> </td>
<td className="px-4 py-3" style={{ color: "var(--text-secondary)" }}> {/* Data cells */}
{BOARD_TYPE_LABELS[device.hw_type] || device.hw_type} {visibleCols.map((col) => (
</td> <td
<td className="px-4 py-3" style={{ color: "var(--text-muted)" }}> key={col.id}
v{device.hw_version} className="px-4 py-3"
</td> onClick={() => navigate(`/manufacturing/devices/${device.serial_number}`)}
<td className="px-4 py-3"> >
<StatusBadge status={device.mfg_status} /> {renderCell(col, device)}
</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> </td>
))}
</tr> </tr>
)) );
})
)} )}
</tbody> </tbody>
</table> </table>
</div> </div>
</div> </div>
{/* Modals */}
{showAddDevice && (
<AddDeviceModal
onClose={() => setShowAddDevice(false)}
onCreated={() => fetchDevices()}
/>
)}
{showDeleteUnprovisioned && (
<DeleteUnprovisionedModal
onClose={() => setShowDeleteUnprovisioned(false)}
onDeleted={(count) => {
fetchDevices();
}}
/>
)}
{showMultiDelete && (
<MultiDeleteModal
selected={selected}
devices={devices}
onClose={() => setShowMultiDelete(false)}
onDeleted={(deletedIds) => {
setDevices((prev) => prev.filter((d) => !deletedIds.includes(d.id)));
setSelected(new Set());
}}
/>
)}
</div> </div>
); );
} }

View File

@@ -3,7 +3,10 @@ import { useParams, useNavigate } from "react-router-dom";
import { useAuth } from "../auth/AuthContext"; import { useAuth } from "../auth/AuthContext";
import api from "../api/client"; import api from "../api/client";
const BOARD_TYPE_LABELS = { vs: "Vesper", vp: "Vesper+", vx: "VesperPro" }; const BOARD_TYPE_LABELS = {
vs: "Vesper", vp: "Vesper Plus", vx: "Vesper Pro",
cb: "Chronos", cp: "Chronos Pro", am: "Agnus Mini", ab: "Agnus",
};
const STATUS_STYLES = { const STATUS_STYLES = {
manufactured: { bg: "var(--bg-card-hover)", color: "var(--text-muted)" }, manufactured: { bg: "var(--bg-card-hover)", color: "var(--text-muted)" },
@@ -18,6 +21,17 @@ const STATUS_OPTIONS = [
"manufactured", "flashed", "provisioned", "sold", "claimed", "decommissioned", "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 }) { function StatusBadge({ status }) {
const style = STATUS_STYLES[status] || STATUS_STYLES.manufactured; const style = STATUS_STYLES[status] || STATUS_STYLES.manufactured;
return ( return (
@@ -36,21 +50,97 @@ function Field({ label, value, mono = false }) {
<p className="text-xs font-medium uppercase tracking-wide mb-0.5" style={{ color: "var(--text-muted)" }}> <p className="text-xs font-medium uppercase tracking-wide mb-0.5" style={{ color: "var(--text-muted)" }}>
{label} {label}
</p> </p>
<p <p className={`text-sm ${mono ? "font-mono" : ""}`} style={{ color: "var(--text-primary)" }}>
className={`text-sm ${mono ? "font-mono" : ""}`}
style={{ color: "var(--text-primary)" }}
>
{value || "—"} {value || "—"}
</p> </p>
</div> </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() { export default function DeviceInventoryDetail() {
const { sn } = useParams(); const { sn } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
const { hasPermission } = useAuth(); const { hasPermission } = useAuth();
const canEdit = hasPermission("manufacturing", "edit"); const canEdit = hasPermission("manufacturing", "edit");
const canDelete = hasPermission("manufacturing", "delete");
const [device, setDevice] = useState(null); const [device, setDevice] = useState(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -70,6 +160,9 @@ export default function DeviceInventoryDetail() {
const [assignError, setAssignError] = useState(""); const [assignError, setAssignError] = useState("");
const [assignSuccess, setAssignSuccess] = useState(false); const [assignSuccess, setAssignSuccess] = useState(false);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [deleting, setDeleting] = useState(false);
const loadDevice = async () => { const loadDevice = async () => {
setLoading(true); setLoading(true);
setError(""); setError("");
@@ -83,9 +176,7 @@ export default function DeviceInventoryDetail() {
} }
}; };
useEffect(() => { useEffect(() => { loadDevice(); }, [sn]);
loadDevice();
}, [sn]);
const handleStatusSave = async () => { const handleStatusSave = async () => {
setStatusError(""); setStatusError("");
@@ -111,10 +202,7 @@ export default function DeviceInventoryDetail() {
try { try {
const updated = await api.request(`/manufacturing/devices/${sn}/assign`, { const updated = await api.request(`/manufacturing/devices/${sn}/assign`, {
method: "POST", method: "POST",
body: JSON.stringify({ body: JSON.stringify({ customer_email: assignEmail, customer_name: assignName || null }),
customer_email: assignEmail,
customer_name: assignName || null,
}),
}); });
setDevice(updated); setDevice(updated);
setAssignSuccess(true); setAssignSuccess(true);
@@ -152,6 +240,21 @@ export default function DeviceInventoryDetail() {
} }
}; };
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) => { const formatDate = (iso) => {
if (!iso) return "—"; if (!iso) return "—";
try { try {
@@ -159,9 +262,7 @@ export default function DeviceInventoryDetail() {
year: "numeric", month: "short", day: "numeric", year: "numeric", month: "short", day: "numeric",
hour: "2-digit", minute: "2-digit", hour: "2-digit", minute: "2-digit",
}); });
} catch { } catch { return iso; }
return iso;
}
}; };
if (loading) { if (loading) {
@@ -175,20 +276,9 @@ export default function DeviceInventoryDetail() {
if (error && !device) { if (error && !device) {
return ( return (
<div> <div>
<button
onClick={() => navigate("/manufacturing")}
className="text-sm mb-4 hover:opacity-80 cursor-pointer"
style={{ color: "var(--text-muted)" }}
>
Device Inventory
</button>
<div <div
className="text-sm rounded-md p-4 border" className="text-sm rounded-md p-4 border"
style={{ style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}
backgroundColor: "var(--danger-bg)",
borderColor: "var(--danger)",
color: "var(--danger-text)",
}}
> >
{error} {error}
</div> </div>
@@ -197,67 +287,67 @@ export default function DeviceInventoryDetail() {
} }
return ( return (
<div className="max-w-2xl"> <div style={{ maxWidth: 640 }}>
<div className="flex items-center gap-3 mb-6"> {/* Title row */}
<button <div className="flex items-start justify-between mb-6">
onClick={() => navigate("/manufacturing")} <div>
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)" }}> <h1 className="text-xl font-bold font-mono" style={{ color: "var(--text-heading)" }}>
{device?.serial_number} {device?.serial_number}
</h1> </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> </div>
{error && ( {error && (
<div <div className="text-sm rounded-md p-3 mb-4 border"
className="text-sm rounded-md p-3 mb-4 border" style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}>
style={{
backgroundColor: "var(--danger-bg)",
borderColor: "var(--danger)",
color: "var(--danger-text)",
}}
>
{error} {error}
</div> </div>
)} )}
{/* Identity card */} {/* Identity card */}
<div <div className="rounded-lg border p-5 mb-4"
className="rounded-lg border p-5 mb-4" style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}>
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)" }}> <h2 className="text-sm font-semibold uppercase tracking-wide mb-4" style={{ color: "var(--text-muted)" }}>
Device Identity Device Identity
</h2> </h2>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<Field label="Serial Number" value={device?.serial_number} mono /> <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="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="HW Version" value={formatHwVersion(device?.hw_version)} />
<Field label="Batch ID" value={device?.mfg_batch_id} mono /> <Field label="Batch ID" value={device?.mfg_batch_id} mono />
<Field label="Created At" value={formatDate(device?.created_at)} /> <Field label="Created At" value={formatDate(device?.created_at)} />
<Field label="Owner" value={device?.owner} /> <Field label="Owner" value={device?.owner} />
{device?.device_name && (
<Field label="Device Name" value={device.device_name} />
)}
</div> </div>
</div> </div>
{/* Status card */} {/* Status card */}
<div <div className="rounded-lg border p-5 mb-4"
className="rounded-lg border p-5 mb-4" style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}>
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
>
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-semibold uppercase tracking-wide" style={{ color: "var(--text-muted)" }}> <h2 className="text-sm font-semibold uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>
Status Status
</h2> </h2>
{canEdit && !editingStatus && ( {canEdit && !editingStatus && (
<button <button
onClick={() => { onClick={() => { setNewStatus(device.mfg_status); setEditingStatus(true); }}
setNewStatus(device.mfg_status);
setEditingStatus(true);
}}
className="text-xs hover:opacity-80 cursor-pointer" className="text-xs hover:opacity-80 cursor-pointer"
style={{ color: "var(--text-link)" }} style={{ color: "var(--text-link)" }}
> >
@@ -271,14 +361,8 @@ export default function DeviceInventoryDetail() {
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
{statusError && ( {statusError && (
<div <div className="text-xs rounded p-2 border"
className="text-xs rounded p-2 border" style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}>
style={{
backgroundColor: "var(--danger-bg)",
borderColor: "var(--danger)",
color: "var(--danger-text)",
}}
>
{statusError} {statusError}
</div> </div>
)} )}
@@ -286,15 +370,9 @@ export default function DeviceInventoryDetail() {
value={newStatus} value={newStatus}
onChange={(e) => setNewStatus(e.target.value)} onChange={(e) => setNewStatus(e.target.value)}
className="w-full px-3 py-2 rounded-md text-sm border" className="w-full px-3 py-2 rounded-md text-sm border"
style={{ style={{ backgroundColor: "var(--bg-input)", borderColor: "var(--border-input)", color: "var(--text-primary)" }}
backgroundColor: "var(--bg-input)",
borderColor: "var(--border-input)",
color: "var(--text-primary)",
}}
> >
{STATUS_OPTIONS.map((s) => ( {STATUS_OPTIONS.map((s) => <option key={s} value={s}>{s}</option>)}
<option key={s} value={s}>{s}</option>
))}
</select> </select>
<input <input
type="text" type="text"
@@ -302,11 +380,7 @@ export default function DeviceInventoryDetail() {
value={statusNote} value={statusNote}
onChange={(e) => setStatusNote(e.target.value)} onChange={(e) => setStatusNote(e.target.value)}
className="w-full px-3 py-2 rounded-md text-sm border" className="w-full px-3 py-2 rounded-md text-sm border"
style={{ style={{ backgroundColor: "var(--bg-input)", borderColor: "var(--border-input)", color: "var(--text-primary)" }}
backgroundColor: "var(--bg-input)",
borderColor: "var(--border-input)",
color: "var(--text-primary)",
}}
/> />
<div className="flex gap-2"> <div className="flex gap-2">
<button <button
@@ -330,10 +404,8 @@ export default function DeviceInventoryDetail() {
</div> </div>
{/* Actions card */} {/* Actions card */}
<div <div className="rounded-lg border p-5 mb-4"
className="rounded-lg border p-5 mb-4" style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}>
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)" }}> <h2 className="text-sm font-semibold uppercase tracking-wide mb-3" style={{ color: "var(--text-muted)" }}>
Actions Actions
</h2> </h2>
@@ -357,32 +429,21 @@ export default function DeviceInventoryDetail() {
{/* Assign to Customer card */} {/* Assign to Customer card */}
{canEdit && ["provisioned", "flashed"].includes(device?.mfg_status) && ( {canEdit && ["provisioned", "flashed"].includes(device?.mfg_status) && (
<div <div className="rounded-lg border p-5"
className="rounded-lg border p-5" style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}>
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)" }}> <h2 className="text-sm font-semibold uppercase tracking-wide mb-3" style={{ color: "var(--text-muted)" }}>
Assign to Customer Assign to Customer
</h2> </h2>
{assignSuccess ? ( {assignSuccess ? (
<div <div className="text-sm rounded-md p-3 border"
className="text-sm rounded-md p-3 border" style={{ backgroundColor: "#0a2e2a", borderColor: "#4dd6c8", color: "#4dd6c8" }}>
style={{ backgroundColor: "#0a2e2a", borderColor: "#4dd6c8", color: "#4dd6c8" }}
>
Device assigned and invitation email sent to <strong>{device?.owner}</strong>. Device assigned and invitation email sent to <strong>{device?.owner}</strong>.
</div> </div>
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
{assignError && ( {assignError && (
<div <div className="text-xs rounded p-2 border"
className="text-xs rounded p-2 border" style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}>
style={{
backgroundColor: "var(--danger-bg)",
borderColor: "var(--danger)",
color: "var(--danger-text)",
}}
>
{assignError} {assignError}
</div> </div>
)} )}
@@ -392,11 +453,7 @@ export default function DeviceInventoryDetail() {
value={assignEmail} value={assignEmail}
onChange={(e) => setAssignEmail(e.target.value)} onChange={(e) => setAssignEmail(e.target.value)}
className="w-full px-3 py-2 rounded-md text-sm border" className="w-full px-3 py-2 rounded-md text-sm border"
style={{ style={{ backgroundColor: "var(--bg-input)", borderColor: "var(--border-input)", color: "var(--text-primary)" }}
backgroundColor: "var(--bg-input)",
borderColor: "var(--border-input)",
color: "var(--text-primary)",
}}
/> />
<input <input
type="text" type="text"
@@ -404,11 +461,7 @@ export default function DeviceInventoryDetail() {
value={assignName} value={assignName}
onChange={(e) => setAssignName(e.target.value)} onChange={(e) => setAssignName(e.target.value)}
className="w-full px-3 py-2 rounded-md text-sm border" className="w-full px-3 py-2 rounded-md text-sm border"
style={{ style={{ backgroundColor: "var(--bg-input)", borderColor: "var(--border-input)", color: "var(--text-primary)" }}
backgroundColor: "var(--bg-input)",
borderColor: "var(--border-input)",
color: "var(--text-primary)",
}}
/> />
<button <button
onClick={handleAssign} onClick={handleAssign}
@@ -425,6 +478,16 @@ export default function DeviceInventoryDetail() {
)} )}
</div> </div>
)} )}
{/* Delete modal */}
{showDeleteModal && (
<DeleteModal
device={device}
onConfirm={handleDelete}
onCancel={() => setShowDeleteModal(false)}
deleting={deleting}
/>
)}
</div> </div>
); );
} }

File diff suppressed because it is too large Load Diff