feat: Phase 6, Device provisioning and deployment of updates on git-pull
This commit is contained in:
266
frontend/src/dashboard/DashboardPage.jsx
Normal file
266
frontend/src/dashboard/DashboardPage.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user