"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(null); const [pareto, setPareto] = useState(null); const [coverage, setCoverage] = useState(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 (
{title}
Total: {totalMinutes.toFixed(0)} min {covPct != null ? ( <> Coverage: {covPct.toFixed(0)}% {pending != null ? ( <> Pending: {pending} ) : null} ) : null}
{showOpenFullReport ? ( View full report → ) : null}
{loading ? (
{t("machine.detail.loading")}
) : err ? (
{err}
) : rows.length === 0 ? (
No downtime reasons found for this range.
) : (
`${v}%`} width={44} /> { if (name === "minutes") return [`${Number(val).toFixed(1)} min`, "Minutes"]; if (name === "cumulativePct") return [`${Number(val).toFixed(1)}%`, "Cumulative"]; return [val, name]; }} />
Top 3 reasons
{top3.map((r) => (
{r.reasonLabel || r.reasonCode}
{r.reasonCode}
{(r.minutesLost ?? 0).toFixed(0)}m
{(r.pctOfTotal ?? 0).toFixed(1)}%
))}
{!isSummary && pareto?.threshold80 ? (
80% cutoff:{" "} {pareto.threshold80.reasonLabel} ({pareto.threshold80.reasonCode})
) : null}
)}
); }