feat: Phase 6, Device provisioning and deployment of updates on git-pull

This commit is contained in:
2026-02-27 04:42:41 +02:00
parent 32a2634739
commit 57259c2c2f
19 changed files with 1670 additions and 26 deletions

View File

@@ -0,0 +1,266 @@
import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { useAuth } from "../auth/AuthContext";
import api from "../api/client";
const STATUS_STYLES = {
manufactured: { bg: "var(--bg-card-hover)", color: "var(--text-muted)" },
flashed: { bg: "var(--badge-blue-bg)", color: "var(--badge-blue-text)" },
provisioned: { bg: "#0a2e2a", color: "#4dd6c8" },
sold: { bg: "#1e1036", color: "#c084fc" },
claimed: { bg: "#2e1a00", color: "#fb923c" },
decommissioned: { bg: "var(--danger-bg)", color: "var(--danger-text)" },
};
const STATUS_ORDER = ["manufactured", "flashed", "provisioned", "sold", "claimed", "decommissioned"];
const ACTION_LABELS = {
batch_created: "Batch created",
device_flashed: "NVS downloaded",
device_assigned: "Device assigned",
status_updated: "Status updated",
};
function StatusBadge({ status }) {
const style = STATUS_STYLES[status] || STATUS_STYLES.manufactured;
return (
<span
className="px-2 py-0.5 text-xs rounded-full capitalize font-medium"
style={{ backgroundColor: style.bg, color: style.color }}
>
{status}
</span>
);
}
function StatCard({ label, count, status, onClick }) {
const style = STATUS_STYLES[status] || STATUS_STYLES.manufactured;
return (
<button
onClick={onClick}
className="rounded-lg border p-4 text-left transition-colors cursor-pointer w-full"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
onMouseEnter={(e) => (e.currentTarget.style.backgroundColor = "var(--bg-card-hover)")}
onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = "var(--bg-card)")}
>
<div
className="text-3xl font-bold mb-1"
style={{ color: style.color }}
>
{count}
</div>
<div className="text-xs capitalize font-medium" style={{ color: "var(--text-muted)" }}>
{label}
</div>
</button>
);
}
export default function DashboardPage() {
const { user, hasPermission } = useAuth();
const navigate = useNavigate();
const canViewMfg = hasPermission("manufacturing", "view");
const [stats, setStats] = useState(null);
const [auditLog, setAuditLog] = useState([]);
const [loadingStats, setLoadingStats] = useState(false);
const [loadingAudit, setLoadingAudit] = useState(false);
useEffect(() => {
if (!canViewMfg) return;
setLoadingStats(true);
api.get("/manufacturing/stats")
.then(setStats)
.catch(() => {})
.finally(() => setLoadingStats(false));
setLoadingAudit(true);
api.get("/manufacturing/audit-log?limit=20")
.then((data) => setAuditLog(data.entries || []))
.catch(() => {})
.finally(() => setLoadingAudit(false));
}, [canViewMfg]);
const formatTs = (ts) => {
if (!ts) return "—";
try {
return new Date(ts).toLocaleString("en-US", {
month: "short", day: "numeric",
hour: "2-digit", minute: "2-digit",
});
} catch {
return ts;
}
};
return (
<div>
<h1 className="text-2xl font-bold mb-1" style={{ color: "var(--text-heading)" }}>
Dashboard
</h1>
<p className="text-sm mb-6" style={{ color: "var(--text-secondary)" }}>
Welcome, {user?.name}.{" "}
<span className="font-medium" style={{ color: "var(--accent)" }}>{user?.role}</span>
</p>
{canViewMfg && (
<>
{/* Device Status Summary */}
<div className="mb-2 flex items-center justify-between">
<h2 className="text-sm font-semibold uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>
Device Inventory
</h2>
<button
onClick={() => navigate("/manufacturing")}
className="text-xs underline"
style={{ color: "var(--text-link)" }}
>
View all
</button>
</div>
{loadingStats ? (
<div className="text-sm mb-6" style={{ color: "var(--text-muted)" }}>Loading</div>
) : stats ? (
<div className="grid grid-cols-3 sm:grid-cols-6 gap-3 mb-8">
{STATUS_ORDER.map((s) => (
<StatCard
key={s}
label={s}
count={stats.counts[s] ?? 0}
status={s}
onClick={() => navigate(`/manufacturing?status=${s}`)}
/>
))}
</div>
) : null}
{/* Recent Activity */}
{stats?.recent_activity?.length > 0 && (
<div className="mb-8">
<h2 className="text-sm font-semibold uppercase tracking-wide mb-2" style={{ color: "var(--text-muted)" }}>
Recent Activity
</h2>
<div className="rounded-lg border overflow-hidden" style={{ borderColor: "var(--border-primary)" }}>
<table className="w-full text-sm">
<thead>
<tr style={{ backgroundColor: "var(--bg-secondary)", borderBottom: "1px solid var(--border-primary)" }}>
<th className="px-4 py-2 text-left font-medium text-xs" style={{ color: "var(--text-muted)" }}>Serial Number</th>
<th className="px-4 py-2 text-left font-medium text-xs" style={{ color: "var(--text-muted)" }}>Status</th>
<th className="px-4 py-2 text-left font-medium text-xs" style={{ color: "var(--text-muted)" }}>Owner</th>
<th className="px-4 py-2 text-left font-medium text-xs" style={{ color: "var(--text-muted)" }}>Date</th>
</tr>
</thead>
<tbody>
{stats.recent_activity.map((item, i) => (
<tr
key={i}
className="cursor-pointer"
style={{ borderBottom: "1px solid var(--border-secondary)" }}
onClick={() => navigate(`/manufacturing/devices/${item.serial_number}`)}
onMouseEnter={(e) => (e.currentTarget.style.backgroundColor = "var(--bg-card-hover)")}
onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = "")}
>
<td className="px-4 py-2 font-mono text-xs" style={{ color: "var(--text-primary)" }}>
{item.serial_number}
</td>
<td className="px-4 py-2">
<StatusBadge status={item.mfg_status} />
</td>
<td className="px-4 py-2 text-xs" style={{ color: "var(--text-muted)" }}>
{item.owner || "—"}
</td>
<td className="px-4 py-2 text-xs" style={{ color: "var(--text-muted)" }}>
{formatTs(item.updated_at)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Audit Log */}
<div>
<h2 className="text-sm font-semibold uppercase tracking-wide mb-2" style={{ color: "var(--text-muted)" }}>
Audit Log
</h2>
{loadingAudit ? (
<div className="text-sm" style={{ color: "var(--text-muted)" }}>Loading</div>
) : auditLog.length === 0 ? (
<div className="text-sm" style={{ color: "var(--text-muted)" }}>No audit entries yet.</div>
) : (
<div className="rounded-lg border overflow-hidden" style={{ borderColor: "var(--border-primary)" }}>
<table className="w-full text-sm">
<thead>
<tr style={{ backgroundColor: "var(--bg-secondary)", borderBottom: "1px solid var(--border-primary)" }}>
<th className="px-4 py-2 text-left font-medium text-xs" style={{ color: "var(--text-muted)" }}>Time</th>
<th className="px-4 py-2 text-left font-medium text-xs" style={{ color: "var(--text-muted)" }}>Admin</th>
<th className="px-4 py-2 text-left font-medium text-xs" style={{ color: "var(--text-muted)" }}>Action</th>
<th className="px-4 py-2 text-left font-medium text-xs" style={{ color: "var(--text-muted)" }}>Device</th>
<th className="px-4 py-2 text-left font-medium text-xs" style={{ color: "var(--text-muted)" }}>Detail</th>
</tr>
</thead>
<tbody>
{auditLog.map((entry) => (
<tr
key={entry.id}
style={{ borderBottom: "1px solid var(--border-secondary)" }}
>
<td className="px-4 py-2 text-xs whitespace-nowrap" style={{ color: "var(--text-muted)" }}>
{formatTs(entry.timestamp)}
</td>
<td className="px-4 py-2 text-xs" style={{ color: "var(--text-secondary)" }}>
{entry.admin_user}
</td>
<td className="px-4 py-2 text-xs font-medium" style={{ color: "var(--text-primary)" }}>
{ACTION_LABELS[entry.action] || entry.action}
</td>
<td className="px-4 py-2 font-mono text-xs" style={{ color: "var(--text-muted)" }}>
{entry.serial_number
? (
<button
className="underline"
style={{ color: "var(--text-link)" }}
onClick={() => navigate(`/manufacturing/devices/${entry.serial_number}`)}
>
{entry.serial_number}
</button>
)
: "—"}
</td>
<td className="px-4 py-2 text-xs" style={{ color: "var(--text-muted)" }}>
{entry.detail
? (() => {
try {
const d = JSON.parse(entry.detail);
return Object.entries(d)
.filter(([, v]) => v !== null && v !== undefined)
.map(([k, v]) => `${k}: ${v}`)
.join(", ");
} catch {
return entry.detail;
}
})()
: "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</>
)}
{!canViewMfg && (
<p className="text-sm" style={{ color: "var(--text-muted)" }}>
Select a section from the sidebar to get started.
</p>
)}
</div>
);
}