Files
MIS-Contro-Tower/components/downtime/DowntimePageClient.tsx
2026-01-21 01:45:57 +00:00

2206 lines
78 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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<string | false | null | undefined>) {
return xs.filter(Boolean).join(" ");
}
function buildSearch(params: URLSearchParams, patch: Record<string, string | null>) {
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 MachineDetailClients 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 (
<div className="fixed inset-0 z-50">
<div className="absolute inset-0 bg-black/70" onClick={onClose} />
<div className="absolute right-0 top-0 h-full w-full max-w-[520px] overflow-hidden border-l border-white/10 bg-zinc-950/70 backdrop-blur-xl">
<div
className="pointer-events-none absolute inset-0 opacity-70"
style={{
background:
"radial-gradient(900px 500px at 20% 10%, rgba(16,185,129,.18), transparent 60%)," +
"radial-gradient(900px 500px at 85% 30%, rgba(59,130,246,.12), transparent 60%)," +
"radial-gradient(900px 600px at 50% 100%, rgba(244,63,94,.10), transparent 60%)",
}}
/>
<div className="relative flex h-full flex-col">
<div className="flex items-start justify-between gap-3 border-b border-white/10 p-5">
<div className="min-w-0">
<div className="text-sm font-semibold text-white">Reason detail</div>
<div className="mt-1 truncate text-xs text-zinc-400">{row.reasonLabel}</div>
</div>
<button
onClick={onClose}
className="rounded-xl border border-white/10 bg-white/5 px-3 py-1.5 text-xs text-white hover:bg-white/10"
>
Close
</button>
</div>
<div className="flex-1 overflow-y-auto p-5 no-scrollbar">
<div className="grid grid-cols-2 gap-3">
<div className="rounded-2xl border border-white/10 bg-white/5 p-4">
<div className="text-xs text-zinc-400">
{metric === "minutes" ? "Downtime" : "Stops"}
</div>
<div className="mt-2 text-2xl font-semibold text-white">
{metric === "minutes" ? `${fmtNum(row.value, 1)} min` : fmtNum(row.value, 0)}
</div>
<div className="mt-1 text-xs text-zinc-400">{fmtPct(row.pctOfTotal, 1)} share</div>
</div>
<div className="rounded-2xl border border-white/10 bg-white/5 p-4">
<div className="text-xs text-zinc-400">Stops</div>
<div className="mt-2 text-2xl font-semibold text-white">{fmtNum(row.count, 0)}</div>
<div className="mt-1 text-xs text-zinc-400">
{avgMin == null ? "Avg duration —" : `Avg ${fmtNum(avgMin, 1)} min`}
</div>
</div>
</div>
<div className="mt-4 rounded-2xl border border-white/10 bg-white/5 p-4">
<div className="text-sm font-semibold text-white">Investigation (next)</div>
<div className="mt-1 text-xs text-zinc-400">
Hook the following panels once you add endpoints for events + breakdowns:
</div>
<ul className="mt-3 list-disc space-y-1 pl-5 text-xs text-zinc-300">
<li>Last 10 events (timestamp, duration, operator note)</li>
<li>Breakdown by machine / shift / work order</li>
<li>Duration histogram (micro vs macro)</li>
<li>Create action (owner, due date, status)</li>
</ul>
</div>
<div className="mt-4 rounded-2xl border border-white/10 bg-black/25 p-4 text-xs text-zinc-400">
Tip: keep this drawer fast. The table + drawer combo is what makes the page feel like a tool.
</div>
</div>
</div>
</div>
</div>
);
}
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 (
<div className={cn("rounded-2xl border bg-white/5 p-5", ring)}>
<div className="text-xs text-zinc-400">{label}</div>
<div className="mt-2 text-3xl font-semibold text-white">{value}</div>
{sub ? <div className="mt-2 text-xs text-zinc-400">{sub}</div> : null}
</div>
);
}
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 (
<div className="mt-4 overflow-x-auto rounded-2xl border border-white/10 bg-black/20 p-3">
<div className="min-w-[860px]">
<div className="flex items-center justify-between pb-3">
<div className="text-[11px] text-zinc-500">
Click a cell to filter Event list by day/hour
</div>
{selected ? (
<button
onClick={onClear}
className="rounded-lg border border-white/10 bg-white/5 px-2 py-1 text-[11px] text-zinc-200 hover:bg-white/10"
>
Clear heatmap filter
</button>
) : null}
</div>
{/* Header row */}
<div className="grid" style={{ gridTemplateColumns: "56px repeat(24, 28px)" }}>
<div />
{hourLabels.map((t, h) => (
<div key={h} className="pb-2 text-center text-[10px] text-zinc-500">
{t}
</div>
))}
</div>
{/* Rows */}
{matrix.map((row, dayIdx) => (
<div
key={dayIdx}
className="grid items-center"
style={{ gridTemplateColumns: "56px repeat(24, 28px)" }}
>
<div className="pr-2 text-right text-[11px] text-zinc-500">
{DAY_LABELS[dayIdx]}
</div>
{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 (
<button
key={hour}
title={title}
onClick={() => onSelect(dayIdx, hour)}
className={cn(
"h-[22px] w-[22px] rounded-md border border-white/5 transition",
"hover:brightness-110",
isSelected && "ring-2 ring-emerald-400/60"
)}
style={{
background: c.bg,
boxShadow: isSelected ? "0 0 0 1px rgba(16,185,129,0.35)" : undefined,
}}
/>
);
})}
</div>
))}
{/* Legend */}
<div className="mt-3 flex flex-wrap items-center gap-3 text-[11px] text-zinc-500">
<div className="inline-flex items-center gap-2">
<span className="h-3 w-3 rounded-sm" style={{ background: "rgba(34,197,94,0.18)" }} />
Good
</div>
<div className="inline-flex items-center gap-2">
<span className="h-3 w-3 rounded-sm" style={{ background: "rgba(234,179,8,0.55)" }} />
Watch
</div>
<div className="inline-flex items-center gap-2">
<span className="h-3 w-3 rounded-sm" style={{ background: "rgba(249,115,22,0.65)" }} />
High
</div>
<div className="inline-flex items-center gap-2">
<span className="h-3 w-3 rounded-sm" style={{ background: "rgba(239,68,68,0.75)" }} />
Critical
</div>
<div className="ml-auto">
{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"}
</div>
</div>
</div>
</div>
);
}
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<ActionItem>(initial);
const [saving, setSaving] = React.useState(false);
const [saveError, setSaveError] = React.useState<string | null>(null);
const availableMembers = React.useMemo(() => members, [members]);
React.useEffect(() => {
setDraft(initial);
setSaveError(null);
}, [initial]);
if (!open) return null;
return (
<div className="fixed inset-0 z-50">
<div className="absolute inset-0 bg-black/70" onClick={onClose} />
<div className="absolute left-1/2 top-1/2 w-[92vw] max-w-[560px] -translate-x-1/2 -translate-y-1/2 overflow-hidden rounded-3xl border border-white/10 bg-zinc-950/80 backdrop-blur-xl">
<div className="flex items-start justify-between gap-3 border-b border-white/10 p-5">
<div className="min-w-0">
<div className="text-sm font-semibold text-white">Action</div>
<div className="mt-1 text-xs text-zinc-400">
Assign ownership + due date. Keep it short and clear.
</div>
</div>
<button
onClick={onClose}
className="rounded-xl border border-white/10 bg-white/5 px-3 py-1.5 text-xs text-white hover:bg-white/10"
>
Close
</button>
</div>
<div className="p-5 space-y-4">
<div>
<div className="text-[11px] text-zinc-500">Title</div>
<input
value={draft.title}
onChange={(e) => 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"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<div className="text-[11px] text-zinc-500">Owner</div>
<select
value={draft.ownerUserId ?? ""}
onChange={(e) =>
setDraft((d) => ({
...d,
ownerUserId: e.target.value ? 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"
>
<option value="">Unassigned</option>
{availableMembers.map((member) => {
const label = member.name ? `${member.name} (${member.email})` : member.email;
const suffix = member.isActive ? "" : " (inactive)";
return (
<option key={member.id} value={member.id}>
{label}{suffix}
</option>
);
})}
</select>
</div>
<div>
<div className="text-[11px] text-zinc-500">Due date</div>
<input
type="date"
value={draft.dueDate ?? ""}
onChange={(e) => 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"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<div className="text-[11px] text-zinc-500">Status</div>
<select
value={draft.status}
onChange={(e) => setDraft((d) => ({ ...d, status: e.target.value as ActionStatus }))}
className="mt-1 h-10 w-full rounded-xl border border-white/10 bg-black/20 px-3 text-sm text-white outline-none"
>
<option value="open">Open</option>
<option value="in_progress">In progress</option>
<option value="blocked">Blocked</option>
<option value="done">Done</option>
</select>
</div>
<div>
<div className="text-[11px] text-zinc-500">Priority</div>
<select
value={draft.priority}
onChange={(e) => setDraft((d) => ({ ...d, priority: e.target.value as ActionPriority }))}
className="mt-1 h-10 w-full rounded-xl border border-white/10 bg-black/20 px-3 text-sm text-white outline-none"
>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</select>
</div>
</div>
<div>
<div className="text-[11px] text-zinc-500">Notes</div>
<textarea
value={draft.notes}
onChange={(e) => setDraft((d) => ({ ...d, notes: e.target.value }))}
placeholder="Context, hypothesis, what to verify, etc."
className="mt-1 h-24 w-full resize-none rounded-xl border border-white/10 bg-black/20 px-3 py-2 text-sm text-white outline-none placeholder:text-zinc-500"
/>
</div>
{saveError ? (
<div className="rounded-xl border border-rose-500/30 bg-rose-500/10 px-3 py-2 text-xs text-rose-200">
{saveError}
</div>
) : null}
<div className="flex flex-wrap items-center gap-2 text-[11px] text-zinc-400">
{draft.machineId ? <span className="rounded-full border border-white/10 bg-white/5 px-2 py-1">Machine</span> : null}
{draft.reasonCode ? <span className="rounded-full border border-white/10 bg-white/5 px-2 py-1">Reason</span> : null}
{draft.hmDay != null && draft.hmHour != null ? (
<span className="rounded-full border border-white/10 bg-white/5 px-2 py-1">Heatmap bucket</span>
) : null}
</div>
<div className="flex items-center justify-between pt-2">
<div>
{onDelete && !isNew ? (
<button
onClick={async () => {
if (!draft.id) return;
setSaving(true);
setSaveError(null);
const result = await onDelete(draft.id);
if (!result.ok) {
setSaveError(result.error || "Failed to delete action");
setSaving(false);
return;
}
setSaving(false);
onClose();
}}
disabled={saving}
className="rounded-xl border border-rose-500/25 bg-rose-500/10 px-4 py-2 text-sm text-rose-200 hover:bg-rose-500/15 disabled:cursor-not-allowed disabled:opacity-60"
>
Delete
</button>
) : null}
</div>
<button
onClick={async () => {
setSaving(true);
setSaveError(null);
const now = new Date().toISOString();
const next: ActionItem = { ...draft, updatedAt: now };
const result = await onSave(next, isNew);
if (!result.ok) {
setSaveError(result.error || "Failed to save action");
setSaving(false);
return;
}
setSaving(false);
onClose();
}}
disabled={saving}
className="rounded-xl border border-emerald-500/30 bg-emerald-500/10 px-4 py-2 text-sm text-emerald-100 hover:bg-emerald-500/20 disabled:cursor-not-allowed disabled:opacity-60"
>
{saving ? "Saving..." : "Save action"}
</button>
</div>
</div>
</div>
</div>
);
}
function ActionsOwnershipPanel({
machineId,
reasonCode,
heatmapSel,
onFocusReason,
}: {
machineId: string | null;
reasonCode: string | null;
heatmapSel: HeatmapSel | null;
onFocusReason: (code: string) => void;
}) {
const [items, setItems] = React.useState<ActionItem[]>([]);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const [members, setMembers] = React.useState<MemberOption[]>([]);
const hmDay = heatmapSel?.day ?? null;
const hmHour = heatmapSel?.hour ?? null;
const loadActions = useCallback(async () => {
setLoading(true);
setError(null);
try {
const params = new URLSearchParams();
if (machineId) params.set("machineId", machineId);
if (reasonCode) params.set("reasonCode", reasonCode);
if (hmDay != null && hmHour != null) {
params.set("hmDay", String(hmDay));
params.set("hmHour", String(hmHour));
}
const qs = params.toString();
const res = await fetch(`/api/downtime/actions${qs ? `?${qs}` : ""}`, { cache: "no-store" });
const data = (await res.json().catch(() => ({}))) as {
ok?: boolean;
error?: string;
actions?: ActionItem[];
};
if (!res.ok || !data.ok) {
throw new Error(data.error || "Failed to load actions");
}
setItems(Array.isArray(data.actions) ? data.actions : []);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load actions");
setItems([]);
} finally {
setLoading(false);
}
}, [machineId, reasonCode, hmDay, hmHour]);
useEffect(() => {
loadActions();
}, [loadActions]);
useEffect(() => {
let alive = true;
async function loadMembers() {
try {
const res = await fetch("/api/org/members", { cache: "no-store" });
const data = (await res.json().catch(() => ({}))) as {
ok?: boolean;
error?: string;
members?: MemberOption[];
};
if (!alive) return;
if (res.ok && data.ok) {
setMembers(Array.isArray(data.members) ? data.members : []);
}
} catch {
if (alive) setMembers([]);
}
}
loadMembers();
return () => {
alive = false;
};
}, []);
const openItems = React.useMemo(() => items.filter((a) => a.status !== "done"), [items]);
const now = new Date();
const dueSoon = React.useMemo(() => {
return openItems.filter((a) => {
if (!a.dueDate) return false;
const d = new Date(a.dueDate + "T00:00:00");
const diffDays = (d.getTime() - now.getTime()) / (1000 * 60 * 60 * 24);
return diffDays >= 0 && diffDays <= 3;
});
}, [openItems, now]);
const overdue = React.useMemo(() => {
return openItems.filter((a) => {
if (!a.dueDate) return false;
const d = new Date(a.dueDate + "T00:00:00");
return d.getTime() < new Date(now.toDateString()).getTime();
});
}, [openItems, now]);
const [modalOpen, setModalOpen] = React.useState(false);
const [editing, setEditing] = React.useState<ActionItem | null>(null);
const initialNew: ActionItem = React.useMemo(() => {
const ts = new Date().toISOString();
return {
id: "",
createdAt: ts,
updatedAt: ts,
machineId,
reasonCode,
hmDay,
hmHour,
title: "",
notes: "",
ownerUserId: null,
ownerName: null,
ownerEmail: null,
dueDate: null,
status: "open",
priority: "medium",
};
}, [machineId, reasonCode, hmDay, hmHour]);
const saveAction = useCallback(
async (next: ActionItem, isNew: boolean) => {
const payload = {
machineId: next.machineId,
reasonCode: next.reasonCode,
hmDay: next.hmDay,
hmHour: next.hmHour,
title: next.title.trim(),
notes: next.notes.trim(),
ownerUserId: next.ownerUserId,
dueDate: next.dueDate,
status: next.status,
priority: next.priority,
};
const url = isNew ? "/api/downtime/actions" : `/api/downtime/actions/${next.id}`;
const res = await fetch(url, {
method: isNew ? "POST" : "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const data = (await res.json().catch(() => ({}))) as {
ok?: boolean;
error?: string;
action?: ActionItem;
};
if (!res.ok || !data.ok || !data.action) {
return { ok: false, error: data.error || "Failed to save action" };
}
setItems((prev) => {
if (isNew) return [data.action as ActionItem, ...prev];
const i = prev.findIndex((x) => x.id === data.action?.id);
if (i === -1) return [data.action as ActionItem, ...prev];
const copy = [...prev];
copy[i] = data.action as ActionItem;
return copy;
});
return { ok: true };
},
[]
);
const deleteAction = useCallback(async (id: string) => {
const res = await fetch(`/api/downtime/actions/${id}`, { method: "DELETE" });
const data = (await res.json().catch(() => ({}))) as { ok?: boolean; error?: string };
if (!res.ok || !data.ok) {
return { ok: false, error: data.error || "Failed to delete action" };
}
setItems((prev) => prev.filter((x) => x.id !== id));
return { ok: true };
}, []);
const list = items
.slice()
.sort((a, b) => (a.status === "done" ? 1 : -1) - (b.status === "done" ? 1 : -1));
return (
<div className="mt-4">
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-xs text-zinc-400">
Convert insights into ownership (who + when).
</div>
</div>
<button
onClick={() => {
setEditing(null);
setModalOpen(true);
}}
className="rounded-xl border border-white/10 bg-white/5 px-3 py-2 text-xs text-white hover:bg-white/10"
>
+ New action
</button>
</div>
{/* mini KPIs */}
<div className="mt-4 grid grid-cols-3 gap-3">
<div className="rounded-2xl border border-white/10 bg-black/20 p-3">
<div className="text-[11px] text-zinc-400">Open</div>
<div className="mt-1 text-base font-semibold text-white">{fmtNum(openItems.length, 0)}</div>
</div>
<div className="rounded-2xl border border-white/10 bg-black/20 p-3">
<div className="text-[11px] text-zinc-400">Due soon (3d)</div>
<div className="mt-1 text-base font-semibold text-white">{fmtNum(dueSoon.length, 0)}</div>
</div>
<div className="rounded-2xl border border-white/10 bg-black/20 p-3">
<div className="text-[11px] text-zinc-400">Overdue</div>
<div className="mt-1 text-base font-semibold text-white">{fmtNum(overdue.length, 0)}</div>
</div>
</div>
{error ? (
<div className="mt-3 rounded-xl border border-rose-500/25 bg-rose-500/10 px-3 py-2 text-xs text-rose-200">
{error}
</div>
) : null}
<div className="mt-4 overflow-hidden rounded-2xl border border-white/10 bg-black/20">
<div className="grid grid-cols-12 gap-2 border-b border-white/10 px-4 py-3 text-[11px] text-zinc-500">
<div className="col-span-6">Action</div>
<div className="col-span-3">Owner</div>
<div className="col-span-3 text-right">Status</div>
</div>
{loading ? (
<div className="p-4 text-sm text-zinc-400">Loading actions</div>
) : list.length === 0 ? (
<div className="p-4 text-sm text-zinc-400">
No actions yet. Create one from the current selection (Reason / Heatmap / Machine).
</div>
) : (
list.slice(0, 8).map((a) => (
<div
key={a.id}
className="grid grid-cols-12 gap-2 border-b border-white/5 px-4 py-3 hover:bg-white/5"
>
<button
onClick={() => {
setEditing(a);
setModalOpen(true);
}}
className="col-span-6 text-left"
>
<div className="truncate text-sm text-white">{a.title || "Untitled action"}</div>
<div className="mt-1 flex flex-wrap items-center gap-2 text-[11px] text-zinc-500">
{a.reasonCode ? (
<button
className="rounded-full border border-white/10 bg-white/5 px-2 py-0.5 hover:bg-white/10"
onClick={(e) => {
e.stopPropagation();
onFocusReason(a.reasonCode!);
}}
title="Focus this reason"
>
{a.reasonCode}
</button>
) : null}
{a.dueDate ? <span>Due {new Date(a.dueDate).toLocaleDateString()}</span> : <span>No due date</span>}
<span className={cn("rounded-full border px-2 py-0.5", priorityPill(a.priority))}>
{a.priority}
</span>
</div>
</button>
<div className="col-span-3 flex items-center text-sm text-zinc-200">
{a.ownerName || a.ownerEmail || "—"}
</div>
<div className="col-span-3 flex items-center justify-end gap-2">
<span className={cn("rounded-full border px-2 py-1 text-[11px]", statusPill(a.status))}>
{a.status.replace("_", " ")}
</span>
{a.status !== "done" ? (
<button
onClick={async () => {
const result = await saveAction(
{ ...a, status: "done", updatedAt: new Date().toISOString() },
false
);
if (!result.ok) {
setError(result.error || "Failed to update action");
}
}}
className="rounded-lg border border-emerald-500/25 bg-emerald-500/10 px-2 py-1 text-[11px] text-emerald-200 hover:bg-emerald-500/15"
title="Mark done"
>
Done
</button>
) : null}
</div>
</div>
))
)}
{list.length > 8 ? (
<div className="p-3 text-[11px] text-zinc-500">
Showing 8 / {list.length}. (Later: pagination + filters)
</div>
) : null}
</div>
<ActionModal
open={modalOpen}
onClose={() => setModalOpen(false)}
initial={editing ?? initialNew}
onSave={saveAction}
onDelete={(id) => deleteAction(id)}
members={members}
isNew={!editing}
/>
</div>
);
}
export default function DowntimePageClient() {
const sp = useSearchParams();
const router = useRouter();
const pathname = usePathname();
// URL-backed filters
const range = (sp.get("range") as Range) || "30d";
const machineId = sp.get("machineId") || null;
// client-only filters (shareable)
const metric = ((sp.get("metric") as Metric) || "minutes") as Metric;
const reasonCode = sp.get("reasonCode") || null;
const hmDay = sp.get("hmDay");
const hmHour = sp.get("hmHour");
const heatmapSel =
hmDay != null && hmHour != null && isValidNum(hmDay) && isValidNum(hmHour)
? { day: Number(hmDay), hour: Number(hmHour) }
: null;
const [pareto, setPareto] = useState<ApiParetoRes | null>(null);
const [coverage, setCoverage] = useState<ApiCoverageRes | null>(null);
const [loading, setLoading] = useState(true);
const [err, setErr] = useState<string | null>(null);
const [eventsRes, setEventsRes] = useState<ApiDowntimeEventsRes | null>(null);
const [eventsLoading, setEventsLoading] = useState(false);
const [eventsErr, setEventsErr] = useState<string | null>(null);
const [eventsLimit, setEventsLimit] = useState<number>(200);
const [eventsBefore, setEventsBefore] = useState<string | null>(null);
// simple client filter (fast): text search on machine/reason/wo
const [eventSearch, setEventSearch] = useState("");
const [drawer, setDrawer] = useState<{ open: boolean; row: MetricRow | null }>({
open: false,
row: null,
});
function fmtMxn(n: number) {
return new Intl.NumberFormat("es-MX", {
style: "currency",
currency: "MXN",
maximumFractionDigits: 0,
}).format(n);
}
function setParams(patch: Record<string, string | null>) {
const next = buildSearch(sp, patch);
router.replace(`${pathname}?${next}`, { scroll: false });
}
const mxnPerMin = Number(sp.get("mxnPerMin") || "0");
const [mxnPerMinInput, setMxnPerMinInput] = useState<string>(sp.get("mxnPerMin") ?? "");
useEffect(() => {
setMxnPerMinInput(String(mxnPerMin || ""));
}, [mxnPerMin]);
// Fetch (real)
useEffect(() => {
let alive = true;
const ac = new AbortController();
async function run() {
setLoading(true);
setErr(null);
try {
const qs = new URLSearchParams();
qs.set("kind", "downtime");
qs.set("range", range);
if (machineId) qs.set("machineId", machineId);
const [r1, r2] = await Promise.all([
fetch(`/api/analytics/pareto?${qs.toString()}`, {
cache: "no-cache",
credentials: "include",
signal: ac.signal,
}),
fetch(`/api/analytics/coverage?${qs.toString()}`, {
cache: "no-cache",
credentials: "include",
signal: ac.signal,
}),
]);
const j1 = (await r1.json().catch(() => ({}))) as ApiParetoRes;
const j2 = (await r2.json().catch(() => ({}))) as ApiCoverageRes;
if (!alive) return;
if (!r1.ok || j1.ok === false) {
setErr(j1?.error ?? "Failed to load pareto");
setPareto(null);
setCoverage(null);
setLoading(false);
return;
}
if (!r2.ok || j2.ok === false) {
// coverage is “nice to have” — dont kill the page
setCoverage(null);
} else {
setCoverage(j2);
}
setPareto(j1);
setLoading(false);
} catch (e: any) {
if (!alive) return;
setErr(e?.message ?? "Network error");
setLoading(false);
}
}
run();
return () => {
alive = false;
ac.abort();
};
}, [range, machineId]);
useEffect(() => {
let alive = true;
const ac = new AbortController();
async function run() {
setEventsLoading(true);
setEventsErr(null);
try {
const qs = new URLSearchParams();
qs.set("range", range);
qs.set("limit", String(eventsLimit));
if (machineId) qs.set("machineId", machineId);
if (reasonCode) qs.set("reasonCode", reasonCode);
if (eventsBefore) qs.set("before", eventsBefore);
const r = await fetch(`/api/analytics/downtime-events?${qs.toString()}`, {
cache: "no-cache",
credentials: "include",
signal: ac.signal,
});
const j = (await r.json().catch(() => ({}))) as ApiDowntimeEventsRes;
if (!alive) return;
if (!r.ok || j.ok === false) {
setEventsErr(j?.error ?? "Failed to load events");
setEventsRes(null);
setEventsLoading(false);
return;
}
setEventsRes(j);
setEventsLoading(false);
} catch (e: any) {
if (!alive) return;
setEventsErr(e?.message ?? "Network error");
setEventsLoading(false);
}
}
run();
return () => {
alive = false;
ac.abort();
};
}, [range, machineId, reasonCode, eventsLimit, eventsBefore]);
// Derived data
const baseRows = pareto?.rows ?? [];
const metricRowsAll = useMemo(() => computeMetricRows(baseRows, metric), [baseRows, metric]);
const metricRowsFiltered = useMemo(() => {
if (!reasonCode) return metricRowsAll;
return metricRowsAll.filter((r) => r.reasonCode === reasonCode);
}, [metricRowsAll, reasonCode]);
const totalMinutes = pareto?.totalMinutesLost ?? 0;
const totalStops = useMemo(
() => baseRows.reduce((acc, r) => acc + (r.count ?? 0), 0),
[baseRows]
);
const top3Share = useMemo(() => {
const top3 = metricRowsAll.slice(0, 3);
return top3.reduce((acc, r) => acc + (r.pctOfTotal ?? 0), 0);
}, [metricRowsAll]);
const unclassifiedPct = useMemo(() => findUnclassifiedPct(metricRowsAll), [metricRowsAll]);
const threshold80Index = useMemo(() => {
// If API threshold80 exists, its based on minutes. For count metric, compute locally.
if (metric === "minutes") return pareto?.threshold80?.index ?? null;
const idx = metricRowsAll.findIndex((r) => (r.cumulativePct ?? 0) >= 80);
return idx >= 0 ? idx : null;
}, [metric, pareto?.threshold80?.index, metricRowsAll]);
const heroData = useMemo(() => {
// Keep hero readable: top 12 (like your screenshot)
const slice = metricRowsAll.slice(0, 12);
return slice.map((r, i) => ({
i,
code: r.reasonCode,
label: r.reasonLabel,
value: r.value,
cum: r.cumulativePct,
pct: r.pctOfTotal,
count: r.count,
}));
}, [metricRowsAll]);
const totalDowntimeMin = pareto?.totalMinutesLost ?? 0;
const events = eventsRes?.events ?? [];
useEffect(() => {
setEventsBefore(null);
}, [range, machineId, reasonCode]);
const filteredEvents = useMemo(() => {
let list = events;
// Heatmap filter (day/hour) — filters by overlap with that hour bucket
if (heatmapSel) {
list = list.filter((e) => eventTouchesSlot(e, heatmapSel.day, heatmapSel.hour));
}
const q = eventSearch.trim().toLowerCase();
if (!q) return list;
return list.filter((e) => {
const hay = [
e.machineName ?? "",
e.reasonLabel ?? "",
e.reasonCode ?? "",
e.workOrderId ?? "",
e.episodeId ?? "",
]
.join(" ")
.toLowerCase();
return hay.includes(q);
});
}, [events, eventSearch, heatmapSel]);
// Use distinct episodes as "stops" (best available now)
const stops = coverage?.receivedEpisodes ?? totalStops;
// Window minutes for MTBF/Availability
const windowMin =
range === "24h" ? 24 * 60 :
range === "7d" ? 7 * 24 * 60 :
range === "30d" ? 30 * 24 * 60 : 0;
// Availability loss % (downtime / window)
const availabilityLossPct = windowMin > 0 ? (totalDowntimeMin / windowMin) * 100 : 0;
// MTTR proxy = avg stop duration
const mttrMin = stops > 0 ? totalDowntimeMin / stops : 0;
// MTBF proxy = avg run time between stops
const mtbfHours = stops > 0 ? (Math.max(0, windowMin - totalDowntimeMin) / stops) / 60 : 0;
// Impact (MXN) if rate is given
const rate = Number(mxnPerMinInput || "0");
const estImpactMxn = rate > 0 ? totalDowntimeMin * rate : 0;
function exportCSV() {
const rows = metricRowsAll;
const header = [
"reasonCode",
"reasonLabel",
metric === "minutes" ? "minutesLost" : "count",
"stops",
"pctOfTotal",
"cumulativePct",
];
const lines = [header.join(",")];
rows.forEach((r) => {
const v = metric === "minutes" ? (r.value ?? 0) : (r.value ?? 0);
const cells = [
`"${String(r.reasonCode ?? "").replaceAll('"', '""')}"`,
`"${String(r.reasonLabel ?? "").replaceAll('"', '""')}"`,
String(v),
String(r.count ?? 0),
String(r.pctOfTotal ?? 0),
String(r.cumulativePct ?? 0),
];
lines.push(cells.join(","));
});
const blob = new Blob([lines.join("\n")], { type: "text/csv;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `downtime_pareto_${metric}_${range}${machineId ? `_machine_${machineId}` : ""}.csv`;
a.click();
URL.revokeObjectURL(url);
}
async function shareLink() {
try {
await navigator.clipboard.writeText(window.location.href);
// silent (you can add a toast later)
} catch {
// ignore
}
}
const scopeChips = (
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs text-zinc-400">Scope:</span>
<span className="rounded-full border border-emerald-500/25 bg-emerald-500/10 px-3 py-1 text-xs text-emerald-200">
Org
</span>
{machineId ? (
<span className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-3 py-1 text-xs text-white">
Machine filtered
<button
className="rounded-full border border-white/10 bg-black/20 px-2 py-0.5 text-[11px] text-zinc-200 hover:bg-white/10"
onClick={() => setParams({ machineId: null, reasonCode: null })}
>
</button>
</span>
) : (
<span className="rounded-full border border-white/10 bg-white/5 px-3 py-1 text-xs text-zinc-200">
All machines
</span>
)}
{reasonCode ? (
<span className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-3 py-1 text-xs text-white">
Reason: {reasonCode}
<button
className="rounded-full border border-white/10 bg-black/20 px-2 py-0.5 text-[11px] text-zinc-200 hover:bg-white/10"
onClick={() => setParams({ reasonCode: null })}
>
</button>
</span>
) : null}
{heatmapSel ? (
<span className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-3 py-1 text-xs text-white">
Heatmap: {DAY_LABELS[heatmapSel.day]} {String(heatmapSel.hour).padStart(2, "0")}:00
<button
className="rounded-full border border-white/10 bg-black/20 px-2 py-0.5 text-[11px] text-zinc-200 hover:bg-white/10"
onClick={() => setParams({ hmDay: null, hmHour: null })}
>
</button>
</span>
) : null}
</div>
);
const shift = sp.get("shift") || "all";
const planned = (sp.get("planned") as "all" | "planned" | "unplanned") || "all";
const microstopLtMin = sp.get("microstopLtMin") || "2";
const filtersRow = (
<div className="mt-4 flex items-center justify-between gap-4">
{/* LEFT: range + metric + reset (never wrap) */}
<div className="flex items-center gap-2 flex-nowrap overflow-x-auto no-scrollbar">
<button
onClick={() => setParams({ range: "24h" })}
className={cn(
"h-9 rounded-xl border px-3 text-xs",
range === "24h"
? "border-emerald-500/30 bg-emerald-500/10 text-emerald-100"
: "border-white/10 bg-white/5 text-zinc-200 hover:bg-white/10"
)}
>
Today
</button>
<button
onClick={() => setParams({ range: "7d" })}
className={cn(
"h-9 rounded-xl border px-3 text-xs",
range === "7d"
? "border-emerald-500/30 bg-emerald-500/10 text-emerald-100"
: "border-white/10 bg-white/5 text-zinc-200 hover:bg-white/10"
)}
>
7D
</button>
<button
onClick={() => setParams({ range: "30d" })}
className={cn(
"h-9 rounded-xl border px-3 text-xs",
range === "30d"
? "border-emerald-500/30 bg-emerald-500/10 text-emerald-100"
: "border-white/10 bg-white/5 text-zinc-200 hover:bg-white/10"
)}
>
30D
</button>
<div className="mx-2 h-6 w-px bg-white/10" />
<button
onClick={() => setParams({ metric: "minutes" })}
className={cn(
"h-9 rounded-xl border px-3 text-xs",
metric === "minutes"
? "border-white/10 bg-white/10 text-white"
: "border-white/10 bg-white/5 text-zinc-200 hover:bg-white/10"
)}
>
Minutes
</button>
<button
onClick={() => setParams({ metric: "count" })}
className={cn(
"h-9 rounded-xl border px-3 text-xs",
metric === "count"
? "border-white/10 bg-white/10 text-white"
: "border-white/10 bg-white/5 text-zinc-200 hover:bg-white/10"
)}
>
Count
</button>
<div className="mx-2 h-6 w-px bg-white/10" />
<button
onClick={() =>
setParams({
range: "30d",
metric: "minutes",
shift: "all",
planned: "all",
microstopLtMin: "2",
reasonCode: null,
mxnPerMin: null,
})
}
className="h-9 rounded-xl border border-white/10 bg-white/5 px-3 text-xs text-zinc-200 hover:bg-white/10"
>
Reset filters
</button>
</div>
{/* RIGHT: shift + planned/unplanned + microstop (also never wrap) */}
<div className="flex items-center gap-2 flex-nowrap overflow-x-auto no-scrollbar">
<select
value={shift}
onChange={(e) => setParams({ shift: e.target.value })}
className="h-9 rounded-xl border border-white/10 bg-white/5 px-3 text-xs text-zinc-200 outline-none hover:bg-white/10"
>
<option value="all">All shifts</option>
<option value="A">Shift A</option>
<option value="B">Shift B</option>
<option value="C">Shift C</option>
</select>
<select
value={planned}
onChange={(e) => setParams({ planned: e.target.value })}
className="h-9 rounded-xl border border-white/10 bg-white/5 px-3 text-xs text-zinc-200 outline-none hover:bg-white/10"
>
<option value="all">Planned + Unplanned</option>
<option value="planned">Planned</option>
<option value="unplanned">Unplanned</option>
</select>
<div className="flex h-9 items-center gap-2 rounded-xl border border-white/10 bg-white/5 px-3 text-xs text-zinc-200">
<span className="text-zinc-400">Microstop &lt;</span>
<input
value={microstopLtMin}
onChange={(e) => setParams({ microstopLtMin: e.target.value })}
className="w-10 bg-transparent text-right text-xs text-white outline-none"
/>
<span className="text-zinc-400">min</span>
</div>
</div>
</div>
);
function HeroTooltip({
active,
payload,
}: {
active?: boolean;
payload?: Array<{ payload?: any }>;
}) {
if (!active || !payload?.length) return null;
const p = payload[0]?.payload;
if (!p) return null;
return (
<div className="rounded-xl border border-white/10 bg-zinc-950/95 px-4 py-3 shadow-lg">
<div className="text-sm font-semibold text-white">{p.label}</div>
<div className="mt-2 space-y-1 text-xs text-zinc-300">
<div>
Value:{" "}
<span className="text-white">
{metric === "minutes" ? `${fmtNum(p.value, 1)} min` : fmtNum(p.value, 0)}
</span>
</div>
<div>
Share: <span className="text-white">{fmtPct(p.pct, 1)}</span>
</div>
<div>
Stops: <span className="text-white">{fmtNum(p.count, 0)}</span>
</div>
<div>
Cumulative: <span className="text-white">{fmtPct(p.cum, 0)}</span>
</div>
</div>
</div>
);
}
return (
<div className="p-4 sm:p-6">
{/* Header */}
<div className="relative overflow-hidden rounded-3xl border border-white/10 bg-zinc-950/40 p-6 backdrop-blur-xl">
<div
className="pointer-events-none absolute inset-0 opacity-70"
style={{
background:
"radial-gradient(900px 500px at 20% 10%, rgba(16,185,129,.18), transparent 60%)," +
"radial-gradient(900px 500px at 85% 30%, rgba(59,130,246,.12), transparent 60%)," +
"radial-gradient(900px 600px at 50% 100%, rgba(244,63,94,.10), transparent 60%)",
}}
/>
<div className="relative flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="min-w-0">
<div className="text-2xl font-semibold text-white">Downtime Pareto Full Report</div>
<div className="mt-1 text-sm text-zinc-400">
Analyze downtime patterns and prioritize improvements
</div>
<div className="mt-4">{scopeChips}</div>
{filtersRow}
</div>
<div className="relative flex flex-wrap items-center gap-2 lg:justify-end">
<div className="flex items-center gap-2 rounded-xl border border-white/10 bg-white/5 px-3 py-2">
<span className="text-xs text-zinc-400">MXN/min</span>
<input
value={mxnPerMinInput}
onChange={(e) => setMxnPerMinInput(e.target.value.replace(/[^\d]/g, ""))}
onBlur={() => setParams({ mxnPerMin: mxnPerMinInput ? mxnPerMinInput : null })}
placeholder="0"
className="w-20 bg-transparent text-right text-sm text-white outline-none"
/>
</div>
<button
onClick={exportCSV}
className="rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-white hover:bg-white/10"
>
Export
</button>
<button
onClick={shareLink}
className="rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-white hover:bg-white/10"
>
Share
</button>
<span className="rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-zinc-400">
Plant select (soon)
</span>
{machineId ? (
<Link
href={`/machines/${encodeURIComponent(machineId)}`}
className="rounded-xl border border-emerald-500/30 bg-emerald-500/10 px-4 py-2 text-sm text-emerald-100 hover:bg-emerald-500/20"
>
Back to machine
</Link>
) : null}
</div>
</div>
</div>
{/* Loading / error */}
{loading ? (
<div className="mt-6 text-sm text-zinc-400">Loading downtime pareto</div>
) : err ? (
<div className="mt-6 rounded-2xl border border-red-500/20 bg-red-500/10 p-4 text-sm text-red-200">
{err}
</div>
) : null}
{!loading && !err && (
<>
{/* KPI strip */}
<div className="mt-6 grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-8">
<KPI
label="Total downtime"
value={fmtHoursFromMinutes(totalDowntimeMin)}
sub={`${fmtNum(totalDowntimeMin, 1)} min`}
accent="emerald"
/>
<KPI
label="Stops count"
value={fmtNum(stops, 0)}
sub="Distinct episodes (coverage)"
accent="zinc"
/>
<KPI
label="Top reason share"
value={metricRowsAll[0] ? fmtPct(metricRowsAll[0].pctOfTotal, 1) : "—"}
sub={metricRowsAll[0] ? metricRowsAll[0].reasonLabel : ""}
accent="yellow"
/>
<KPI
label="MTBF"
value={stops > 0 ? `${fmtNum(mtbfHours, 1)} hrs` : "—"}
sub="Proxy (window-based)"
/>
<KPI
label="MTTR"
value={stops > 0 ? `${fmtNum(mttrMin, 1)} min` : "—"}
sub="Avg stop duration"
/>
<KPI
label="Availability loss"
value={windowMin > 0 ? `${fmtNum(availabilityLossPct, 1)}%` : "—"}
sub="Downtime / window"
accent="rose"
/>
<KPI
label="Est. impact (MXN)"
value={rate > 0 ? fmtMxn(estImpactMxn) : "—"}
sub={rate > 0 ? `Rate: ${fmtMxn(rate)}/min` : "Set MXN/min"}
accent="rose"
/>
<KPI
label="Unclassified"
value={`${fmtNum(unclassifiedPct, 0)}%`}
sub="Data quality signal"
/>
</div>
{/* Hero + breakdown */}
<div className="mt-6 grid grid-cols-1 gap-4 xl:grid-cols-3">
{/* Hero chart */}
<div className="rounded-3xl border border-white/10 bg-white/5 p-5 xl:col-span-2">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<div className="text-lg font-semibold text-white">Downtime Pareto Analysis</div>
<div className="mt-1 text-xs text-zinc-400">
Top reasons by impact · {range} · metric: {metric}
</div>
</div>
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-xs text-zinc-300">
<div className="text-white">
Top 3 reasons explain{" "}
<span className="font-semibold">{fmtPct(top3Share, 1)}</span>
</div>
<div className="mt-1 text-zinc-400">
Fix these first = highest ROI
</div>
</div>
</div>
<div
className="mt-4 h-[360px] rounded-3xl border border-white/10 bg-black/30 p-4 backdrop-blur"
style={{ boxShadow: "var(--app-chart-shadow)" }}
>
<ResponsiveContainer width="100%" height="100%">
<ComposedChart
data={heroData}
onClick={(st: any) => {
const p = st?.activePayload?.[0]?.payload;
if (!p?.code) return;
setParams({ reasonCode: p.code });
}}
>
<CartesianGrid strokeDasharray="3 3" stroke="var(--app-chart-grid)" />
<XAxis
dataKey="label"
interval={0}
tick={{ fill: "var(--app-chart-tick)" }}
tickFormatter={(v: string) => (v.length > 14 ? `${v.slice(0, 14)}` : v)}
/>
<YAxis
yAxisId="left"
tick={{ fill: "var(--app-chart-tick)" }}
tickFormatter={(v: number) =>
metric === "minutes" ? `${v}` : `${v}`
}
/>
<YAxis
yAxisId="right"
orientation="right"
domain={[0, 100]}
tick={{ fill: "var(--app-chart-tick)" }}
tickFormatter={(v: number) => `${v}%`}
/>
<Tooltip content={<HeroTooltip />} cursor={{ stroke: "var(--app-chart-grid)" }} />
<Bar
yAxisId="left"
dataKey="value"
radius={[10, 10, 0, 0]}
isAnimationActive={false}
fill="rgba(16,185,129,0.85)"
/>
<Line
yAxisId="right"
type="monotone"
dataKey="cum"
stroke="rgba(110,231,183,0.95)"
strokeWidth={2}
dot={false}
isAnimationActive={false}
/>
<ReferenceLine
yAxisId="right"
y={80}
stroke="rgba(255,255,255,0.25)"
strokeDasharray="6 6"
/>
</ComposedChart>
</ResponsiveContainer>
</div>
{threshold80Index != null && metricRowsAll[threshold80Index] ? (
<div className="mt-3 text-xs text-zinc-400">
80% threshold reached at{" "}
<span className="text-white">{metricRowsAll[threshold80Index].reasonLabel}</span>
</div>
) : null}
</div>
{/* Reason breakdown */}
<div className="rounded-3xl border border-white/10 bg-white/5 p-5">
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-lg font-semibold text-white">Reason Breakdown</div>
<div className="mt-1 text-xs text-zinc-400">Click row for details</div>
</div>
<div className="rounded-xl border border-white/10 bg-white/5 px-3 py-1.5 text-xs text-zinc-300">
Top {Math.min(12, metricRowsAll.length)}
</div>
</div>
<div className="mt-4 max-h-[360px] overflow-y-auto no-scrollbar rounded-2xl border border-white/10 bg-black/20">
<div className="grid grid-cols-12 gap-2 border-b border-white/10 px-4 py-3 text-[11px] text-zinc-500">
<div className="col-span-8">Reason</div>
<div className="col-span-4 text-right">{metric === "minutes" ? "Minutes" : "Count"}</div>
</div>
{metricRowsAll.slice(0, 12).map((r) => {
const active = reasonCode === r.reasonCode;
return (
<button
key={r.reasonCode}
className={cn(
"grid w-full grid-cols-12 gap-2 px-4 py-3 text-left text-sm transition",
"border-b border-white/5 hover:bg-white/5",
active && "bg-emerald-500/10"
)}
onClick={() => {
setDrawer({ open: true, row: r });
setParams({ reasonCode: r.reasonCode });
}}
>
<div className="col-span-8">
<div className="truncate text-white">{r.reasonLabel}</div>
<div className="mt-1 text-[11px] text-zinc-400">
{fmtPct(r.pctOfTotal, 1)} · {fmtNum(r.count, 0)} stops
</div>
</div>
<div className="col-span-4 text-right">
<div className="text-white">
{metric === "minutes" ? `${fmtNum(r.value, 1)}m` : fmtNum(r.value, 0)}
</div>
<div className="mt-1 text-[11px] text-zinc-500">
cum {fmtPct(r.cumulativePct, 0)}
</div>
</div>
</button>
);
})}
{metricRowsAll.length === 0 ? (
<div className="p-4 text-sm text-zinc-400">No data for this range.</div>
) : null}
</div>
{/* Coverage mini */}
<div className="mt-4 rounded-2xl border border-white/10 bg-white/5 p-4">
<div className="text-sm font-semibold text-white">Coverage received</div>
<div className="mt-1 text-xs text-zinc-400">
Sync health from Control Tower ingest
</div>
<div className="mt-3 grid grid-cols-2 gap-3">
<div className="rounded-xl border border-white/10 bg-black/20 p-3">
<div className="text-[11px] text-zinc-400">Episodes</div>
<div className="mt-1 text-base font-semibold text-white">
{coverage?.receivedEpisodes != null ? fmtNum(coverage.receivedEpisodes, 0) : "—"}
</div>
</div>
<div className="rounded-xl border border-white/10 bg-black/20 p-3">
<div className="text-[11px] text-zinc-400">Minutes</div>
<div className="mt-1 text-base font-semibold text-white">
{coverage?.receivedMinutes != null ? fmtNum(coverage.receivedMinutes, 1) : "—"}
</div>
</div>
</div>
{coverage?.note ? (
<div className="mt-3 text-[11px] text-zinc-500">{coverage.note}</div>
) : null}
</div>
</div>
</div>
{/* Drilldown table */}
<div className="mt-6 rounded-3xl border border-white/10 bg-white/5 p-5">
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
<div>
<div className="text-lg font-semibold text-white">Drilldown table</div>
<div className="mt-1 text-xs text-zinc-400">
Sortable later · click row opens drawer
</div>
</div>
<div className="text-xs text-zinc-400">
Showing {metricRowsFiltered.length} / {metricRowsAll.length}
</div>
</div>
<div className="mt-4 overflow-x-auto rounded-2xl border border-white/10 bg-black/20">
<table className="w-full min-w-[860px] text-left text-sm">
<thead className="border-b border-white/10 text-[11px] text-zinc-500">
<tr>
<th className="px-4 py-3">Reason</th>
<th className="px-4 py-3 text-right">Downtime (min)</th>
<th className="px-4 py-3 text-right">Stops</th>
<th className="px-4 py-3 text-right">Avg (min)</th>
<th className="px-4 py-3 text-right">% share</th>
<th className="px-4 py-3 text-right">Cum %</th>
</tr>
</thead>
<tbody className="divide-y divide-white/5">
{metricRowsFiltered.map((r) => {
const avg =
r.count > 0 && r.minutesLost != null ? r.minutesLost / r.count : null;
return (
<tr
key={r.reasonCode}
className={cn(
"cursor-pointer hover:bg-white/5",
reasonCode === r.reasonCode && "bg-emerald-500/10"
)}
onClick={() => {
setDrawer({ open: true, row: r });
setParams({ reasonCode: r.reasonCode });
}}
>
<td className="px-4 py-3">
<div className="truncate text-white">{r.reasonLabel}</div>
<div className="mt-1 text-[11px] text-zinc-500">{r.reasonCode}</div>
</td>
<td className="px-4 py-3 text-right text-white">
{r.minutesLost != null ? fmtNum(r.minutesLost, 1) : "—"}
</td>
<td className="px-4 py-3 text-right text-white">{fmtNum(r.count, 0)}</td>
<td className="px-4 py-3 text-right text-zinc-200">
{avg == null ? "—" : fmtNum(avg, 1)}
</td>
<td className="px-4 py-3 text-right text-zinc-200">{fmtPct(r.pctOfTotal, 1)}</td>
<td className="px-4 py-3 text-right text-zinc-200">{fmtPct(r.cumulativePct, 0)}</td>
</tr>
);
})}
{metricRowsFiltered.length === 0 ? (
<tr>
<td className="px-4 py-6 text-sm text-zinc-400" colSpan={6}>
No rows.
</td>
</tr>
) : null}
</tbody>
</table>
</div>
</div>
{/* Patterns + Events + Actions (layout placeholders, no endpoints yet) */}
<div className="mt-6 grid grid-cols-1 gap-4 xl:grid-cols-3">
<div className="rounded-3xl border border-white/10 bg-white/5 p-5 xl:col-span-2">
<div className="text-lg font-semibold text-white">Patterns (heatmaps)</div>
<div className="mt-1 text-xs text-zinc-400">
Add endpoints later: hour-of-day × day heatmap, shift comparisons
</div>
<Heatmap
events={events}
metric={metric}
selected={heatmapSel}
onSelect={(day, hour) => setParams({ hmDay: String(day), hmHour: String(hour) })}
onClear={() => setParams({ hmDay: null, hmHour: null })}
/>
</div>
<div className="rounded-3xl border border-white/10 bg-white/5 p-5">
<div className="text-lg font-semibold text-white">Actions & ownership</div>
<div className="mt-1 text-xs text-zinc-400">
Next: create action from reason/event (owner, due date, status)
</div>
<ActionsOwnershipPanel
machineId={machineId}
reasonCode={reasonCode}
heatmapSel={heatmapSel}
onFocusReason={(code) => setParams({ reasonCode: code })}
/>
</div>
</div>
<div className="mt-6 rounded-3xl border border-white/10 bg-white/5 p-5">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<div className="text-lg font-semibold text-white">Event list (audit trail)</div>
<div className="mt-1 text-xs text-zinc-400">
Real downtime episodes · filtered by scope + reason
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<input
value={eventSearch}
onChange={(e) => setEventSearch(e.target.value)}
placeholder="Search machine / reason / WO / episode…"
className="h-9 w-[260px] rounded-xl border border-white/10 bg-black/20 px-3 text-xs text-white outline-none placeholder:text-zinc-500"
/>
<select
value={String(eventsLimit)}
onChange={(e) => {
setEventsBefore(null);
setEventsLimit(Number(e.target.value));
}}
className="h-9 rounded-xl border border-white/10 bg-white/5 px-3 text-xs text-zinc-200 outline-none hover:bg-white/10"
>
<option value="50">50</option>
<option value="100">100</option>
<option value="200">200</option>
<option value="300">300</option>
<option value="500">500</option>
</select>
<button
onClick={() => setEventsBefore(null)}
className="h-9 rounded-xl border border-white/10 bg-white/5 px-3 text-xs text-zinc-200 hover:bg-white/10"
>
Newest
</button>
<button
disabled={!eventsRes?.nextBefore}
onClick={() => setEventsBefore(eventsRes?.nextBefore ?? null)}
className={cn(
"h-9 rounded-xl border px-3 text-xs",
eventsRes?.nextBefore
? "border-white/10 bg-white/5 text-zinc-200 hover:bg-white/10"
: "border-white/10 bg-white/5 text-zinc-500 opacity-50 cursor-not-allowed"
)}
>
Older
</button>
</div>
</div>
{eventsLoading ? (
<div className="mt-4 text-sm text-zinc-400">Loading events</div>
) : eventsErr ? (
<div className="mt-4 rounded-2xl border border-red-500/20 bg-red-500/10 p-4 text-sm text-red-200">
{eventsErr}
</div>
) : (
<div className="mt-4 overflow-x-auto rounded-2xl border border-white/10 bg-black/20">
<table className="w-full min-w-[980px] text-left text-sm">
<thead className="border-b border-white/10 text-[11px] text-zinc-500">
<tr>
<th className="px-4 py-3">Start</th>
<th className="px-4 py-3">End</th>
<th className="px-4 py-3">Machine</th>
<th className="px-4 py-3">Reason</th>
<th className="px-4 py-3">WO</th>
<th className="px-4 py-3 text-right">Duration</th>
<th className="px-4 py-3 text-right">Episode</th>
</tr>
</thead>
<tbody className="divide-y divide-white/5">
{filteredEvents.map((e) => {
const isActive = reasonCode === e.reasonCode;
const durMin = e.durationMinutes ?? (e.durationSeconds != null ? e.durationSeconds / 60 : null);
return (
<tr
key={e.id}
className={cn(
"cursor-pointer hover:bg-white/5",
isActive && "bg-emerald-500/10"
)}
onClick={() => {
// clicking an event focuses the rest of the page on its reason
setParams({ reasonCode: e.reasonCode });
}}
title="Click to focus this reason"
>
<td className="px-4 py-3 text-zinc-200">{fmtDT(e.startAt)}</td>
<td className="px-4 py-3 text-zinc-200">{fmtDT(e.endAt)}</td>
<td className="px-4 py-3">
<div className="truncate text-white">{e.machineName ?? "—"}</div>
<div className="mt-1 text-[11px] text-zinc-500">{e.machineId}</div>
</td>
<td className="px-4 py-3">
<div className="truncate text-white">{e.reasonLabel}</div>
<div className="mt-1 text-[11px] text-zinc-500">{e.reasonCode}</div>
</td>
<td className="px-4 py-3 text-zinc-200">{e.workOrderId ?? "—"}</td>
<td className="px-4 py-3 text-right text-white">
{durMin == null ? "—" : `${fmtNum(durMin, 1)} min`}
</td>
<td className="px-4 py-3 text-right text-[11px] text-zinc-500">
{e.episodeId ?? "—"}
</td>
</tr>
);
})}
{filteredEvents.length === 0 ? (
<tr>
<td className="px-4 py-6 text-sm text-zinc-400" colSpan={7}>
No events found for this filter/range.
</td>
</tr>
) : null}
</tbody>
</table>
</div>
)}
<div className="mt-3 text-[11px] text-zinc-500">
Tip: click any row to focus the whole page on that reason (Pareto + table + drawer).
</div>
</div>
</>
)}
<ReasonDrawer
open={drawer.open}
onClose={() => setDrawer({ open: false, row: null })}
row={drawer.row}
metric={metric}
/>
</div>
);
}