Final MVP valid
This commit is contained in:
329
components/analytics/DowntimeParetoCard.tsx
Normal file
329
components/analytics/DowntimeParetoCard.tsx
Normal file
@@ -0,0 +1,329 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { DOWNTIME_RANGES, type DowntimeRange } from "@/lib/analytics/downtimeRange";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
Bar,
|
||||
CartesianGrid,
|
||||
ComposedChart,
|
||||
Line,
|
||||
ReferenceLine,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import { useI18n } from "@/lib/i18n/useI18n";
|
||||
|
||||
|
||||
type ParetoRow = {
|
||||
reasonCode: string;
|
||||
reasonLabel: string;
|
||||
minutesLost?: number; // downtime
|
||||
scrapQty?: number; // scrap (future)
|
||||
pctOfTotal: number; // 0..100
|
||||
cumulativePct: number; // 0..100
|
||||
};
|
||||
|
||||
type ParetoResponse = {
|
||||
ok?: boolean;
|
||||
rows?: ParetoRow[];
|
||||
top3?: ParetoRow[];
|
||||
totalMinutesLost?: number;
|
||||
threshold80?: { reasonCode: string; reasonLabel: string; index: number } | null;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
type CoverageResponse = {
|
||||
ok?: boolean;
|
||||
totalDowntimeMinutes?: number;
|
||||
receivedMinutes?: number;
|
||||
receivedCoveragePct?: number; // could be 0..1 or 0..100 depending on your impl
|
||||
pendingEpisodesCount?: number;
|
||||
};
|
||||
|
||||
function clampLabel(s: string, max = 18) {
|
||||
if (!s) return "";
|
||||
return s.length > max ? `${s.slice(0, max - 1)}…` : s;
|
||||
}
|
||||
|
||||
function normalizePct(v?: number | null) {
|
||||
if (v == null || Number.isNaN(v)) return null;
|
||||
// If API returns 0..1, convert to 0..100
|
||||
return v <= 1 ? v * 100 : v;
|
||||
}
|
||||
|
||||
export default function DowntimeParetoCard({
|
||||
machineId,
|
||||
range = "7d",
|
||||
showCoverage = true,
|
||||
showOpenFullReport = true,
|
||||
variant = "summary",
|
||||
maxBars,
|
||||
}: {
|
||||
machineId?: string;
|
||||
range?: DowntimeRange;
|
||||
showCoverage?: boolean;
|
||||
showOpenFullReport?: boolean;
|
||||
variant?: "summary" | "full";
|
||||
maxBars?: number; // optional override
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const isSummary = variant === "summary";
|
||||
const barsLimit = maxBars ?? (isSummary ? 5 : 12);
|
||||
const chartHeightClass = isSummary ? "h-[240px]" : "h-[360px]";
|
||||
const containerPad = isSummary ? "p-4" : "p-5";
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [pareto, setPareto] = useState<ParetoResponse | null>(null);
|
||||
const [coverage, setCoverage] = useState<CoverageResponse | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
setErr(null);
|
||||
|
||||
try {
|
||||
const qs = new URLSearchParams();
|
||||
qs.set("kind", "downtime");
|
||||
qs.set("range", range);
|
||||
if (machineId) qs.set("machineId", machineId);
|
||||
|
||||
const res = await fetch(`/api/analytics/pareto?${qs.toString()}`, {
|
||||
cache: "no-cache",
|
||||
credentials: "include",
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
const json = (await res.json().catch(() => ({}))) as ParetoResponse;
|
||||
|
||||
if (!res.ok || json?.ok === false) {
|
||||
setPareto(null);
|
||||
setErr(json?.error ?? "Failed to load pareto.");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setPareto(json);
|
||||
|
||||
// Optional coverage (fail silently if endpoint not ready)
|
||||
if (showCoverage) {
|
||||
const cqs = new URLSearchParams();
|
||||
cqs.set("kind", "downtime");
|
||||
cqs.set("range", range);
|
||||
if (machineId) cqs.set("machineId", machineId);
|
||||
|
||||
fetch(`/api/analytics/coverage?${cqs.toString()}`, {
|
||||
cache: "no-cache",
|
||||
credentials: "include",
|
||||
signal: controller.signal,
|
||||
})
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((cj) => (cj ? (cj as CoverageResponse) : null))
|
||||
.then((cj) => {
|
||||
if (cj) setCoverage(cj);
|
||||
})
|
||||
.catch(() => {
|
||||
// ignore
|
||||
});
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
} catch (e: any) {
|
||||
if (e?.name === "AbortError") return;
|
||||
setErr("Network error.");
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
load();
|
||||
return () => controller.abort();
|
||||
}, [machineId, range, showCoverage]);
|
||||
|
||||
const rows = pareto?.rows ?? [];
|
||||
|
||||
const chartData = useMemo(() => {
|
||||
return rows.slice(0, barsLimit).map((r, idx) => ({
|
||||
i: idx,
|
||||
reasonCode: r.reasonCode,
|
||||
reasonLabel: r.reasonLabel,
|
||||
label: clampLabel(r.reasonLabel || r.reasonCode, isSummary ? 16 : 22),
|
||||
minutes: Number(r.minutesLost ?? 0),
|
||||
pctOfTotal: Number(r.pctOfTotal ?? 0),
|
||||
cumulativePct: Number(r.cumulativePct ?? 0),
|
||||
}));
|
||||
}, [rows, barsLimit, isSummary]);
|
||||
|
||||
|
||||
const top3 = useMemo(() => {
|
||||
if (pareto?.top3?.length) return pareto.top3.slice(0, 3);
|
||||
return [...rows]
|
||||
.sort((a, b) => Number(b.minutesLost ?? 0) - Number(a.minutesLost ?? 0))
|
||||
.slice(0, 3);
|
||||
}, [pareto?.top3, rows]);
|
||||
|
||||
const totalMinutes = Number(pareto?.totalMinutesLost ?? 0);
|
||||
|
||||
const covPct = normalizePct(coverage?.receivedCoveragePct ?? null);
|
||||
const pending = coverage?.pendingEpisodesCount ?? null;
|
||||
|
||||
const title =
|
||||
range === "24h"
|
||||
? "Downtime Pareto (24h)"
|
||||
: range === "30d"
|
||||
? "Downtime Pareto (30d)"
|
||||
: range === "mtd"
|
||||
? "Downtime Pareto (MTD)"
|
||||
: "Downtime Pareto (7d)";
|
||||
|
||||
|
||||
const reportHref = machineId
|
||||
? `/downtime?machineId=${encodeURIComponent(machineId)}&range=${encodeURIComponent(range)}`
|
||||
: `/downtime?range=${encodeURIComponent(range)}`;
|
||||
|
||||
return (
|
||||
<div className={`rounded-2xl border border-white/10 bg-white/5 ${containerPad}`}>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-white">{title}</div>
|
||||
<div className="mt-1 text-xs text-zinc-400">
|
||||
Total: <span className="text-white">{totalMinutes.toFixed(0)} min</span>
|
||||
{covPct != null ? (
|
||||
<>
|
||||
<span className="mx-2 text-zinc-600">•</span>
|
||||
Coverage: <span className="text-white">{covPct.toFixed(0)}%</span>
|
||||
{pending != null ? (
|
||||
<>
|
||||
<span className="mx-2 text-zinc-600">•</span>
|
||||
Pending: <span className="text-white">{pending}</span>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{showOpenFullReport ? (
|
||||
<Link
|
||||
href={reportHref}
|
||||
className="rounded-xl border border-white/10 bg-white/5 px-3 py-1.5 text-xs text-white hover:bg-white/10"
|
||||
>
|
||||
View full report →
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="mt-4 text-sm text-zinc-400">{t("machine.detail.loading")}</div>
|
||||
) : err ? (
|
||||
<div className="mt-4 rounded-2xl border border-red-500/20 bg-red-500/10 p-4 text-sm text-red-200">
|
||||
{err}
|
||||
</div>
|
||||
) : rows.length === 0 ? (
|
||||
<div className="mt-4 text-sm text-zinc-400">No downtime reasons found for this range.</div>
|
||||
) : (
|
||||
<div className="mt-4 grid grid-cols-1 gap-4 lg:grid-cols-3">
|
||||
<div
|
||||
className={`${chartHeightClass} rounded-3xl border border-white/10 bg-black/30 p-4 backdrop-blur lg:col-span-2`}
|
||||
style={{ boxShadow: "var(--app-chart-shadow)" }}
|
||||
>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ComposedChart data={chartData} margin={{ top: 10, right: 24, left: 0, bottom: 10 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--app-chart-grid)" />
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
interval={0}
|
||||
tick={{ fill: "var(--app-chart-tick)", fontSize: 11 }}
|
||||
/>
|
||||
<YAxis
|
||||
yAxisId="left"
|
||||
tick={{ fill: "var(--app-chart-tick)" }}
|
||||
width={40}
|
||||
/>
|
||||
<YAxis
|
||||
yAxisId="right"
|
||||
orientation="right"
|
||||
domain={[0, 100]}
|
||||
tick={{ fill: "var(--app-chart-tick)" }}
|
||||
tickFormatter={(v) => `${v}%`}
|
||||
width={44}
|
||||
/>
|
||||
<Tooltip
|
||||
cursor={{ stroke: "var(--app-chart-grid)" }}
|
||||
contentStyle={{
|
||||
background: "var(--app-chart-tooltip-bg)",
|
||||
border: "1px solid var(--app-chart-tooltip-border)",
|
||||
}}
|
||||
labelStyle={{ color: "var(--app-chart-label)" }}
|
||||
formatter={(val: any, name: any, ctx: any) => {
|
||||
if (name === "minutes") return [`${Number(val).toFixed(1)} min`, "Minutes"];
|
||||
if (name === "cumulativePct") return [`${Number(val).toFixed(1)}%`, "Cumulative"];
|
||||
return [val, name];
|
||||
}}
|
||||
/>
|
||||
|
||||
<ReferenceLine
|
||||
yAxisId="right"
|
||||
y={80}
|
||||
stroke="rgba(255,255,255,0.25)"
|
||||
strokeDasharray="6 6"
|
||||
/>
|
||||
|
||||
<Bar
|
||||
yAxisId="left"
|
||||
dataKey="minutes"
|
||||
radius={[10, 10, 0, 0]}
|
||||
isAnimationActive={false}
|
||||
fill="#FF7A00"
|
||||
/>
|
||||
<Line
|
||||
yAxisId="right"
|
||||
dataKey="cumulativePct"
|
||||
dot={false}
|
||||
strokeWidth={2}
|
||||
isAnimationActive={false}
|
||||
stroke="#12D18E"
|
||||
/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
<div className={`rounded-2xl border border-white/10 bg-black/20 ${isSummary ? "p-3" : "p-4"}`}>
|
||||
<div className="text-xs font-semibold text-white">Top 3 reasons</div>
|
||||
<div className="mt-3 space-y-3">
|
||||
{top3.map((r) => (
|
||||
<div key={r.reasonCode} className="rounded-xl border border-white/10 bg-white/5 p-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-semibold text-white">
|
||||
{r.reasonLabel || r.reasonCode}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-zinc-400">{r.reasonCode}</div>
|
||||
</div>
|
||||
<div className="shrink-0 text-right">
|
||||
<div className="text-sm font-semibold text-white">
|
||||
{(r.minutesLost ?? 0).toFixed(0)}m
|
||||
</div>
|
||||
<div className="text-xs text-zinc-400">{(r.pctOfTotal ?? 0).toFixed(1)}%</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{!isSummary && pareto?.threshold80 ? (
|
||||
<div className="mt-4 rounded-xl border border-white/10 bg-white/5 p-3 text-xs text-zinc-300">
|
||||
80% cutoff:{" "}
|
||||
<span className="text-white">
|
||||
{pareto.threshold80.reasonLabel} ({pareto.threshold80.reasonCode})
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
2205
components/downtime/DowntimePageClient.tsx
Normal file
2205
components/downtime/DowntimePageClient.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -30,10 +30,10 @@ export function AppShell({
|
||||
}, [drawerOpen]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-black text-white">
|
||||
<div className="flex min-h-screen">
|
||||
<div className="h-screen overflow-hidden bg-black text-white">
|
||||
<div className="flex h-full">
|
||||
<Sidebar />
|
||||
<div className="flex min-h-screen flex-1 flex-col">
|
||||
<div className="flex h-full flex-1 flex-col">
|
||||
<header className="sticky top-0 z-30 flex min-h-[3.5rem] flex-wrap items-center justify-between gap-3 border-b border-white/10 bg-black/20 px-4 py-2 backdrop-blur">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
@@ -50,7 +50,7 @@ export function AppShell({
|
||||
</div>
|
||||
<UtilityControls initialTheme={initialTheme} />
|
||||
</header>
|
||||
<main className="flex-1">{children}</main>
|
||||
<main className="flex-1 overflow-y-auto">{children}</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ import { useEffect, useMemo, useState } from "react";
|
||||
import { BarChart3, Bell, DollarSign, LayoutGrid, LogOut, Settings, Wrench, X } from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { useI18n } from "@/lib/i18n/useI18n";
|
||||
import { useScreenlessMode } from "@/lib/ui/screenlessMode";
|
||||
|
||||
|
||||
type NavItem = {
|
||||
href: string;
|
||||
@@ -21,6 +23,8 @@ const items: NavItem[] = [
|
||||
{ href: "/alerts", labelKey: "nav.alerts", icon: Bell },
|
||||
{ href: "/financial", labelKey: "nav.financial", icon: DollarSign, ownerOnly: true },
|
||||
{ href: "/settings", labelKey: "nav.settings", icon: Settings },
|
||||
{ href: "/downtime", labelKey: "nav.downtime", icon: BarChart3 },
|
||||
|
||||
];
|
||||
|
||||
type SidebarProps = {
|
||||
@@ -33,6 +37,7 @@ export function Sidebar({ variant = "desktop", onNavigate, onClose }: SidebarPro
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
const { screenlessMode } = useScreenlessMode();
|
||||
const [me, setMe] = useState<{
|
||||
user?: { name?: string | null; email?: string | null };
|
||||
org?: { name?: string | null };
|
||||
@@ -67,7 +72,28 @@ export function Sidebar({ variant = "desktop", onNavigate, onClose }: SidebarPro
|
||||
|
||||
const roleKey = (me?.membership?.role || "MEMBER").toLowerCase();
|
||||
const isOwner = roleKey === "owner";
|
||||
const visibleItems = useMemo(() => items.filter((it) => !it.ownerOnly || isOwner), [isOwner]);
|
||||
const visibleItems = useMemo(() => {
|
||||
return items.filter((it) => {
|
||||
if (it.ownerOnly && !isOwner) return false;
|
||||
if (screenlessMode && it.href === "/downtime") return false;
|
||||
return true;
|
||||
});
|
||||
}, [isOwner, screenlessMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (screenlessMode && pathname.startsWith("/downtime")) {
|
||||
router.replace("/overview");
|
||||
}
|
||||
}, [screenlessMode, pathname, router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!screenlessMode) return;
|
||||
if (pathname === "/downtime" || pathname.startsWith("/downtime/")) {
|
||||
router.replace("/overview");
|
||||
}
|
||||
}, [screenlessMode, pathname, router]);
|
||||
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
visibleItems.forEach((it) => {
|
||||
@@ -75,7 +101,7 @@ export function Sidebar({ variant = "desktop", onNavigate, onClose }: SidebarPro
|
||||
});
|
||||
}, [router, visibleItems]);
|
||||
const shellClass = [
|
||||
"relative z-20 flex flex-col border-r border-white/10 bg-black/40",
|
||||
"relative z-20 flex flex-col border-r border-white/10 bg-black/40 shrink-0",
|
||||
variant === "desktop" ? "hidden md:flex h-screen w-64" : "flex h-full w-72 max-w-[85vw]",
|
||||
].join(" ");
|
||||
|
||||
|
||||
110
components/reports/DowntimeParetoReportClient.tsx
Normal file
110
components/reports/DowntimeParetoReportClient.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import DowntimeParetoCard from "@/components/analytics/DowntimeParetoCard";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
|
||||
import { DOWNTIME_RANGES, coerceDowntimeRange, type DowntimeRange } from "@/lib/analytics/downtimeRange";
|
||||
|
||||
type MachineLite = {
|
||||
id: string;
|
||||
name: string;
|
||||
siteName?: string | null; // optional for later
|
||||
};
|
||||
|
||||
export default function DowntimeParetoReportClient() {
|
||||
const sp = useSearchParams();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
|
||||
const [range, setRange] = useState<DowntimeRange>(coerceDowntimeRange(sp.get("range")));
|
||||
const [machineId, setMachineId] = useState<string>(sp.get("machineId") || "");
|
||||
const [machines, setMachines] = useState<MachineLite[]>([]);
|
||||
const [loadingMachines, setLoadingMachines] = useState(true);
|
||||
|
||||
// Keep URL in sync (so deep-links work)
|
||||
|
||||
useEffect(() => {
|
||||
const qs = new URLSearchParams();
|
||||
if (range) qs.set("range", range);
|
||||
if (machineId) qs.set("machineId", machineId);
|
||||
|
||||
const next = `${pathname}?${qs.toString()}`;
|
||||
const current = `${pathname}?${sp.toString()}`;
|
||||
|
||||
// avoid needless replace loops
|
||||
if (next !== current) router.replace(next);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [range, machineId, pathname]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function loadMachines() {
|
||||
setLoadingMachines(true);
|
||||
try {
|
||||
// Use whatever endpoint you already have for listing machines:
|
||||
// If you don’t have one, easiest is GET /api/machines returning [{id,name}]
|
||||
const res = await fetch("/api/machines", { credentials: "include" });
|
||||
const json = await res.json();
|
||||
if (!cancelled && res.ok) setMachines(json.machines ?? json ?? []);
|
||||
} finally {
|
||||
if (!cancelled) setLoadingMachines(false);
|
||||
}
|
||||
}
|
||||
|
||||
loadMachines();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const machineOptions = useMemo(() => {
|
||||
return [{ id: "", name: "All machines" }, ...machines];
|
||||
}, [machines]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-lg font-semibold text-white">Downtime Pareto</div>
|
||||
<div className="text-sm text-zinc-400">Org-wide report with drilldown</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<select
|
||||
className="rounded-xl border border-white/10 bg-black/40 px-3 py-2 text-sm text-white"
|
||||
value={range}
|
||||
onChange={(e) => setRange(e.target.value as DowntimeRange)}
|
||||
>
|
||||
<option className="bg-black text-white" value="24h">Last 24h</option>
|
||||
<option className="bg-black text-white" value="7d">Last 7d</option>
|
||||
<option className="bg-black text-white" value="30d">Last 30d</option>
|
||||
</select>
|
||||
|
||||
<select
|
||||
className="min-w-[240px] rounded-xl border border-white/10 bg-white/5 px-3 py-2 text-sm text-white"
|
||||
value={machineId}
|
||||
onChange={(e) => setMachineId(e.target.value)}
|
||||
disabled={loadingMachines}
|
||||
>
|
||||
{machineOptions.map((m) => (
|
||||
<option className="bg-black text-white" key={m.id || "all"} value={m.id}>
|
||||
{m.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DowntimeParetoCard
|
||||
range={range}
|
||||
machineId={machineId || undefined}
|
||||
showOpenFullReport={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user