"use client"; import React, { useCallback, useEffect, useMemo, useState } from "react"; import Link from "next/link"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { Bar, CartesianGrid, ComposedChart, Line, ReferenceLine, ResponsiveContainer, Tooltip, XAxis, YAxis, } from "recharts"; /** * API SHAPES (from your route.ts) */ type ApiParetoRow = { reasonCode: string; reasonLabel: string; minutesLost?: number; scrapQty?: number; pctOfTotal: number; // percent 0..100 cumulativePct: number; // percent 0..100 count: number; }; type ApiParetoRes = { ok: boolean; error?: string; orgId?: string; machineId?: string | null; kind?: "downtime" | "scrap"; range?: "24h" | "7d" | "30d"; start?: string; totalMinutesLost?: number; totalScrap?: number; rows?: ApiParetoRow[]; top3?: ApiParetoRow[]; threshold80?: { index: number; reasonCode: string; reasonLabel: string } | null; total?: number; }; type ApiDowntimeEvent = { id: string; episodeId: string | null; machineId: string; machineName: string | null; reasonCode: string; reasonLabel: string; reasonText: string | null; durationSeconds: number | null; durationMinutes: number | null; startAt: string | null; endAt: string | null; capturedAt: string | null; workOrderId: string | null; meta: any | null; createdAt: string | null; }; type ApiDowntimeEventsRes = { ok: boolean; error?: string; orgId?: string; range?: "24h" | "7d" | "30d"; start?: string; machineId?: string | null; reasonCode?: string | null; limit?: number; before?: string | null; nextBefore?: string | null; events?: ApiDowntimeEvent[]; }; function fmtDT(iso: string | null) { if (!iso) return "—"; const d = new Date(iso); return d.toLocaleString("en-US", { hour12: true }); } type ApiCoverageRes = { ok: boolean; error?: string; orgId?: string; machineId?: string | null; range?: "24h" | "7d" | "30d"; start?: string; receivedEpisodes?: number; receivedMinutes?: number; note?: string; }; type Range = "24h" | "7d" | "30d"; type Metric = "minutes" | "count"; type MetricRow = { reasonCode: string; reasonLabel: string; value: number; // minutes OR count count: number; // always count (stops) pctOfTotal: number; // percent 0..100 in selected metric cumulativePct: number; // percent 0..100 in selected metric minutesLost?: number; // if available }; function fmtNum(n: number, digits = 0) { return new Intl.NumberFormat("en-US", { maximumFractionDigits: digits, minimumFractionDigits: digits, }).format(n); } function fmtPct(pct: number, digits = 0) { return `${fmtNum(pct, digits)}%`; } function fmtHoursFromMinutes(min: number) { const hrs = min / 60; return hrs >= 10 ? `${fmtNum(hrs, 0)} hrs` : `${fmtNum(hrs, 1)} hrs`; } function cn(...xs: Array) { return xs.filter(Boolean).join(" "); } function buildSearch(params: URLSearchParams, patch: Record) { const next = new URLSearchParams(params.toString()); Object.entries(patch).forEach(([k, v]) => { if (v === null) next.delete(k); else next.set(k, v); }); return next.toString(); } /** * Derive a Pareto set for Minutes or Count from the same API response. * - Your API always returns rows sorted by VALUE (minutes for downtime, scrapQty for scrap). * - For Metric=COUNT, we re-sort by count and recompute pct/cum on client. */ function computeMetricRows(base: ApiParetoRow[], metric: Metric): MetricRow[] { const safe = base ?? []; if (metric === "minutes") { const rows: MetricRow[] = safe.map((r) => ({ reasonCode: r.reasonCode, reasonLabel: r.reasonLabel, value: r.minutesLost ?? 0, count: r.count ?? 0, pctOfTotal: r.pctOfTotal ?? 0, cumulativePct: r.cumulativePct ?? 0, minutesLost: r.minutesLost ?? 0, })); return rows; } // metric === "count" const sorted = [...safe].sort((a, b) => (b.count ?? 0) - (a.count ?? 0)); const total = sorted.reduce((acc, r) => acc + (r.count ?? 0), 0); let cum = 0; const out: MetricRow[] = sorted.map((r) => { const v = r.count ?? 0; const pct = total > 0 ? (v / total) * 100 : 0; cum += v; const cumPct = total > 0 ? (cum / total) * 100 : 0; return { reasonCode: r.reasonCode, reasonLabel: r.reasonLabel, value: v, count: v, pctOfTotal: pct, cumulativePct: cumPct, minutesLost: r.minutesLost ?? 0, }; }); return out; } function findUnclassifiedPct(rows: MetricRow[]) { const hit = rows.find((r) => { const code = (r.reasonCode ?? "").toLowerCase(); const label = (r.reasonLabel ?? "").toLowerCase(); return code.includes("unclass") || code.includes("unknown") || label.includes("unclass") || label.includes("unknown"); }); return hit ? hit.pctOfTotal : 0; } /** * Right-side drawer (investigation) * Built in the same style as MachineDetailClient’s Modal overlay. */ function ReasonDrawer({ open, onClose, row, metric, }: { open: boolean; onClose: () => void; row: MetricRow | null; metric: Metric; }) { if (!open || !row) return null; const avgMin = row.count > 0 && row.minutesLost != null ? row.minutesLost / row.count : null; return (
Reason detail
{row.reasonLabel}
{metric === "minutes" ? "Downtime" : "Stops"}
{metric === "minutes" ? `${fmtNum(row.value, 1)} min` : fmtNum(row.value, 0)}
{fmtPct(row.pctOfTotal, 1)} share
Stops
{fmtNum(row.count, 0)}
{avgMin == null ? "Avg duration —" : `Avg ${fmtNum(avgMin, 1)} min`}
Investigation (next)
Hook the following panels once you add endpoints for events + breakdowns:
  • Last 10 events (timestamp, duration, operator note)
  • Breakdown by machine / shift / work order
  • Duration histogram (micro vs macro)
  • Create action (owner, due date, status)
Tip: keep this drawer “fast”. The table + drawer combo is what makes the page feel like a tool.
); } function KPI({ label, value, sub, accent, }: { label: string; value: string; sub?: string; accent?: "emerald" | "yellow" | "rose" | "zinc"; }) { const ring = accent === "emerald" ? "border-emerald-500/20" : accent === "yellow" ? "border-yellow-500/20" : accent === "rose" ? "border-rose-500/20" : "border-white/10"; return (
{label}
{value}
{sub ?
{sub}
: null}
); } const DAY_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; function nextHourBoundary(d: Date) { const x = new Date(d); x.setMinutes(0, 0, 0); x.setHours(x.getHours() + 1); return x; } function getEventInterval(e: ApiDowntimeEvent): { start: Date | null; end: Date | null } { const startIso = e.startAt ?? e.capturedAt; if (!startIso) return { start: null, end: null }; const start = new Date(startIso); if (Number.isNaN(start.getTime())) return { start: null, end: null }; // Prefer endAt if present if (e.endAt) { const end = new Date(e.endAt); if (!Number.isNaN(end.getTime()) && end > start) return { start, end }; } // Fall back to duration fields const durMin = e.durationMinutes ?? (e.durationSeconds != null ? e.durationSeconds / 60 : null); if (durMin != null && durMin > 0) { const end = new Date(start.getTime() + durMin * 60_000); return { start, end }; } return { start, end: null }; } /** * Build heatmap matrix [7 days][24 hours] * - metric="minutes": distributes duration across hour buckets (accurate) * - metric="count": increments the start hour bucket */ function buildHeatmapMatrix(events: ApiDowntimeEvent[], metric: Metric) { const m = Array.from({ length: 7 }, () => Array.from({ length: 24 }, () => 0)); for (const e of events) { const { start, end } = getEventInterval(e); if (!start) continue; if (metric === "count") { m[start.getDay()][start.getHours()] += 1; continue; } if (!end) continue; let t = start; while (t < end) { const day = t.getDay(); const hour = t.getHours(); const boundary = nextHourBoundary(t); const segEnd = boundary < end ? boundary : end; const segMin = (segEnd.getTime() - t.getTime()) / 60_000; m[day][hour] += segMin; t = segEnd; } } let max = 0; for (let d = 0; d < 7; d++) { for (let h = 0; h < 24; h++) max = Math.max(max, m[d][h]); } return { matrix: m, max }; } function eventTouchesSlot(e: ApiDowntimeEvent, slotDay: number, slotHour: number) { const { start, end } = getEventInterval(e); if (!start) return false; // Count metric: consider start bucket if (!end) return start.getDay() === slotDay && start.getHours() === slotHour; // Minutes metric: any overlap with that (day, hour) bucket let t = start; while (t < end) { if (t.getDay() === slotDay && t.getHours() === slotHour) return true; const boundary = nextHourBoundary(t); t = boundary < end ? boundary : end; } return false; } function heatColor(v: number, metric: Metric) { // "Good" = green even when v=0 if (v <= 0) return { bg: "rgba(34,197,94,0.18)", label: "Good" }; if (metric === "minutes") { // per-hour downtime minutes severity if (v < 2) return { bg: "rgba(34,197,94,0.45)", label: "Low" }; if (v < 6) return { bg: "rgba(234,179,8,0.55)", label: "Watch" }; // yellow if (v < 15) return { bg: "rgba(249,115,22,0.65)", label: "High" }; // orange return { bg: "rgba(239,68,68,0.75)", label: "Critical" }; // red } // metric === "count" if (v <= 1) return { bg: "rgba(34,197,94,0.45)", label: "Low" }; if (v <= 3) return { bg: "rgba(234,179,8,0.55)", label: "Watch" }; if (v <= 6) return { bg: "rgba(249,115,22,0.65)", label: "High" }; return { bg: "rgba(239,68,68,0.75)", label: "Critical" }; } function Heatmap({ events, metric, selected, onSelect, onClear, }: { events: ApiDowntimeEvent[]; metric: Metric; selected: { day: number; hour: number } | null; onSelect: (day: number, hour: number) => void; onClear: () => void; }) { const { matrix, max } = useMemo(() => buildHeatmapMatrix(events, metric), [events, metric]); const hourLabels = Array.from({ length: 24 }, (_, h) => h % 2 === 0 ? String(h).padStart(2, "0") : "" ); const hasData = max > 0; return (
Click a cell to filter Event list by day/hour
{selected ? ( ) : null}
{/* Header row */}
{hourLabels.map((t, h) => (
{t}
))}
{/* Rows */} {matrix.map((row, dayIdx) => (
{DAY_LABELS[dayIdx]}
{row.map((v, hour) => { const c = heatColor(v, metric); const isSelected = selected?.day === dayIdx && selected?.hour === hour; const title = `${DAY_LABELS[dayIdx]} ${String(hour).padStart(2, "0")}:00–${String( (hour + 1) % 24 ).padStart(2, "0")}:00\n${ metric === "minutes" ? `${fmtNum(v, 1)} min` : `${fmtNum(v, 0)} stops` }\n${c.label}`; return (
))} {/* Legend */}
Good
Watch
High
Critical
{events.length === 0 ? "No events loaded for this scope" : hasData ? `Max cell: ${metric === "minutes" ? `${fmtNum(max, 1)} min` : `${fmtNum(max, 0)} stops`}` : "Events loaded, but no usable durations/endAt yet"}
); } type ActionStatus = "open" | "in_progress" | "blocked" | "done"; type ActionPriority = "low" | "medium" | "high"; type HeatmapSel = { day: number; hour: number }; type ActionItem = { id: string; createdAt: string; updatedAt: string; machineId: string | null; reasonCode: string | null; hmDay: number | null; hmHour: number | null; title: string; notes: string; ownerUserId: string | null; ownerName: string | null; ownerEmail: string | null; dueDate: string | null; // YYYY-MM-DD status: ActionStatus; priority: ActionPriority; }; type MemberOption = { id: string; name?: string | null; email: string; role: string; isActive: boolean; }; function statusPill(status: ActionStatus) { switch (status) { case "done": return "border-emerald-500/25 bg-emerald-500/10 text-emerald-200"; case "blocked": return "border-rose-500/25 bg-rose-500/10 text-rose-200"; case "in_progress": return "border-sky-500/25 bg-sky-500/10 text-sky-200"; default: return "border-amber-500/25 bg-amber-500/10 text-amber-200"; } } function priorityPill(p: ActionPriority) { switch (p) { case "high": return "border-rose-500/25 bg-rose-500/10 text-rose-200"; case "medium": return "border-yellow-500/25 bg-yellow-500/10 text-yellow-200"; default: return "border-white/10 bg-white/5 text-zinc-200"; } } function isValidNum(x: any) { const n = Number(x); return Number.isFinite(n); } function ActionModal({ open, onClose, initial, onSave, onDelete, members, isNew, }: { open: boolean; onClose: () => void; initial: ActionItem; onSave: (a: ActionItem, isNew: boolean) => Promise<{ ok: boolean; error?: string }>; onDelete?: (id: string) => Promise<{ ok: boolean; error?: string }>; members: MemberOption[]; isNew: boolean; }) { const [draft, setDraft] = React.useState(initial); const [saving, setSaving] = React.useState(false); const [saveError, setSaveError] = React.useState(null); const availableMembers = React.useMemo(() => members, [members]); React.useEffect(() => { setDraft(initial); setSaveError(null); }, [initial]); if (!open) return null; return (
Action
Assign ownership + due date. Keep it short and clear.
Title
setDraft((d) => ({ ...d, title: e.target.value }))} placeholder="e.g. Add checklist for material feed before start-up" className="mt-1 h-10 w-full rounded-xl border border-white/10 bg-black/20 px-3 text-sm text-white outline-none placeholder:text-zinc-500" />
Owner
Due date
setDraft((d) => ({ ...d, dueDate: e.target.value || null }))} className="mt-1 h-10 w-full rounded-xl border border-white/10 bg-black/20 px-3 text-sm text-white outline-none" />
Status
Priority
Notes