diff --git a/backend/manufacturing/models.py b/backend/manufacturing/models.py
index d9698aa..ed48648 100644
--- a/backend/manufacturing/models.py
+++ b/backend/manufacturing/models.py
@@ -5,14 +5,22 @@ from enum import Enum
class BoardType(str, Enum):
vs = "vs" # Vesper
- vp = "vp" # Vesper+
- vx = "vx" # VesperPro
+ vp = "vp" # Vesper Plus
+ vx = "vx" # Vesper Pro
+ cb = "cb" # Chronos
+ cp = "cp" # Chronos Pro
+ am = "am" # Agnus Mini
+ ab = "ab" # Agnus
BOARD_TYPE_LABELS = {
"vs": "Vesper",
- "vp": "Vesper+",
- "vx": "VesperPro",
+ "vp": "Vesper Plus",
+ "vx": "Vesper Pro",
+ "cb": "Chronos",
+ "cp": "Chronos Pro",
+ "am": "Agnus Mini",
+ "ab": "Agnus",
}
@@ -27,7 +35,11 @@ class MfgStatus(str, Enum):
class BatchCreate(BaseModel):
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)
@@ -49,6 +61,7 @@ class DeviceInventoryItem(BaseModel):
created_at: Optional[str] = None
owner: Optional[str] = None
assigned_to: Optional[str] = None
+ device_name: Optional[str] = None
class DeviceInventoryListResponse(BaseModel):
diff --git a/backend/manufacturing/router.py b/backend/manufacturing/router.py
index 9777398..b028713 100644
--- a/backend/manufacturing/router.py
+++ b/backend/manufacturing/router.py
@@ -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 RedirectResponse
from typing import Optional
@@ -13,6 +13,7 @@ from manufacturing.models import (
)
from manufacturing import service
from manufacturing import audit
+from shared.exceptions import NotFoundError
router = APIRouter(prefix="/api/manufacturing", tags=["manufacturing"])
@@ -129,6 +130,41 @@ async def assign_device(
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")
def redirect_firmware(
sn: str,
diff --git a/backend/manufacturing/service.py b/backend/manufacturing/service.py
index 84bf68e..5cf29de 100644
--- a/backend/manufacturing/service.py
+++ b/backend/manufacturing/service.py
@@ -46,6 +46,7 @@ def _doc_to_inventory_item(doc) -> DeviceInventoryItem:
created_at=created_str,
owner=data.get("owner"),
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)
+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:
"""Return the FastAPI download URL for the latest stable firmware for this device's hw_type."""
from firmware.service import get_latest
diff --git a/frontend/src/devices/DeviceDetail.jsx b/frontend/src/devices/DeviceDetail.jsx
index 5d1aff3..f09e119 100644
--- a/frontend/src/devices/DeviceDetail.jsx
+++ b/frontend/src/devices/DeviceDetail.jsx
@@ -17,6 +17,23 @@ L.Icon.Default.mergeOptions({
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 ---
function Field({ label, children }) {
@@ -3023,7 +3040,7 @@ export default function DeviceDetail() {
{isOnline ? "Online" : "Offline"}
{mqttStatus && (
- {mqttStatus.seconds_since_heartbeat}s ago
+ {formatSecondsAgo(mqttStatus.seconds_since_heartbeat)}
)}
diff --git a/frontend/src/manufacturing/BatchCreator.jsx b/frontend/src/manufacturing/BatchCreator.jsx
index 22b9549..3ae7119 100644
--- a/frontend/src/manufacturing/BatchCreator.jsx
+++ b/frontend/src/manufacturing/BatchCreator.jsx
@@ -3,30 +3,35 @@ 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)" },
+ { 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" },
];
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 [boardType, setBoardType] = useState(null);
+ const [boardVersion, setBoardVersion] = useState("1.0");
+ 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();
+ if (!boardType) return;
setError("");
setResult(null);
setSaving(true);
try {
const data = await api.post("/manufacturing/batch", {
board_type: boardType,
- board_version: boardVersion,
+ board_version: boardVersion.trim(),
quantity: Number(quantity),
});
setResult(data);
@@ -44,21 +49,17 @@ export default function BatchCreator() {
setTimeout(() => setCopied(false), 2000);
};
+ const inputStyle = {
+ backgroundColor: "var(--bg-input)",
+ borderColor: "var(--border-input)",
+ color: "var(--text-primary)",
+ };
+
return (
-
-
-
- /
-
- New Batch
-
-
+
+
+ New Batch
+
{!result ? (
{error && (
-
@@ -223,7 +222,7 @@ export default function BatchCreator() {
View Inventory