1092 lines
49 KiB
JavaScript
1092 lines
49 KiB
JavaScript
import { useState, useEffect, useRef, useCallback } from "react";
|
|
import { useParams, useNavigate } from "react-router-dom";
|
|
import { useAuth } from "../auth/AuthContext";
|
|
import api from "../api/client";
|
|
|
|
const BOARD_TYPE_LABELS = {
|
|
vesper: "Vesper", vesper_plus: "Vesper+", vesper_pro: "Vesper Pro",
|
|
chronos: "Chronos", chronos_pro: "Chronos Pro", agnus_mini: "Agnus Mini", agnus: "Agnus",
|
|
};
|
|
|
|
const LIFECYCLE = [
|
|
{ key: "manufactured", label: "Manufactured", icon: "🔩", color: "#94a3b8", bg: "#1e2a3a", accent: "#64748b" },
|
|
{ key: "flashed", label: "Flashed", icon: "⚡", color: "#60a5fa", bg: "#1e3a5f", accent: "#3b82f6" },
|
|
{ key: "provisioned", label: "Provisioned", icon: "📡", color: "#34d399", bg: "#064e3b", accent: "#10b981" },
|
|
{ key: "sold", label: "Sold", icon: "📦", color: "#c084fc", bg: "#2e1065", accent: "#a855f7" },
|
|
{ key: "claimed", label: "Claimed", icon: "✅", color: "#fb923c", bg: "#431407", accent: "#f97316" },
|
|
{ key: "decommissioned", label: "Decommissioned", icon: "🗑", color: "#f87171", bg: "#450a0a", accent: "#ef4444" },
|
|
];
|
|
|
|
const STEP_INDEX = Object.fromEntries(LIFECYCLE.map((s, i) => [s.key, i]));
|
|
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 formatDate(iso) {
|
|
if (!iso) return "—";
|
|
try {
|
|
return new Date(iso).toLocaleString("en-US", {
|
|
year: "numeric", month: "short", day: "numeric",
|
|
hour: "2-digit", minute: "2-digit",
|
|
});
|
|
} catch { return iso; }
|
|
}
|
|
|
|
function toDatetimeLocal(iso) {
|
|
if (!iso) return "";
|
|
try {
|
|
const d = new Date(iso);
|
|
const pad = (n) => String(n).padStart(2, "0");
|
|
return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
} catch { return ""; }
|
|
}
|
|
|
|
function Field({ label, value, mono = false }) {
|
|
return (
|
|
<div>
|
|
<p className="ui-field-label mb-0.5">{label}</p>
|
|
<p className={`text-sm ${mono ? "font-mono" : ""}`} style={{ color: "var(--text-primary)" }}>
|
|
{value || "—"}
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── User Search Modal (for Claimed) ──────────────────────────────────────────
|
|
|
|
function UserSearchModal({ onSelect, onCancel, existingUsers = [] }) {
|
|
const [query, setQuery] = useState("");
|
|
const [results, setResults] = useState([]);
|
|
const [searching, setSearching] = useState(false);
|
|
const inputRef = useRef(null);
|
|
|
|
useEffect(() => { inputRef.current?.focus(); }, []);
|
|
|
|
const search = useCallback(async (q) => {
|
|
setSearching(true);
|
|
try {
|
|
const data = await api.get(`/users?search=${encodeURIComponent(q)}&limit=20`);
|
|
setResults(data.users || data || []);
|
|
} catch {
|
|
setResults([]);
|
|
} finally {
|
|
setSearching(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const t = setTimeout(() => search(query), 300);
|
|
return () => clearTimeout(t);
|
|
}, [query, search]);
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center" style={{ backgroundColor: "rgba(0,0,0,0.65)" }}>
|
|
<div className="rounded-xl border p-6 w-full max-w-md shadow-2xl" style={{ backgroundColor: "var(--bg-card)", borderColor: "#f97316aa" }}>
|
|
<div className="flex items-center gap-3 mb-4">
|
|
<div className="w-8 h-8 rounded-lg flex items-center justify-center" style={{ backgroundColor: "#431407", border: "1px solid #f9731640" }}>
|
|
<span>✅</span>
|
|
</div>
|
|
<div>
|
|
<p className="text-xs font-semibold uppercase tracking-wider" style={{ color: "#fb923c" }}>Set as Claimed</p>
|
|
<h2 className="text-sm font-bold" style={{ color: "var(--text-heading)" }}>Assign a User</h2>
|
|
</div>
|
|
</div>
|
|
<p className="text-xs mb-3" style={{ color: "var(--text-muted)" }}>
|
|
A device is "Claimed" when a registered user has been assigned to it. Search and select the user to assign.
|
|
</p>
|
|
|
|
{/* Keep existing users option */}
|
|
{existingUsers.length > 0 && (
|
|
<div className="mb-3">
|
|
<p className="text-xs font-medium mb-1.5" style={{ color: "var(--text-muted)" }}>Already assigned</p>
|
|
{existingUsers.map((u) => (
|
|
<button
|
|
key={u.uid}
|
|
type="button"
|
|
onClick={() => onSelect(u, true)}
|
|
className="w-full text-left px-3 py-2.5 text-sm rounded-lg border mb-1.5 flex items-center gap-3 cursor-pointer hover:opacity-80 transition-opacity"
|
|
style={{ backgroundColor: "#431407", borderColor: "#f9731640", color: "var(--text-primary)" }}
|
|
>
|
|
<div className="w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0"
|
|
style={{ backgroundColor: "#f9731630", color: "#fb923c" }}>
|
|
{(u.display_name || u.email || "U")[0].toUpperCase()}
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<span className="font-medium block truncate">{u.display_name || u.email || u.uid}</span>
|
|
{u.email && u.display_name && <span className="text-xs block" style={{ color: "var(--text-muted)" }}>{u.email}</span>}
|
|
</div>
|
|
<span className="text-xs flex-shrink-0 px-2 py-0.5 rounded"
|
|
style={{ backgroundColor: "#f9731620", color: "#fb923c", border: "1px solid #f9731640" }}>
|
|
Keep
|
|
</span>
|
|
</button>
|
|
))}
|
|
<div className="flex items-center gap-2 my-3">
|
|
<div className="flex-1 h-px" style={{ backgroundColor: "var(--border-secondary)" }} />
|
|
<span className="text-xs" style={{ color: "var(--text-muted)" }}>or assign a different user</span>
|
|
<div className="flex-1 h-px" style={{ backgroundColor: "var(--border-secondary)" }} />
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div style={{ position: "relative" }} className="mb-3">
|
|
<input
|
|
ref={inputRef}
|
|
type="text"
|
|
value={query}
|
|
onChange={(e) => setQuery(e.target.value)}
|
|
placeholder="Search by name or email…"
|
|
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)" }}
|
|
/>
|
|
{searching && <span className="absolute right-3 top-1/2 -translate-y-1/2 text-xs" style={{ color: "var(--text-muted)" }}>…</span>}
|
|
</div>
|
|
<div className="rounded-md border overflow-y-auto" style={{ borderColor: "var(--border-secondary)", maxHeight: 220, minHeight: 48 }}>
|
|
{results.length === 0 ? (
|
|
<p className="px-3 py-3 text-sm" style={{ color: "var(--text-muted)" }}>
|
|
{searching ? "Searching…" : query ? "No users found." : "Type to search users…"}
|
|
</p>
|
|
) : results.map((u) => (
|
|
<button key={u.id || u.uid} type="button" onClick={() => onSelect(u, false)}
|
|
className="w-full text-left px-3 py-2.5 text-sm hover:opacity-80 cursor-pointer border-b last:border-b-0 transition-colors"
|
|
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-secondary)", color: "var(--text-primary)" }}>
|
|
<span className="font-medium">{u.display_name || u.name || u.email || u.id}</span>
|
|
{u.email && <span className="block text-xs" style={{ color: "var(--text-muted)" }}>{u.email}</span>}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<div className="flex gap-3 justify-end mt-4">
|
|
<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>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Customer Search Modal ─────────────────────────────────────────────────
|
|
|
|
function CustomerSearchModal({ onSelect, onCancel }) {
|
|
const [query, setQuery] = useState("");
|
|
const [results, setResults] = useState([]);
|
|
const [searching, setSearching] = useState(false);
|
|
const inputRef = useRef(null);
|
|
|
|
useEffect(() => { inputRef.current?.focus(); }, []);
|
|
|
|
const search = useCallback(async (q) => {
|
|
setSearching(true);
|
|
try {
|
|
const data = await api.get(`/manufacturing/customers/search?q=${encodeURIComponent(q)}`);
|
|
setResults(data.results || []);
|
|
} catch {
|
|
setResults([]);
|
|
} finally {
|
|
setSearching(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const t = setTimeout(() => search(query), 250);
|
|
return () => clearTimeout(t);
|
|
}, [query, search]);
|
|
|
|
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(--border-primary)" }}>
|
|
<h2 className="text-base font-bold mb-4" style={{ color: "var(--text-heading)" }}>Assign to Customer</h2>
|
|
<div style={{ position: "relative" }} className="mb-3">
|
|
<input
|
|
ref={inputRef}
|
|
type="text"
|
|
value={query}
|
|
onChange={(e) => setQuery(e.target.value)}
|
|
placeholder="Search by name, email, phone, org, tags…"
|
|
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)" }}
|
|
/>
|
|
{searching && <span className="absolute right-3 top-1/2 -translate-y-1/2 text-xs" style={{ color: "var(--text-muted)" }}>…</span>}
|
|
</div>
|
|
<div className="rounded-md border overflow-y-auto" style={{ borderColor: "var(--border-secondary)", maxHeight: 260, minHeight: 48 }}>
|
|
{results.length === 0 ? (
|
|
<p className="px-3 py-3 text-sm" style={{ color: "var(--text-muted)" }}>
|
|
{searching ? "Searching…" : query ? "No customers found." : "Type to search customers…"}
|
|
</p>
|
|
) : results.map((c) => {
|
|
const fullName = [c.name, c.surname].filter(Boolean).join(" ") || c.email || c.id;
|
|
return (
|
|
<button key={c.id} type="button" onClick={() => onSelect(c)}
|
|
className="w-full text-left px-3 py-2.5 text-sm hover:opacity-80 cursor-pointer border-b last:border-b-0 transition-colors"
|
|
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-secondary)", color: "var(--text-primary)" }}>
|
|
<span className="font-semibold block">{fullName}</span>
|
|
{c.organization && <span className="block text-xs" style={{ color: "var(--text-muted)" }}>{c.organization}</span>}
|
|
{c.city && <span className="block text-xs" style={{ color: "var(--text-muted)" }}>{c.city}</span>}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
<div className="flex gap-3 justify-end mt-4">
|
|
<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>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Delete Device 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.
|
|
</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>
|
|
);
|
|
}
|
|
|
|
// ─── Lifecycle Entry Edit Modal ────────────────────────────────────────────────
|
|
|
|
function LifecycleEditModal({ entry, stepMeta, isCurrent, onSave, onDelete, onCancel }) {
|
|
const isNew = !entry;
|
|
const [dateVal, setDateVal] = useState(() => isNew ? toDatetimeLocal(new Date().toISOString()) : toDatetimeLocal(entry?.date));
|
|
const [note, setNote] = useState(entry?.note || "");
|
|
const [saving, setSaving] = useState(false);
|
|
const [deleting, setDeleting] = useState(false);
|
|
const [confirmDelete, setConfirmDelete] = useState(false);
|
|
|
|
const handleSave = async () => {
|
|
setSaving(true);
|
|
try {
|
|
const isoDate = dateVal ? new Date(dateVal).toISOString() : new Date().toISOString();
|
|
await onSave(isoDate, note);
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const handleDelete = async () => {
|
|
setDeleting(true);
|
|
try {
|
|
await onDelete();
|
|
} finally {
|
|
setDeleting(false);
|
|
setConfirmDelete(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center" style={{ backgroundColor: "rgba(0,0,0,0.75)" }}>
|
|
<div className="rounded-2xl border p-6 w-full max-w-sm shadow-2xl" style={{ backgroundColor: "var(--bg-card)", borderColor: stepMeta.accent + "60" }}>
|
|
{/* Header */}
|
|
<div className="flex items-center gap-3 mb-5">
|
|
<div className="w-9 h-9 rounded-xl flex items-center justify-center text-lg flex-shrink-0"
|
|
style={{ backgroundColor: stepMeta.bg, border: `1px solid ${stepMeta.accent}40` }}>
|
|
{stepMeta.icon}
|
|
</div>
|
|
<div>
|
|
<p className="text-xs font-semibold uppercase tracking-wider mb-0.5" style={{ color: stepMeta.color }}>
|
|
{isNew ? "Create Step" : "Edit Step"}
|
|
</p>
|
|
<h3 className="text-sm font-bold" style={{ color: "var(--text-heading)" }}>{stepMeta.label}</h3>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Date field */}
|
|
<div className="mb-4">
|
|
<label className="block text-xs font-medium mb-1.5" style={{ color: "var(--text-muted)" }}>Date & Time</label>
|
|
<input
|
|
type="datetime-local"
|
|
value={dateVal}
|
|
onChange={(e) => setDateVal(e.target.value)}
|
|
className="w-full px-3 py-2 rounded-lg text-sm border"
|
|
style={{ backgroundColor: "var(--bg-input)", borderColor: "var(--border-input)", color: "var(--text-primary)" }}
|
|
/>
|
|
</div>
|
|
|
|
{/* Note field */}
|
|
<div className="mb-5">
|
|
<label className="block text-xs font-medium mb-1.5" style={{ color: "var(--text-muted)" }}>
|
|
Note <span style={{ fontWeight: 400 }}>(optional)</span>
|
|
</label>
|
|
<textarea
|
|
value={note}
|
|
onChange={(e) => setNote(e.target.value)}
|
|
rows={3}
|
|
placeholder="Add a note about this step…"
|
|
className="w-full px-3 py-2 rounded-lg text-sm border resize-none"
|
|
style={{ backgroundColor: "var(--bg-input)", borderColor: "var(--border-input)", color: "var(--text-primary)" }}
|
|
/>
|
|
</div>
|
|
|
|
{/* Delete confirm */}
|
|
{confirmDelete && (
|
|
<div className="mb-4 rounded-lg p-3 border text-xs" style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}>
|
|
Delete this step record permanently?
|
|
<div className="flex gap-2 mt-2">
|
|
<button onClick={() => setConfirmDelete(false)} className="px-3 py-1 rounded cursor-pointer hover:opacity-80"
|
|
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)" }}>Cancel</button>
|
|
<button onClick={handleDelete} disabled={deleting} className="px-3 py-1 rounded cursor-pointer font-medium hover:opacity-90 disabled:opacity-50"
|
|
style={{ backgroundColor: "var(--danger)", color: "#fff" }}>
|
|
{deleting ? "Deleting…" : "Yes, Delete"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex gap-2 justify-between">
|
|
{/* Delete button — only for existing entries that are NOT current */}
|
|
{!isNew && !isCurrent && !confirmDelete ? (
|
|
<button onClick={() => setConfirmDelete(true)}
|
|
className="px-3 py-1.5 text-xs rounded-lg cursor-pointer hover:opacity-80"
|
|
style={{ backgroundColor: "var(--danger-bg)", color: "var(--danger-text)", border: "1px solid var(--danger)" }}>
|
|
Delete Step
|
|
</button>
|
|
) : <div />}
|
|
|
|
<div className="flex gap-2">
|
|
<button onClick={onCancel} className="px-4 py-1.5 text-sm rounded-lg hover:opacity-80 cursor-pointer"
|
|
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)" }}>Cancel</button>
|
|
<button onClick={handleSave} disabled={saving}
|
|
className="px-4 py-1.5 text-sm rounded-lg font-medium hover:opacity-90 cursor-pointer disabled:opacity-50"
|
|
style={{ backgroundColor: stepMeta.accent, color: "#fff" }}>
|
|
{saving ? "Saving…" : isNew ? "Create" : "Save"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Lifecycle Ladder ────────────────────────────────────────────────────────
|
|
|
|
function LifecycleLadder({ device, canEdit, onStatusChange, onEditEntry, statusError }) {
|
|
const currentIndex = STEP_INDEX[device?.mfg_status] ?? 0;
|
|
const history = device?.lifecycle_history || [];
|
|
const [hoveredIndex, setHoveredIndex] = useState(null);
|
|
|
|
// Map status_id -> last entry in history
|
|
const historyMap = {};
|
|
history.forEach((entry) => {
|
|
historyMap[entry.status_id] = entry;
|
|
});
|
|
|
|
return (
|
|
<div>
|
|
{statusError && (
|
|
<div className="text-xs rounded-lg p-2.5 border mb-4"
|
|
style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}>
|
|
{statusError}
|
|
</div>
|
|
)}
|
|
|
|
<div style={{ display: "flex", flexDirection: "column", gap: 0 }}>
|
|
{LIFECYCLE.map((step, i) => {
|
|
const isCurrent = i === currentIndex;
|
|
const isPast = i < currentIndex;
|
|
const isFuture = i > currentIndex;
|
|
const isLast = i === LIFECYCLE.length - 1;
|
|
const entry = historyMap[step.key];
|
|
const hasData = !!entry;
|
|
const isHovered = hoveredIndex === i;
|
|
|
|
return (
|
|
<div key={step.key} style={{ display: "flex", alignItems: "stretch", gap: 0 }}>
|
|
{/* Left rail */}
|
|
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", width: 44, flexShrink: 0 }}>
|
|
{/* Step circle — clicking handled on the card now */}
|
|
<div
|
|
style={{
|
|
width: 38,
|
|
height: 38,
|
|
borderRadius: "50%",
|
|
border: `2px solid ${isCurrent ? step.accent : isPast ? step.accent + "70" : isHovered && canEdit ? step.accent + "50" : "var(--border-secondary)"}`,
|
|
backgroundColor: isCurrent ? step.bg : isPast ? step.bg : isHovered && canEdit ? step.bg + "60" : "var(--bg-primary)",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
fontSize: "1rem",
|
|
flexShrink: 0,
|
|
opacity: isFuture && !isHovered ? 0.35 : 1,
|
|
boxShadow: isCurrent ? `0 0 0 3px ${step.accent}25, 0 0 14px ${step.accent}25` : "none",
|
|
position: "relative",
|
|
transition: "all 0.2s",
|
|
pointerEvents: "none",
|
|
}}
|
|
>
|
|
{isPast && (
|
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"
|
|
style={{ position: "absolute", bottom: -2, right: -2, background: "var(--bg-card)", borderRadius: "50%" }}>
|
|
<circle cx="7" cy="7" r="6" fill={step.accent} opacity="0.9"/>
|
|
<path d="M4.5 7L6.2 8.7L9.5 5.3" stroke="white" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round"/>
|
|
</svg>
|
|
)}
|
|
<span style={{ fontSize: isCurrent ? "1rem" : "0.85rem" }}>{step.icon}</span>
|
|
</div>
|
|
|
|
{/* Connector line */}
|
|
{!isLast && (
|
|
<div style={{
|
|
width: 2,
|
|
flex: 1,
|
|
minHeight: 12,
|
|
background: isPast
|
|
? `linear-gradient(to bottom, ${step.accent}90, ${LIFECYCLE[i+1].accent}40)`
|
|
: "var(--border-secondary)",
|
|
margin: "2px 0",
|
|
borderRadius: 1,
|
|
transition: "background 0.3s",
|
|
}} />
|
|
)}
|
|
</div>
|
|
|
|
{/* Right clickable card */}
|
|
<div
|
|
role={canEdit ? "button" : undefined}
|
|
tabIndex={canEdit ? 0 : undefined}
|
|
onMouseEnter={() => setHoveredIndex(i)}
|
|
onMouseLeave={() => setHoveredIndex(null)}
|
|
onClick={(e) => {
|
|
// Don't trigger if clicking the EDIT button (it has its own handler)
|
|
if (e.target.closest("[data-edit-btn]")) return;
|
|
if (!canEdit) return;
|
|
onStatusChange(step.key);
|
|
}}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter" || e.key === " ") {
|
|
if (!canEdit) return;
|
|
onStatusChange(step.key);
|
|
}
|
|
}}
|
|
style={{
|
|
flex: 1,
|
|
marginLeft: 10,
|
|
marginBottom: isLast ? 0 : 6,
|
|
padding: "10px 12px",
|
|
borderRadius: 10,
|
|
border: `1px solid ${
|
|
isCurrent ? step.accent + "60"
|
|
: isHovered && canEdit ? step.accent + "35"
|
|
: isPast ? step.accent + "25"
|
|
: "var(--border-secondary)"
|
|
}`,
|
|
backgroundColor: isCurrent
|
|
? step.bg
|
|
: isHovered && canEdit
|
|
? step.bg + "55"
|
|
: isPast
|
|
? step.bg + "35"
|
|
: "transparent",
|
|
opacity: isFuture && !isHovered ? 0.38 : 1,
|
|
transition: "all 0.18s",
|
|
cursor: canEdit ? "pointer" : "default",
|
|
position: "relative",
|
|
display: "flex",
|
|
alignItems: hasData ? "flex-start" : "center",
|
|
justifyContent: "space-between",
|
|
gap: 8,
|
|
minHeight: 44,
|
|
}}
|
|
>
|
|
{/* Content */}
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
{/* Label row */}
|
|
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: hasData ? 3 : 0 }}>
|
|
<span style={{
|
|
fontSize: "0.78rem",
|
|
fontWeight: 700,
|
|
letterSpacing: "0.05em",
|
|
textTransform: "uppercase",
|
|
color: isCurrent ? step.color : isPast ? step.color + "bb" : isHovered ? step.color + "90" : "var(--text-muted)",
|
|
}}>
|
|
{step.label}
|
|
</span>
|
|
{isCurrent && (
|
|
<span style={{
|
|
fontSize: "0.58rem",
|
|
fontWeight: 700,
|
|
letterSpacing: "0.1em",
|
|
textTransform: "uppercase",
|
|
color: step.color,
|
|
backgroundColor: step.accent + "22",
|
|
border: `1px solid ${step.accent}45`,
|
|
padding: "1px 6px",
|
|
borderRadius: 4,
|
|
}}>CURRENT</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* Date + set_by */}
|
|
{hasData && (
|
|
<p style={{ fontSize: "0.7rem", color: isCurrent ? step.color + "99" : "var(--text-muted)", margin: 0, marginBottom: entry.note ? 2 : 0 }}>
|
|
{formatDate(entry.date)}
|
|
{entry.set_by && <span style={{ marginLeft: 6, opacity: 0.65 }}>· {entry.set_by}</span>}
|
|
</p>
|
|
)}
|
|
|
|
{/* Note */}
|
|
{entry?.note && (
|
|
<p style={{ fontSize: "0.7rem", color: "var(--text-secondary)", margin: 0, marginTop: 1, fontStyle: "italic", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
|
"{entry.note}"
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* EDIT button — only visible on hover */}
|
|
{canEdit && (
|
|
<button
|
|
data-edit-btn
|
|
type="button"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onEditEntry(i, entry || null);
|
|
}}
|
|
style={{
|
|
fontSize: "0.58rem",
|
|
fontWeight: 700,
|
|
letterSpacing: "0.1em",
|
|
textTransform: "uppercase",
|
|
color: isHovered ? step.color : "transparent",
|
|
backgroundColor: "transparent",
|
|
border: `1px solid ${isHovered ? step.accent + "55" : "transparent"}`,
|
|
borderRadius: 5,
|
|
padding: "2px 8px",
|
|
cursor: "pointer",
|
|
flexShrink: 0,
|
|
alignSelf: "flex-start",
|
|
transition: "all 0.15s",
|
|
pointerEvents: isHovered ? "auto" : "none",
|
|
}}
|
|
onMouseEnter={(e) => {
|
|
e.currentTarget.style.backgroundColor = step.bg;
|
|
e.currentTarget.style.borderColor = step.accent + "80";
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
e.currentTarget.style.backgroundColor = "transparent";
|
|
}}
|
|
>
|
|
EDIT
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Main Component ───────────────────────────────────────────────────────────
|
|
|
|
export default function DeviceInventoryDetail() {
|
|
const { sn } = useParams();
|
|
const navigate = useNavigate();
|
|
const { hasPermission } = useAuth();
|
|
const canEdit = hasPermission("manufacturing", "edit");
|
|
const canDelete = hasPermission("manufacturing", "delete");
|
|
|
|
const [device, setDevice] = useState(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState("");
|
|
|
|
const [statusSaving, setStatusSaving] = useState(false);
|
|
const [statusError, setStatusError] = useState("");
|
|
|
|
const [nvsDownloading, setNvsDownloading] = useState(false);
|
|
|
|
const [assignedCustomer, setAssignedCustomer] = useState(null);
|
|
const [assignSaving, setAssignSaving] = useState(false);
|
|
const [assignError, setAssignError] = useState("");
|
|
const [showCustomerModal, setShowCustomerModal] = useState(false);
|
|
|
|
// Claimed user assignment
|
|
const [showUserModal, setShowUserModal] = useState(false);
|
|
const [userSaving, setUserSaving] = useState(false);
|
|
|
|
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
|
const [deleting, setDeleting] = useState(false);
|
|
|
|
// Resolved user info for the assigned users section
|
|
const [resolvedUsers, setResolvedUsers] = useState([]); // [{uid, display_name, email}]
|
|
|
|
// Lifecycle edit modal: { stepIndex, entry|null }
|
|
const [editModalData, setEditModalData] = useState(null);
|
|
|
|
const loadDevice = async () => {
|
|
setLoading(true);
|
|
setError("");
|
|
try {
|
|
const data = await api.get(`/manufacturing/devices/${sn}`);
|
|
setDevice(data);
|
|
if (data.customer_id) fetchCustomerDetails(data.customer_id);
|
|
if (data.user_list?.length) fetchResolvedUsers(data.user_list);
|
|
} catch (err) {
|
|
setError(err.message);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const fetchCustomerDetails = async (customerId) => {
|
|
try {
|
|
const data = await api.get(`/manufacturing/customers/${customerId}`);
|
|
setAssignedCustomer(data || null);
|
|
} catch {
|
|
setAssignedCustomer(null);
|
|
}
|
|
};
|
|
|
|
const fetchResolvedUsers = async (uidList) => {
|
|
const results = await Promise.all(
|
|
uidList.map(async (uid) => {
|
|
try {
|
|
const u = await api.get(`/users/${uid}`);
|
|
return { uid, display_name: u.display_name || "", email: u.email || "" };
|
|
} catch {
|
|
return { uid, display_name: "", email: "" };
|
|
}
|
|
})
|
|
);
|
|
setResolvedUsers(results);
|
|
};
|
|
|
|
useEffect(() => { loadDevice(); }, [sn]);
|
|
|
|
// ── Status change ──────────────────────────────────────────────────────────
|
|
const handleStatusChange = async (newStatus) => {
|
|
if (newStatus === device?.mfg_status) return; // already current
|
|
|
|
if (newStatus === "claimed") {
|
|
// Open user-assign flow: user must pick a user first
|
|
setShowUserModal(true);
|
|
return;
|
|
}
|
|
if (newStatus === "sold" && !device?.customer_id) {
|
|
setShowCustomerModal(true);
|
|
return;
|
|
}
|
|
setStatusError("");
|
|
setStatusSaving(true);
|
|
try {
|
|
const updated = await api.request(`/manufacturing/devices/${sn}/status`, {
|
|
method: "PATCH",
|
|
body: JSON.stringify({ status: newStatus }),
|
|
});
|
|
setDevice(updated);
|
|
} catch (err) {
|
|
setStatusError(err.message);
|
|
} finally {
|
|
setStatusSaving(false);
|
|
}
|
|
};
|
|
|
|
// ── Claimed: assign user then set status ───────────────────────────────────
|
|
const handleClaimedUserSelect = async (user, keepExisting = false) => {
|
|
setShowUserModal(false);
|
|
setUserSaving(true);
|
|
setStatusError("");
|
|
try {
|
|
// 1. Add user to device's user_list (skip if keeping the existing user)
|
|
if (!keepExisting) {
|
|
await api.request(`/devices/${device.id}/users`, {
|
|
method: "POST",
|
|
body: JSON.stringify({ user_id: user.id || user.uid }),
|
|
});
|
|
}
|
|
// 2. Set status to claimed (backend guard now passes since user_list is non-empty)
|
|
const updated = await api.request(`/manufacturing/devices/${sn}/status`, {
|
|
method: "PATCH",
|
|
body: JSON.stringify({ status: "claimed", force_claimed: true }),
|
|
});
|
|
setDevice(updated);
|
|
if (updated.user_list?.length) fetchResolvedUsers(updated.user_list);
|
|
} catch (err) {
|
|
setStatusError(err.message);
|
|
} finally {
|
|
setUserSaving(false);
|
|
}
|
|
};
|
|
|
|
// ── Customer assign ────────────────────────────────────────────────────────
|
|
const handleSelectCustomer = async (customer) => {
|
|
setShowCustomerModal(false);
|
|
setAssignError("");
|
|
setAssignSaving(true);
|
|
try {
|
|
const updated = await api.request(`/manufacturing/devices/${sn}/assign`, {
|
|
method: "POST",
|
|
body: JSON.stringify({ customer_id: customer.id }),
|
|
});
|
|
setDevice(updated);
|
|
setAssignedCustomer(customer);
|
|
} catch (err) {
|
|
setAssignError(err.message);
|
|
} finally {
|
|
setAssignSaving(false);
|
|
}
|
|
};
|
|
|
|
// ── NVS download ───────────────────────────────────────────────────────────
|
|
const downloadNvs = async () => {
|
|
setNvsDownloading(true);
|
|
try {
|
|
const token = localStorage.getItem("access_token");
|
|
const response = await fetch(`/api/manufacturing/devices/${sn}/nvs.bin`, {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
});
|
|
if (!response.ok) {
|
|
const err = await response.json().catch(() => ({}));
|
|
throw new Error(err.detail || "Download failed");
|
|
}
|
|
const blob = await response.blob();
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement("a");
|
|
a.href = url;
|
|
a.download = `${sn}_nvs.bin`;
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
} catch (err) {
|
|
setError(err.message);
|
|
} finally {
|
|
setNvsDownloading(false);
|
|
}
|
|
};
|
|
|
|
// ── Device delete ──────────────────────────────────────────────────────────
|
|
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);
|
|
}
|
|
};
|
|
|
|
// ── Lifecycle edit helpers ─────────────────────────────────────────────────
|
|
// Build step_key -> last array index in lifecycle_history
|
|
const history = device?.lifecycle_history || [];
|
|
const historyIndexMap = {};
|
|
history.forEach((entry, idx) => { historyIndexMap[entry.status_id] = idx; });
|
|
|
|
const handleEditEntryByStep = (stepIndex, entry) => {
|
|
setEditModalData({ stepIndex, entry });
|
|
};
|
|
|
|
const handleSaveLifecycle = async (stepIndex, date, note) => {
|
|
const stepKey = LIFECYCLE[stepIndex]?.key;
|
|
const arrayIndex = historyIndexMap[stepKey];
|
|
|
|
if (arrayIndex === undefined) {
|
|
// No existing entry → create on the fly
|
|
const updated = await api.request(`/manufacturing/devices/${sn}/lifecycle`, {
|
|
method: "POST",
|
|
body: JSON.stringify({ status_id: stepKey, date, note }),
|
|
});
|
|
setDevice(updated);
|
|
} else {
|
|
// Edit existing
|
|
const updated = await api.request(`/manufacturing/devices/${sn}/lifecycle`, {
|
|
method: "PATCH",
|
|
body: JSON.stringify({ index: arrayIndex, date, note }),
|
|
});
|
|
setDevice(updated);
|
|
}
|
|
setEditModalData(null);
|
|
};
|
|
|
|
const handleDeleteLifecycleStep = async (stepIndex) => {
|
|
const stepKey = LIFECYCLE[stepIndex]?.key;
|
|
const arrayIndex = historyIndexMap[stepKey];
|
|
if (arrayIndex === undefined) { setEditModalData(null); return; }
|
|
const updated = await api.request(`/manufacturing/devices/${sn}/lifecycle/${arrayIndex}`, {
|
|
method: "DELETE",
|
|
});
|
|
setDevice(updated);
|
|
setEditModalData(null);
|
|
};
|
|
|
|
// ── Early returns ──────────────────────────────────────────────────────────
|
|
if (loading) {
|
|
return (
|
|
<div className="flex items-center justify-center py-20">
|
|
<p className="text-sm" style={{ color: "var(--text-muted)" }}>Loading…</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (error && !device) {
|
|
return (
|
|
<div className="text-sm rounded-md p-4 border"
|
|
style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}>
|
|
{error}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const currentStepKey = device?.mfg_status;
|
|
const editStepMeta = editModalData ? LIFECYCLE[editModalData.stepIndex] : null;
|
|
const editIsCurrent = editModalData ? LIFECYCLE[editModalData.stepIndex]?.key === currentStepKey : false;
|
|
const userList = device?.user_list || [];
|
|
|
|
return (
|
|
<div style={{ width: "100%" }}>
|
|
{/* Title row */}
|
|
<div className="flex items-start justify-between mb-6">
|
|
<div>
|
|
<h1 className="text-2xl 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)" }}>
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
{/* 2-column 50/50 grid */}
|
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 20, alignItems: "start", width: "100%" }}>
|
|
|
|
{/* ── LEFT COLUMN ──────────────────────────────────────────────────── */}
|
|
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
|
|
|
{/* Device Identity */}
|
|
<div className="ui-section-card">
|
|
<div className="ui-section-card__title-row">
|
|
<h2 className="ui-section-card__title">Device Identity</h2>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<Field label="Serial Number" value={device?.serial_number} mono />
|
|
<Field label="Batch ID" value={device?.mfg_batch_id} mono />
|
|
<Field label="Hardware Family" value={BOARD_TYPE_LABELS[device?.hw_type] || device?.hw_type} />
|
|
<Field label="HW Version" value={formatHwVersion(device?.hw_version)} />
|
|
<Field label="Created At" value={formatDate(device?.created_at)} />
|
|
{device?.device_name && <Field label="Device Name" value={device.device_name} />}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Assign to Customer */}
|
|
{canEdit && (
|
|
<div className="ui-section-card">
|
|
<div className="ui-section-card__title-row">
|
|
<h2 className="ui-section-card__title">Assign to Customer</h2>
|
|
</div>
|
|
|
|
{assignError && (
|
|
<div className="text-xs rounded p-2 border mb-3"
|
|
style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}>
|
|
{assignError}
|
|
</div>
|
|
)}
|
|
|
|
{device?.customer_id ? (
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex-1 flex items-center gap-3 px-3 py-2 rounded-md border"
|
|
style={{ backgroundColor: "var(--bg-primary)", borderColor: "var(--border-secondary)" }}>
|
|
<div className="w-7 h-7 rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0"
|
|
style={{ backgroundColor: "var(--badge-blue-bg)", color: "var(--badge-blue-text)" }}>
|
|
{(assignedCustomer?.name || "?")[0].toUpperCase()}
|
|
</div>
|
|
<div className="min-w-0">
|
|
<p className="text-sm font-semibold" style={{ color: "var(--text-primary)" }}>
|
|
{[assignedCustomer?.name, assignedCustomer?.surname].filter(Boolean).join(" ") || "Customer"}
|
|
</p>
|
|
{assignedCustomer?.organization && <p className="text-xs" style={{ color: "var(--text-muted)" }}>{assignedCustomer.organization}</p>}
|
|
{assignedCustomer?.city && <p className="text-xs" style={{ color: "var(--text-muted)" }}>{assignedCustomer.city}</p>}
|
|
</div>
|
|
</div>
|
|
<button type="button" onClick={() => setShowCustomerModal(true)} disabled={assignSaving}
|
|
className="px-3 py-1.5 text-xs rounded-md cursor-pointer hover:opacity-80 disabled:opacity-50"
|
|
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)", border: "1px solid var(--border-primary)" }}>
|
|
Reassign
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<div>
|
|
<p className="text-sm mb-3" style={{ color: "var(--text-muted)" }}>
|
|
No customer assigned. Assigning a customer will set the device status to <em>Sold</em>.
|
|
</p>
|
|
<button type="button" onClick={() => setShowCustomerModal(true)} disabled={assignSaving}
|
|
className="flex items-center gap-2 px-4 py-2 text-sm rounded-md hover:opacity-90 transition-opacity cursor-pointer disabled:opacity-50"
|
|
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}>
|
|
{assignSaving ? "Assigning…" : "+ Assign to Customer"}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Assigned Users */}
|
|
<div className="ui-section-card">
|
|
<div className="ui-section-card__title-row">
|
|
<h2 className="ui-section-card__title">Assigned Users</h2>
|
|
</div>
|
|
{userList.length === 0 ? (
|
|
<p className="text-sm" style={{ color: "var(--text-muted)" }}>
|
|
No users assigned to this device.
|
|
</p>
|
|
) : (
|
|
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
|
{userList.map((uid) => {
|
|
const resolved = resolvedUsers.find((u) => u.uid === uid);
|
|
const displayName = resolved?.display_name || "";
|
|
const email = resolved?.email || "";
|
|
const initials = (displayName || email || uid)[0]?.toUpperCase() || "U";
|
|
return (
|
|
<div key={uid}
|
|
className="flex items-center gap-3 px-3 py-2 rounded-md border"
|
|
style={{ backgroundColor: "var(--bg-primary)", borderColor: "var(--border-secondary)" }}>
|
|
<div className="w-7 h-7 rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0"
|
|
style={{ backgroundColor: "#431407", color: "#fb923c" }}>
|
|
{initials}
|
|
</div>
|
|
<div className="min-w-0">
|
|
{displayName
|
|
? <p className="text-sm font-medium" style={{ color: "var(--text-primary)" }}>{displayName}</p>
|
|
: <p className="text-sm font-mono" style={{ color: "var(--text-muted)" }}>{uid}</p>
|
|
}
|
|
{email && <p className="text-xs" style={{ color: "var(--text-muted)" }}>{email}</p>}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
</div>
|
|
|
|
{/* ── RIGHT COLUMN: Lifecycle ───────────────────────────────────────── */}
|
|
<div style={{ display: "flex", flexDirection: "column", gap: 20 }}>
|
|
<div className="ui-section-card">
|
|
<div className="ui-section-card__title-row">
|
|
<h2 className="ui-section-card__title">Product Lifecycle</h2>
|
|
{(statusSaving || userSaving) && (
|
|
<span className="text-xs" style={{ color: "var(--text-muted)" }}>Saving…</span>
|
|
)}
|
|
</div>
|
|
<LifecycleLadder
|
|
device={device}
|
|
canEdit={canEdit}
|
|
onStatusChange={handleStatusChange}
|
|
onEditEntry={handleEditEntryByStep}
|
|
statusError={statusError}
|
|
/>
|
|
</div>
|
|
|
|
{/* NVS Binary */}
|
|
<div className="ui-section-card">
|
|
<div className="ui-section-card__title-row">
|
|
<h2 className="ui-section-card__title">NVS Binary</h2>
|
|
</div>
|
|
<button
|
|
onClick={downloadNvs}
|
|
disabled={nvsDownloading}
|
|
className="px-4 py-2 text-sm rounded-md hover:opacity-90 transition-opacity cursor-pointer disabled:opacity-50 flex items-center gap-2"
|
|
style={{ backgroundColor: "var(--badge-blue-bg)", color: "var(--badge-blue-text)" }}
|
|
>
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
|
</svg>
|
|
{nvsDownloading ? "Generating…" : "Download NVS Binary"}
|
|
</button>
|
|
<p className="text-xs mt-2" style={{ color: "var(--text-muted)" }}>
|
|
Encodes serial_number, hw_family, hw_version. Flash at 0x9000.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
{/* ── Modals ──────────────────────────────────────────────────────────── */}
|
|
{showCustomerModal && (
|
|
<CustomerSearchModal onSelect={handleSelectCustomer} onCancel={() => setShowCustomerModal(false)} />
|
|
)}
|
|
{showUserModal && (
|
|
<UserSearchModal
|
|
onSelect={handleClaimedUserSelect}
|
|
onCancel={() => setShowUserModal(false)}
|
|
existingUsers={resolvedUsers}
|
|
/>
|
|
)}
|
|
{showDeleteModal && (
|
|
<DeleteModal device={device} onConfirm={handleDelete} onCancel={() => setShowDeleteModal(false)} deleting={deleting} />
|
|
)}
|
|
{editModalData && editStepMeta && (
|
|
<LifecycleEditModal
|
|
entry={editModalData.entry}
|
|
stepMeta={editStepMeta}
|
|
isCurrent={editIsCurrent}
|
|
onSave={(date, note) => handleSaveLifecycle(editModalData.stepIndex, date, note)}
|
|
onDelete={() => handleDeleteLifecycleStep(editModalData.stepIndex)}
|
|
onCancel={() => setEditModalData(null)}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|