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

@@ -3,7 +3,10 @@ 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 BOARD_TYPE_LABELS = {
vs: "Vesper", vp: "Vesper Plus", vx: "Vesper Pro",
cb: "Chronos", cp: "Chronos Pro", am: "Agnus Mini", ab: "Agnus",
};
const STATUS_STYLES = {
manufactured: { bg: "var(--bg-card-hover)", color: "var(--text-muted)" },
@@ -18,6 +21,17 @@ const STATUS_OPTIONS = [
"manufactured", "flashed", "provisioned", "sold", "claimed", "decommissioned",
];
// Statuses that require serial confirmation before delete
const PROTECTED_STATUSES = ["sold", "claimed"];
function formatHwVersion(v) {
if (!v) return "—";
if (/^\d+\.\d+/.test(v)) return `Rev ${v}`;
const n = parseInt(v, 10);
if (!isNaN(n)) return `Rev ${n}.0`;
return `Rev ${v}`;
}
function StatusBadge({ status }) {
const style = STATUS_STYLES[status] || STATUS_STYLES.manufactured;
return (
@@ -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)" }}>
{label}
</p>
<p
className={`text-sm ${mono ? "font-mono" : ""}`}
style={{ color: "var(--text-primary)" }}
>
<p className={`text-sm ${mono ? "font-mono" : ""}`} style={{ color: "var(--text-primary)" }}>
{value || "—"}
</p>
</div>
);
}
// ─── Delete Confirmation Modal ────────────────────────────────────────────────
function DeleteModal({ device, onConfirm, onCancel, deleting }) {
const isProtected = PROTECTED_STATUSES.includes(device?.mfg_status);
const [typed, setTyped] = useState("");
const confirmed = !isProtected || typed === device?.serial_number;
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center"
style={{ backgroundColor: "rgba(0,0,0,0.6)" }}
>
<div
className="rounded-xl border p-6 w-full max-w-md"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--danger)" }}
>
<h2 className="text-base font-bold mb-2" style={{ color: "var(--danger-text)" }}>
{isProtected ? "⚠ Delete Protected Device" : "Delete Device"}
</h2>
{isProtected ? (
<>
<p className="text-sm mb-3" style={{ color: "var(--text-secondary)" }}>
This device has status <strong>{device.mfg_status}</strong> and is linked to a customer.
Deleting it is irreversible and may break the customer's access.
</p>
<p className="text-sm mb-3" style={{ color: "var(--text-secondary)" }}>
To confirm, type the serial number exactly:
</p>
<p className="font-mono text-sm mb-3 px-3 py-2 rounded" style={{ backgroundColor: "var(--bg-primary)", color: "var(--text-primary)" }}>
{device.serial_number}
</p>
<input
type="text"
value={typed}
onChange={(e) => setTyped(e.target.value)}
onPaste={(e) => e.preventDefault()}
placeholder="Type serial number here…"
autoComplete="off"
className="w-full px-3 py-2 rounded-md text-sm border mb-4 font-mono"
style={{
backgroundColor: "var(--bg-input)",
borderColor: typed === device.serial_number ? "var(--accent)" : "var(--border-input)",
color: "var(--text-primary)",
}}
/>
</>
) : (
<p className="text-sm mb-4" style={{ color: "var(--text-secondary)" }}>
Are you sure you want to delete <strong className="font-mono">{device?.serial_number}</strong>?
This cannot be undone.
</p>
)}
<div className="flex gap-3 justify-end">
<button
onClick={onCancel}
className="px-4 py-1.5 text-sm rounded-md hover:opacity-80 cursor-pointer"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)" }}
>
Cancel
</button>
<button
onClick={onConfirm}
disabled={!confirmed || deleting}
className="px-4 py-1.5 text-sm rounded-md font-medium hover:opacity-90 cursor-pointer disabled:opacity-40"
style={{ backgroundColor: "var(--danger)", color: "#fff" }}
>
{deleting ? "Deleting…" : "Delete Device"}
</button>
</div>
</div>
</div>
);
}
// ─── Main Component ───────────────────────────────────────────────────────────
export default function DeviceInventoryDetail() {
const { sn } = useParams();
const navigate = useNavigate();
const { hasPermission } = useAuth();
const canEdit = hasPermission("manufacturing", "edit");
const canEdit = hasPermission("manufacturing", "edit");
const canDelete = hasPermission("manufacturing", "delete");
const [device, setDevice] = useState(null);
const [loading, setLoading] = useState(true);
@@ -70,6 +160,9 @@ export default function DeviceInventoryDetail() {
const [assignError, setAssignError] = useState("");
const [assignSuccess, setAssignSuccess] = useState(false);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [deleting, setDeleting] = useState(false);
const loadDevice = async () => {
setLoading(true);
setError("");
@@ -83,9 +176,7 @@ export default function DeviceInventoryDetail() {
}
};
useEffect(() => {
loadDevice();
}, [sn]);
useEffect(() => { loadDevice(); }, [sn]);
const handleStatusSave = async () => {
setStatusError("");
@@ -111,10 +202,7 @@ export default function DeviceInventoryDetail() {
try {
const updated = await api.request(`/manufacturing/devices/${sn}/assign`, {
method: "POST",
body: JSON.stringify({
customer_email: assignEmail,
customer_name: assignName || null,
}),
body: JSON.stringify({ customer_email: assignEmail, customer_name: assignName || null }),
});
setDevice(updated);
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) => {
if (!iso) return "—";
try {
@@ -159,9 +262,7 @@ export default function DeviceInventoryDetail() {
year: "numeric", month: "short", day: "numeric",
hour: "2-digit", minute: "2-digit",
});
} catch {
return iso;
}
} catch { return iso; }
};
if (loading) {
@@ -175,20 +276,9 @@ export default function DeviceInventoryDetail() {
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)",
}}
style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}
>
{error}
</div>
@@ -197,67 +287,67 @@ export default function DeviceInventoryDetail() {
}
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 style={{ maxWidth: 640 }}>
{/* Title row */}
<div className="flex items-start justify-between mb-6">
<div>
<h1 className="text-xl font-bold font-mono" style={{ color: "var(--text-heading)" }}>
{device?.serial_number}
</h1>
{device?.device_name && (
<p className="text-sm mt-0.5" style={{ color: "var(--text-muted)" }}>{device.device_name}</p>
)}
</div>
{canDelete && (
<button
onClick={() => setShowDeleteModal(true)}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-md font-medium hover:opacity-80 cursor-pointer transition-opacity"
style={{ backgroundColor: "var(--danger-bg)", color: "var(--danger-text)", border: "1px solid var(--danger)" }}
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
Delete Device
</button>
)}
</div>
{error && (
<div
className="text-sm rounded-md p-3 mb-4 border"
style={{
backgroundColor: "var(--danger-bg)",
borderColor: "var(--danger)",
color: "var(--danger-text)",
}}
>
<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)" }}
>
<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="HW Version" value={formatHwVersion(device?.hw_version)} />
<Field label="Batch ID" value={device?.mfg_batch_id} mono />
<Field label="Created At" value={formatDate(device?.created_at)} />
<Field label="Owner" value={device?.owner} />
{device?.device_name && (
<Field label="Device Name" value={device.device_name} />
)}
</div>
</div>
{/* Status card */}
<div
className="rounded-lg border p-5 mb-4"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
>
<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);
}}
onClick={() => { setNewStatus(device.mfg_status); setEditingStatus(true); }}
className="text-xs hover:opacity-80 cursor-pointer"
style={{ color: "var(--text-link)" }}
>
@@ -271,14 +361,8 @@ export default function DeviceInventoryDetail() {
) : (
<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)",
}}
>
<div className="text-xs rounded p-2 border"
style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}>
{statusError}
</div>
)}
@@ -286,15 +370,9 @@ export default function DeviceInventoryDetail() {
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)",
}}
style={{ backgroundColor: "var(--bg-input)", borderColor: "var(--border-input)", color: "var(--text-primary)" }}
>
{STATUS_OPTIONS.map((s) => (
<option key={s} value={s}>{s}</option>
))}
{STATUS_OPTIONS.map((s) => <option key={s} value={s}>{s}</option>)}
</select>
<input
type="text"
@@ -302,11 +380,7 @@ export default function DeviceInventoryDetail() {
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)",
}}
style={{ backgroundColor: "var(--bg-input)", borderColor: "var(--border-input)", color: "var(--text-primary)" }}
/>
<div className="flex gap-2">
<button
@@ -330,10 +404,8 @@ export default function DeviceInventoryDetail() {
</div>
{/* Actions card */}
<div
className="rounded-lg border p-5 mb-4"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
>
<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-3" style={{ color: "var(--text-muted)" }}>
Actions
</h2>
@@ -357,32 +429,21 @@ export default function DeviceInventoryDetail() {
{/* Assign to Customer card */}
{canEdit && ["provisioned", "flashed"].includes(device?.mfg_status) && (
<div
className="rounded-lg border p-5"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
>
<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)" }}>
Assign to Customer
</h2>
{assignSuccess ? (
<div
className="text-sm rounded-md p-3 border"
style={{ backgroundColor: "#0a2e2a", borderColor: "#4dd6c8", color: "#4dd6c8" }}
>
<div className="text-sm rounded-md p-3 border"
style={{ backgroundColor: "#0a2e2a", borderColor: "#4dd6c8", color: "#4dd6c8" }}>
Device assigned and invitation email sent to <strong>{device?.owner}</strong>.
</div>
) : (
<div className="space-y-3">
{assignError && (
<div
className="text-xs rounded p-2 border"
style={{
backgroundColor: "var(--danger-bg)",
borderColor: "var(--danger)",
color: "var(--danger-text)",
}}
>
<div className="text-xs rounded p-2 border"
style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}>
{assignError}
</div>
)}
@@ -392,11 +453,7 @@ export default function DeviceInventoryDetail() {
value={assignEmail}
onChange={(e) => setAssignEmail(e.target.value)}
className="w-full px-3 py-2 rounded-md text-sm border"
style={{
backgroundColor: "var(--bg-input)",
borderColor: "var(--border-input)",
color: "var(--text-primary)",
}}
style={{ backgroundColor: "var(--bg-input)", borderColor: "var(--border-input)", color: "var(--text-primary)" }}
/>
<input
type="text"
@@ -404,11 +461,7 @@ export default function DeviceInventoryDetail() {
value={assignName}
onChange={(e) => setAssignName(e.target.value)}
className="w-full px-3 py-2 rounded-md text-sm border"
style={{
backgroundColor: "var(--bg-input)",
borderColor: "var(--border-input)",
color: "var(--text-primary)",
}}
style={{ backgroundColor: "var(--bg-input)", borderColor: "var(--border-input)", color: "var(--text-primary)" }}
/>
<button
onClick={handleAssign}
@@ -425,6 +478,16 @@ export default function DeviceInventoryDetail() {
)}
</div>
)}
{/* Delete modal */}
{showDeleteModal && (
<DeleteModal
device={device}
onConfirm={handleDelete}
onCancel={() => setShowDeleteModal(false)}
deleting={deleting}
/>
)}
</div>
);
}