style: Updated the overall UI of the provisining pages
This commit is contained in:
@@ -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 && (
|
||||
<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>
|
||||
|
||||
@@ -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 (
|
||||
<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>
|
||||
<div style={{ maxWidth: 640 }}>
|
||||
<h1 className="text-xl font-bold mb-6" style={{ color: "var(--text-heading)" }}>
|
||||
New Batch
|
||||
</h1>
|
||||
|
||||
{!result ? (
|
||||
<div
|
||||
@@ -70,65 +71,69 @@ export default function BatchCreator() {
|
||||
</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)",
|
||||
}}
|
||||
>
|
||||
<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">
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
{/* Board Type tiles */}
|
||||
<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
|
||||
</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 className="grid grid-cols-2 gap-2">
|
||||
{BOARD_TYPES.map((bt) => {
|
||||
const isSel = boardType === bt.value;
|
||||
return (
|
||||
<button
|
||||
key={bt.value}
|
||||
type="button"
|
||||
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)",
|
||||
boxShadow: isSel ? "0 0 0 1px #22c55e" : "none",
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{/* Board Revision */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1.5" style={{ color: "var(--text-secondary)" }}>
|
||||
Board Version
|
||||
Board Revision
|
||||
</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)",
|
||||
}}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium" style={{ color: "var(--text-muted)" }}>Rev</span>
|
||||
<input
|
||||
type="text"
|
||||
value={boardVersion}
|
||||
onChange={(e) => setBoardVersion(e.target.value)}
|
||||
placeholder="1.0"
|
||||
required
|
||||
className="px-3 py-2 rounded-md text-sm border w-32"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{/* Quantity */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1.5" style={{ color: "var(--text-secondary)" }}>
|
||||
Quantity
|
||||
@@ -141,22 +146,18 @@ export default function BatchCreator() {
|
||||
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)",
|
||||
}}
|
||||
style={inputStyle}
|
||||
/>
|
||||
</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"
|
||||
disabled={saving || !boardType}
|
||||
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)" }}
|
||||
>
|
||||
{saving ? "Generating…" : `Generate ${quantity} Serial Number${quantity > 1 ? "s" : ""}`}
|
||||
{saving ? "Generating…" : `Generate ${quantity} Serial Number${Number(quantity) > 1 ? "s" : ""}`}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -187,7 +188,7 @@ export default function BatchCreator() {
|
||||
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}
|
||||
{BOARD_TYPES.find((b) => b.value === result.board_type)?.name || result.board_type} Rev {result.board_version}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -201,9 +202,7 @@ export default function BatchCreator() {
|
||||
}}
|
||||
>
|
||||
{result.serial_numbers.map((sn) => (
|
||||
<div key={sn} className="py-0.5">
|
||||
{sn}
|
||||
</div>
|
||||
<div key={sn} className="py-0.5">{sn}</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -223,7 +222,7 @@ export default function BatchCreator() {
|
||||
View Inventory
|
||||
</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"
|
||||
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)" }}
|
||||
>
|
||||
|
||||
@@ -1,9 +1,21 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useRef } 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" };
|
||||
// ─── 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 = {
|
||||
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)" },
|
||||
};
|
||||
|
||||
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 }) {
|
||||
const style = STATUS_STYLES[status] || STATUS_STYLES.manufactured;
|
||||
return (
|
||||
@@ -26,31 +82,434 @@ 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() {
|
||||
const navigate = useNavigate();
|
||||
const { hasPermission } = useAuth();
|
||||
const canAdd = hasPermission("manufacturing", "add");
|
||||
const canAdd = hasPermission("manufacturing", "add");
|
||||
const canDelete = hasPermission("manufacturing", "delete");
|
||||
|
||||
const [devices, setDevices] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
const [search, setSearch] = useState("");
|
||||
const [statusFilter, setStatusFilter] = 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 () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (search) params.set("search", search);
|
||||
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);
|
||||
// Clear selection on refresh
|
||||
setSelected(new Set());
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
@@ -58,67 +517,108 @@ export default function DeviceInventory() {
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchDevices();
|
||||
}, [search, statusFilter, hwTypeFilter]);
|
||||
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;
|
||||
const updateColVisible = (id, visible) => {
|
||||
const next = { ...colPrefs.visible, [id]: visible };
|
||||
setColPrefs((p) => ({ ...p, visible: next }));
|
||||
saveColumnPrefs(next, colPrefs.orderedIds);
|
||||
};
|
||||
|
||||
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;
|
||||
});
|
||||
};
|
||||
|
||||
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 (
|
||||
<div>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold" style={{ color: "var(--text-heading)" }}>
|
||||
Device Inventory
|
||||
</h1>
|
||||
<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 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 && (
|
||||
<button
|
||||
onClick={() => navigate("/manufacturing/batch/new")}
|
||||
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)" }}
|
||||
>
|
||||
+ New Batch
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
{/* Filters + column toggle */}
|
||||
<div className="flex flex-wrap gap-3 mb-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search serial number, batch, owner…"
|
||||
placeholder="Search serial, 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)",
|
||||
}}
|
||||
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)",
|
||||
}}
|
||||
style={{ backgroundColor: "var(--bg-input)", borderColor: "var(--border-input)", color: "var(--text-primary)" }}
|
||||
>
|
||||
<option value="">All Statuses</option>
|
||||
<option value="manufactured">Manufactured</option>
|
||||
@@ -132,113 +632,161 @@ export default function DeviceInventory() {
|
||||
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)",
|
||||
}}
|
||||
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>
|
||||
{BOARD_TYPES.map((bt) => (
|
||||
<option key={bt.value} value={bt.value}>{bt.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<ColumnToggle
|
||||
visible={colPrefs.visible}
|
||||
orderedIds={colPrefs.orderedIds}
|
||||
onChange={updateColVisible}
|
||||
onReorder={updateColOrder}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
{/* Multi-select action bar */}
|
||||
{selected.size > 0 && canDelete && (
|
||||
<div
|
||||
className="text-sm rounded-md p-3 mb-4 border"
|
||||
style={{
|
||||
backgroundColor: "var(--danger-bg)",
|
||||
borderColor: "var(--danger)",
|
||||
color: "var(--danger-text)",
|
||||
}}
|
||||
className="flex items-center justify-between px-4 py-2 rounded-md mb-3"
|
||||
style={{ backgroundColor: "var(--bg-card)", border: "1px solid var(--border-primary)" }}
|
||||
>
|
||||
<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}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="rounded-lg border overflow-hidden"
|
||||
style={{ borderColor: "var(--border-primary)" }}
|
||||
>
|
||||
{/* 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)" }}>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>
|
||||
{/* Checkbox column */}
|
||||
<th className="px-3 py-3 w-10">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allSelected}
|
||||
onChange={toggleAll}
|
||||
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>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<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…
|
||||
</td>
|
||||
</tr>
|
||||
) : devices.length === 0 ? (
|
||||
<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.
|
||||
{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>
|
||||
))
|
||||
devices.map((device) => {
|
||||
const isSelected = selected.has(device.id);
|
||||
return (
|
||||
<tr
|
||||
key={device.id}
|
||||
className="cursor-pointer transition-colors"
|
||||
style={{
|
||||
borderBottom: "1px solid var(--border-secondary)",
|
||||
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 = ""; }}
|
||||
>
|
||||
{/* Checkbox */}
|
||||
<td className="px-3 py-3" onClick={(e) => toggleRow(device.id, e)}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => {}}
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
</td>
|
||||
{/* Data cells */}
|
||||
{visibleCols.map((col) => (
|
||||
<td
|
||||
key={col.id}
|
||||
className="px-4 py-3"
|
||||
onClick={() => navigate(`/manufacturing/devices/${device.serial_number}`)}
|
||||
>
|
||||
{renderCell(col, device)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user