reliability semi-fix

This commit is contained in:
Marcelo
2026-04-24 14:06:15 +00:00
parent 4973c18dc3
commit 6aaafb9115
32 changed files with 3749 additions and 1093 deletions

161
Reliability.md Normal file
View File

@@ -0,0 +1,161 @@
Data Reliability — Handoff Prompt
Problem
Same machine shows different numbers in 3 places:
Home UI (Node-RED): goodParts=353, OEE=77.9%
Recap: goodParts=185, OEE=56%
Machine detail: OEE=4.3%, "Sin datos" in 1h timeline
Root cause: each view queries a different table with different logic. No single source of truth.
Rule: pick one source per metric, reuse across views
Metric Authoritative source Why
goodParts, scrapParts (per WO) MachineWorkOrder.good_parts / scrap_parts Node-RED writes this via UPDATE work_orders SET .... It's what Home UI shows.
cycleCount MachineWorkOrder.cycle_count Same reason.
oee / availability / performance / quality time-weighted avg of MachineKpiSnapshot rows in window Snapshots are minute-by-minute; Node-RED already sends them. Don't recompute.
Stops (count, duration) MachineEvent filtered by eventType IN (microstop, macrostop, mold-change) + data->>'status' != 'active' + !is_update && !is_auto_ack Deduped at source.
Timeline segments UNION of: MachineWorkOrder status spans, MachineEvent (mold-change/micro/macro), filled with idle Only way to get continuous coverage.
Backend changes
/api/recap/[machineId]/route.ts and /api/recap/summary/route.ts
goodParts aggregation — stop summing MachineCycle.good_delta. Instead:
// For window [start, end], sum good_parts from WOs that had activity in window
const wos = await prisma.machineWorkOrder.findMany({
where: { machineId, orgId, updatedAt: { gte: start } },
select: { workOrderId: true, sku: true, good_parts: true, scrap_parts: true, target_qty: true, status: true, updatedAt: true }
});
const goodParts = wos.reduce((s, w) => s + (w.good_parts ?? 0), 0);
const scrapParts = wos.reduce((s, w) => s + (w.scrap_parts ?? 0), 0);
Optionally scope to WOs that were RUNNING during the window; but for 24h window this rarely matters.
OEE aggregation — time-weighted average:
const snaps = await prisma.machineKpiSnapshot.findMany({
where: { machineId, orgId, ts: { gte: start, lte: end } },
orderBy: { ts: 'asc' },
select: { ts: true, oee: true, availability: true, performance: true, quality: true }
});
function weightedAvg(field: 'oee' | 'availability' | 'performance' | 'quality') {
if (snaps.length === 0) return null;
let totalMs = 0, sum = 0;
for (let i = 0; i < snaps.length; i++) {
const nextTs = (snaps[i+1]?.ts ?? end).getTime();
const dt = Math.max(0, nextTs - snaps[i].ts.getTime());
sum += (snaps[i][field] ?? 0) * dt;
totalMs += dt;
}
return totalMs > 0 ? sum / totalMs : null;
}
Return null (not 0, not 100) when no snapshots. Frontend renders — for null.
Stops aggregation — filter properly:
const stops = await prisma.machineEvent.findMany({
where: {
machineId, orgId,
ts: { gte: start, lte: end },
eventType: { in: ['microstop','macrostop'] },
}
});
const real = stops.filter(e => {
const d = e.data as any;
return d?.status !== 'active' && !d?.is_auto_ack && !d?.is_update;
});
const stopsCount = real.length;
const stopsMin = real.reduce((s, e) => s + (((e.data as any)?.stoppage_duration_seconds ?? 0) / 60), 0);
/api/recap/[machineId]/timeline — MUST include mold-change
Segment builder in priority order (higher priority wins when overlapping):
mold-change segments (pair active→resolved by incidentKey, duration from data.duration_sec)
macrostop segments (same pairing)
microstop segments (merge runs <60s apart into cluster)
production segments — derived from WO status history, use MachineWorkOrder.status transitions + MachineCycle density (no cycles for >threshold → not production)
idle gap-fill
Never return empty array if any event exists in window. "Sin datos" only if literally zero rows in both MachineEvent and MachineCycle for the window.
Merge rules:
Same-type consecutive segments separated by <30s → merge
Any segment <30s duration, absorb into neighbor
Return format:
{
range: { start, end },
segments: Array<{
type: 'production' | 'mold-change' | 'macrostop' | 'microstop' | 'idle',
startMs, endMs, durationSec,
label?: string, // WO id, mold ids, reason
workOrderId?: string,
sku?: string,
reasonLabel?: string,
}>,
hasData: boolean // false only if literally empty
}
Frontend changes
RecapMachineCard.tsx / Machine detail page / OverviewTimeline.tsx
All three MUST consume the same endpoint and render from the same shape. Timeline in Machine detail page (app/(app)/machines/[machineId]/MachineDetailClient.tsx) currently queries its own source — refactor to call /api/recap/[machineId]/timeline with range=1h for the small timeline, range=24h for the recap.
"Sin datos" fallback: render only when hasData === false. If timeline has any mold-change or stop segment, render the bar.
Null handling for OEE
If backend returns oee: null:
<div className="text-2xl font-semibold text-zinc-400">—</div>
<div className="text-xs text-zinc-500">Sin datos de KPI</div>
Not 0.0%. Not 100%. Dash. User knows "no data" vs. "bad performance".
Reconciling with Home UI live numbers
Home UI reads live state.activeWorkOrder.goodParts from Node-RED. Recap reads MachineWorkOrder.good_parts from CT DB.
These WILL briefly differ because of outbox lag (cycle POST → DB insert → next recap query). Mitigate:
Cache recap endpoints 30-60s max (shorter than current 2-5 min).
On recap header, show "Actualizado hace Xs" timestamp so user sees freshness.
Pi cycle outbox should already be fast (<5s normally). If backlog is persistent, flag it in the UI with a "CT desincronizado" warning (compare MachineHeartbeat.ts to now; if >5min, show amber status).
Sanity check queries for debugging
Run on CT to audit one machine:
-- Authoritative WO state (matches Home UI)
SELECT work_order_id, sku, good_parts, scrap_parts, cycle_count, status, "updatedAt"
FROM "MachineWorkOrder"
WHERE "machineId" = '<uuid>'
ORDER BY "updatedAt" DESC LIMIT 5;
-- What KPI snapshots exist in last 24h
SELECT ts, oee, availability, performance, quality
FROM "MachineKpiSnapshot"
WHERE "machineId" = '<uuid>' AND ts > NOW() - INTERVAL '24 hours'
ORDER BY ts DESC LIMIT 20;
-- Events breakdown
SELECT "eventType",
COUNT(*) AS total,
COUNT(*) FILTER (WHERE data->>'status' = 'active') AS active,
COUNT(*) FILTER (WHERE (data->>'is_update')::bool) AS updates,
COUNT(*) FILTER (WHERE (data->>'is_auto_ack')::bool) AS auto_acks
FROM "MachineEvent"
WHERE "machineId" = '<uuid>' AND ts > NOW() - INTERVAL '24 hours'
GROUP BY "eventType";
If MachineWorkOrder.good_parts says 353 and Home UI says 353 but recap says 185 → recap is still using old aggregation.
If MachineKpiSnapshot count is 0 for last hour → Node-RED isn't sending snapshots (check outbox).
Checklist
not done
Recap endpoints use MachineWorkOrder.good_parts not cycle sum
not done
OEE uses time-weighted MachineKpiSnapshot avg, returns null when empty
not done
Timeline includes mold-change events
not done
Machine detail timeline uses same endpoint as recap
not done
"Sin datos" fallback only when hasData: false
not done
Null OEE renders as —, not 0 or 100
not done
Same endpoint feeds recap grid mini timeline + detail full timeline
not done
Cache TTL reduced to 30-60s
not done
Staleness indicator visible in UI header
Non-goals: no schema changes, no Node-RED changes, no new ingest endpoints

View File

@@ -20,6 +20,14 @@ import {
} from "recharts"; } from "recharts";
import { useI18n } from "@/lib/i18n/useI18n"; import { useI18n } from "@/lib/i18n/useI18n";
import { useScreenlessMode } from "@/lib/ui/screenlessMode"; import { useScreenlessMode } from "@/lib/ui/screenlessMode";
import type { RecapTimelineResponse, RecapTimelineSegment } from "@/lib/recap/types";
import {
computeWidths,
formatDuration,
formatTime,
normalizeTimelineSegments,
TIMELINE_COLORS,
} from "@/components/recap/timelineRender";
type Heartbeat = { type Heartbeat = {
ts: string; ts: string;
@@ -87,20 +95,6 @@ type Thresholds = {
type TimelineState = "normal" | "slow" | "microstop" | "macrostop"; type TimelineState = "normal" | "slow" | "microstop" | "macrostop";
type TimelineSeg = {
start: number;
end: number;
durationSec: number;
state: TimelineState;
};
type ActiveStoppage = {
state: "microstop" | "macrostop";
startedAt: string;
durationSec: number;
theoreticalCycleTime: number;
};
type UploadState = { type UploadState = {
status: "idle" | "parsing" | "uploading" | "success" | "error"; status: "idle" | "parsing" | "uploading" | "success" | "error";
message?: string; message?: string;
@@ -324,7 +318,6 @@ export default function MachineDetailClient() {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [cycles, setCycles] = useState<CycleRow[]>([]); const [cycles, setCycles] = useState<CycleRow[]>([]);
const [thresholds, setThresholds] = useState<Thresholds | null>(null); const [thresholds, setThresholds] = useState<Thresholds | null>(null);
const [activeStoppage, setActiveStoppage] = useState<ActiveStoppage | null>(null);
const [open, setOpen] = useState<null | "events" | "deviation" | "impact">(null); const [open, setOpen] = useState<null | "events" | "deviation" | "impact">(null);
const fileInputRef = useRef<HTMLInputElement | null>(null); const fileInputRef = useRef<HTMLInputElement | null>(null);
const [uploadState, setUploadState] = useState<UploadState>({ status: "idle" }); const [uploadState, setUploadState] = useState<UploadState>({ status: "idle" });
@@ -372,7 +365,6 @@ export default function MachineDetailClient() {
setEventsCountAll(typeof json.eventsCountAll === "number" ? json.eventsCountAll : null); setEventsCountAll(typeof json.eventsCountAll === "number" ? json.eventsCountAll : null);
setCycles(json.cycles ?? []); setCycles(json.cycles ?? []);
setThresholds(json.thresholds ?? null); setThresholds(json.thresholds ?? null);
setActiveStoppage(json.activeStoppage ?? null);
setError(null); setError(null);
if (initial) setLoading(false); if (initial) setLoading(false);
} catch { } catch {
@@ -691,84 +683,45 @@ export default function MachineDetailClient() {
); );
} }
function MachineActivityTimeline({ function MachineActivityTimeline({ machineId }: { machineId: string | undefined }) {
cycles, const [timeline, setTimeline] = useState<RecapTimelineResponse | null>(null);
cycleTarget, const [timelineLoading, setTimelineLoading] = useState(true);
thresholds,
activeStoppage,
}: {
cycles: CycleRow[];
cycleTarget: number | null;
thresholds: Thresholds | null;
activeStoppage: ActiveStoppage | null;
}) {
const [nowMs, setNowMs] = useState(() => Date.now());
useEffect(() => { useEffect(() => {
const timer = setInterval(() => setNowMs(Date.now()), 1000); if (!machineId) return;
return () => clearInterval(timer); let alive = true;
}, []);
const timeline = useMemo(() => { async function loadTimeline() {
const rows = cycles ?? []; try {
const windowSec = rows.length < 1 ? 10800 : 3600; const res = await fetch(`/api/recap/${machineId}/timeline?range=1h`, { cache: "no-store" });
const end = nowMs; const json = await res.json().catch(() => null);
const start = end - windowSec * 1000; if (!alive || !res.ok || !json) return;
setTimeline(json as RecapTimelineResponse);
if (rows.length < 1) { } finally {
return { if (alive) setTimelineLoading(false);
windowSec,
segments: [] as TimelineSeg[],
start,
end,
};
}
const segs: TimelineSeg[] = [];
for (const cycle of rows) {
const ideal = (cycle.ideal ?? cycleTarget ?? 0) as number;
const actual = cycle.actual ?? 0;
if (!ideal || ideal <= 0 || !actual || actual <= 0) continue;
const cycleEnd = cycle.t;
const cycleStart = cycleEnd - actual * 1000;
if (cycleEnd <= start || cycleStart >= end) continue;
const segStart = Math.max(cycleStart, start);
const segEnd = Math.min(cycleEnd, end);
if (segEnd <= segStart) continue;
const state = classifyCycleDuration(actual, ideal, thresholds);
segs.push({
start: segStart,
end: segEnd,
durationSec: (segEnd - segStart) / 1000,
state,
});
}
if (activeStoppage?.startedAt) {
const stoppageStart = new Date(activeStoppage.startedAt).getTime();
const segStart = Math.max(stoppageStart, start);
const segEnd = Math.min(end, nowMs);
if (segEnd > segStart) {
segs.push({
start: segStart,
end: segEnd,
durationSec: (segEnd - segStart) / 1000,
state: activeStoppage.state,
});
} }
} }
segs.sort((a, b) => a.start - b.start); void loadTimeline();
const timer = window.setInterval(() => {
void loadTimeline();
}, 30000);
return { windowSec, segments: segs, start, end }; return () => {
}, [activeStoppage, cycles, cycleTarget, nowMs, thresholds]); alive = false;
window.clearInterval(timer);
};
}, [machineId]);
const { segments, windowSec } = timeline; const hasData = timeline?.hasData ?? false;
const startMs = timeline ? new Date(timeline.range.start).getTime() : Date.now() - 60 * 60 * 1000;
const endMs = timeline ? new Date(timeline.range.end).getTime() : Date.now();
const totalMs = Math.max(1, endMs - startMs);
const normalized = useMemo(() => {
if (!timeline || !hasData) return [] as RecapTimelineSegment[];
return normalizeTimelineSegments(timeline.segments, startMs, endMs);
}, [timeline, hasData, startMs, endMs]);
const widths = useMemo(() => computeWidths(normalized, totalMs, 1.5), [normalized, totalMs]);
return ( return (
<div className="rounded-2xl border border-white/10 bg-white/5 p-5"> <div className="rounded-2xl border border-white/10 bg-white/5 p-5">
@@ -777,49 +730,56 @@ export default function MachineDetailClient() {
<div className="text-sm font-semibold text-white">{t("machine.detail.activity.title")}</div> <div className="text-sm font-semibold text-white">{t("machine.detail.activity.title")}</div>
<div className="mt-1 text-xs text-zinc-400">{t("machine.detail.activity.subtitle")}</div> <div className="mt-1 text-xs text-zinc-400">{t("machine.detail.activity.subtitle")}</div>
</div> </div>
<div className="text-xs text-zinc-400">{windowSec}s</div> <div className="text-xs text-zinc-400">1h</div>
</div> </div>
<div className="mt-4 flex flex-wrap items-center gap-4 text-xs text-zinc-300"> <div className="mt-4 flex flex-wrap items-center gap-4 text-xs text-zinc-300">
{(["normal", "slow", "microstop", "macrostop"] as const).map((key) => ( {(["production", "mold-change", "macrostop", "microstop", "idle"] as const).map((type) => (
<div key={key} className="flex items-center gap-2"> <div key={type} className="flex items-center gap-2">
<span className="h-2.5 w-2.5 rounded-full" style={{ backgroundColor: BUCKET[key].dot }} /> <span className={`h-2.5 w-2.5 rounded-full ${TIMELINE_COLORS[type]}`} />
<span>{t(BUCKET[key].labelKey)}</span> <span>
{type === "production" ? t("recap.timeline.type.production") : null}
{type === "mold-change" ? t("recap.timeline.type.moldChange") : null}
{type === "macrostop" ? t("recap.timeline.type.macrostop") : null}
{type === "microstop" ? t("recap.timeline.type.microstop") : null}
{type === "idle" ? t("recap.timeline.type.idle") : null}
</span>
</div> </div>
))} ))}
</div> </div>
<div className="mt-4 rounded-2xl border border-white/10 bg-black/25 p-4"> <div className="mt-4 rounded-2xl border border-white/10 bg-black/25 p-4">
<div className="mb-2 flex justify-between text-[11px] text-zinc-500"> <div className="mb-2 flex justify-between text-[11px] text-zinc-500">
<span>0s</span> <span>{timelineLoading ? t("common.loading") : formatTime(startMs, locale)}</span>
<span>1h</span> <span>{formatTime(endMs, locale)}</span>
</div> </div>
<div className="flex h-14 w-full overflow-hidden rounded-2xl"> <div className="flex h-14 w-full overflow-hidden rounded-2xl">
{segments.length === 0 ? ( {!hasData ? (
<div className="flex h-full w-full items-center justify-center text-xs text-zinc-400"> <div className="flex h-full w-full items-center justify-center text-xs text-zinc-400">
{t("machine.detail.activity.noData")} {t("machine.detail.activity.noData")}
</div> </div>
) : ( ) : (
segments.map((seg, idx) => { normalized.map((segment, idx) => {
const wPct = Math.max(0, (seg.durationSec / windowSec) * 100); const widthPct = widths[idx] ?? 0;
const meta = BUCKET[seg.state]; const typeLabel =
const glow = segment.type === "production"
seg.state === "microstop" || seg.state === "macrostop" ? t("recap.timeline.type.production")
? `0 0 22px ${meta.glow}` : segment.type === "mold-change"
: `0 0 12px ${meta.glow}`; ? t("recap.timeline.type.moldChange")
: segment.type === "macrostop"
? t("recap.timeline.type.macrostop")
: segment.type === "microstop" || segment.type === "slow-cycle"
? t("recap.timeline.type.microstop")
: t("recap.timeline.type.idle");
const title = `${typeLabel} · ${formatDuration(segment.startMs, segment.endMs)}`;
return ( return (
<div <div
key={`${seg.start}-${seg.end}-${idx}`} key={`${segment.type}:${segment.startMs}:${segment.endMs}:${idx}`}
title={`${t(meta.labelKey)}: ${seg.durationSec.toFixed(1)}s`} title={title}
className="h-full" className={`h-full ${TIMELINE_COLORS[segment.type]}`}
style={{ style={{ width: `${Math.max(0, widthPct)}%` }}
width: `${wPct}%`,
background: meta.dot,
boxShadow: glow,
opacity: 0.95,
}}
/> />
); );
}) })
@@ -1106,12 +1066,19 @@ export default function MachineDetailClient() {
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4"> <div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
<div className="rounded-2xl border border-white/10 bg-white/5 p-5"> <div className="rounded-2xl border border-white/10 bg-white/5 p-5">
<div className="text-xs text-zinc-400">OEE</div> <div className="text-xs text-zinc-400">OEE</div>
<div className="mt-2 text-3xl font-bold text-emerald-300">{fmtPct(kpi?.oee)}</div> {kpi?.oee == null || Number.isNaN(kpi.oee) ? (
<div className="mt-2 text-3xl font-bold text-zinc-400"></div>
) : (
<div className="mt-2 text-3xl font-bold text-emerald-300">{fmtPct(kpi?.oee)}</div>
)}
<div className="mt-1 text-xs text-zinc-400"> <div className="mt-1 text-xs text-zinc-400">
{t("machine.detail.kpi.updated", { {t("machine.detail.kpi.updated", {
time: kpi?.ts ? timeAgo(kpi.ts) : t("common.never"), time: kpi?.ts ? timeAgo(kpi.ts) : t("common.never"),
})} })}
</div> </div>
{kpi?.oee == null || Number.isNaN(kpi.oee) ? (
<div className="mt-1 text-xs text-zinc-500">{t("recap.kpi.noData")}</div>
) : null}
</div> </div>
<div className="rounded-2xl border border-white/10 bg-white/5 p-5"> <div className="rounded-2xl border border-white/10 bg-white/5 p-5">
@@ -1131,12 +1098,7 @@ export default function MachineDetailClient() {
</div> </div>
<div className="mt-6"> <div className="mt-6">
<MachineActivityTimeline <MachineActivityTimeline machineId={machineId} />
cycles={cycles}
cycleTarget={cycleTarget}
thresholds={thresholds}
activeStoppage={activeStoppage}
/>
</div> </div>
{!screenlessMode && ( {!screenlessMode && (
<div className="mt-6 rounded-2xl border border-white/10 bg-white/5 p-5"> <div className="mt-6 rounded-2xl border border-white/10 bg-white/5 p-5">

View File

@@ -1,310 +0,0 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useI18n } from "@/lib/i18n/useI18n";
import type { RecapMachine, RecapResponse, RecapTimelineResponse } from "@/lib/recap/types";
import RecapKpiRow from "@/components/recap/RecapKpiRow";
import RecapProductionBySku from "@/components/recap/RecapProductionBySku";
import RecapDowntimeTop from "@/components/recap/RecapDowntimeTop";
import RecapWorkOrderStatus from "@/components/recap/RecapWorkOrderStatus";
import RecapMachineStatus from "@/components/recap/RecapMachineStatus";
import RecapTimeline from "@/components/recap/RecapTimeline";
type Props = {
initialData: RecapResponse;
initialFilters: {
machineId: string;
shift: string;
start: string;
end: string;
};
};
type RangeMode = "24h" | "shift" | "custom";
function toInputDate(value: string) {
if (!value) return "";
const d = new Date(value);
const pad = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
function toMinutesLabel(minutes: number | null) {
if (minutes == null || minutes <= 0) return "0";
return String(Math.round(minutes));
}
export default function RecapClient({ initialData, initialFilters }: Props) {
const { t, locale } = useI18n();
const [data, setData] = useState<RecapResponse>(initialData);
const [machineId, setMachineId] = useState(initialFilters.machineId || "");
const [shift, setShift] = useState(initialFilters.shift || "shift1");
const [customStart, setCustomStart] = useState(toInputDate(initialFilters.start));
const [customEnd, setCustomEnd] = useState(toInputDate(initialFilters.end));
const [mode, setMode] = useState<RangeMode>(() => {
if (initialFilters.shift) return "shift";
if (initialFilters.start || initialFilters.end) return "custom";
return "24h";
});
const [loading, setLoading] = useState(false);
const [timeline, setTimeline] = useState<RecapTimelineResponse | null>(null);
const shiftOptions = useMemo(
() =>
data.availableShifts?.length
? data.availableShifts
: [
{ id: "shift1", name: t("recap.shift.1") },
{ id: "shift2", name: t("recap.shift.2") },
{ id: "shift3", name: t("recap.shift.3") },
],
[data.availableShifts, t]
);
useEffect(() => {
if (mode !== "shift") return;
if (shiftOptions.some((option) => option.id === shift)) return;
setShift(shiftOptions[0]?.id ?? "shift1");
}, [mode, shift, shiftOptions]);
useEffect(() => {
let alive = true;
async function load() {
setLoading(true);
const qs = new URLSearchParams();
if (machineId) qs.set("machineId", machineId);
if (mode === "shift") qs.set("shift", shift || "shift1");
if (mode === "custom") {
if (customStart) qs.set("start", new Date(customStart).toISOString());
if (customEnd) qs.set("end", new Date(customEnd).toISOString());
}
try {
const res = await fetch(`/api/recap?${qs.toString()}`, { cache: "no-cache" });
const json = await res.json().catch(() => null);
if (!alive || !json) return;
setData(json as RecapResponse);
} finally {
if (alive) setLoading(false);
}
}
const timeout = setTimeout(load, 200);
return () => {
alive = false;
clearTimeout(timeout);
};
}, [machineId, mode, shift, customStart, customEnd]);
useEffect(() => {
async function refresh() {
const qs = new URLSearchParams();
if (machineId) qs.set("machineId", machineId);
if (mode === "shift") qs.set("shift", shift || "shift1");
if (mode === "custom") {
if (customStart) qs.set("start", new Date(customStart).toISOString());
if (customEnd) qs.set("end", new Date(customEnd).toISOString());
}
const res = await fetch(`/api/recap?${qs.toString()}`, { cache: "no-cache" });
const json = await res.json().catch(() => null);
if (json) setData(json as RecapResponse);
}
const onFocus = () => {
void refresh();
};
const interval = window.setInterval(onFocus, 60000);
window.addEventListener("focus", onFocus);
return () => {
window.clearInterval(interval);
window.removeEventListener("focus", onFocus);
};
}, [machineId, mode, shift, customStart, customEnd]);
const selectedMachine = useMemo(() => {
if (!data.machines.length) return null;
return data.machines.find((m) => m.machineId === machineId) ?? data.machines[0];
}, [data.machines, machineId]);
useEffect(() => {
let alive = true;
async function loadTimeline() {
if (mode !== "24h") {
if (alive) setTimeline(null);
return;
}
if (!selectedMachine?.machineId) {
if (alive) setTimeline(null);
return;
}
const qs = new URLSearchParams({
machineId: selectedMachine.machineId,
hours: "24",
start: data.range.start,
end: data.range.end,
});
const res = await fetch(`/api/recap/timeline?${qs.toString()}`, { cache: "no-cache" });
const json = await res.json().catch(() => null);
if (!alive) return;
if (res.ok && json && json.segments) {
setTimeline(json as RecapTimelineResponse);
} else {
setTimeline(null);
}
}
void loadTimeline();
return () => {
alive = false;
};
}, [mode, selectedMachine?.machineId, data.range.start, data.range.end]);
const fleet = useMemo(() => {
let good = 0;
let scrap = 0;
let stops = 0;
let oeeSum = 0;
let oeeCount = 0;
for (const m of data.machines) {
good += m.production.goodParts;
scrap += m.production.scrapParts;
stops += m.downtime.stopsCount;
if (m.oee.avg != null) {
oeeSum += m.oee.avg;
oeeCount += 1;
}
}
return {
oeeAvg: oeeCount ? oeeSum / oeeCount : null,
good,
scrap,
stops,
};
}, [data.machines]);
const bannerMold = selectedMachine?.workOrders.moldChangeInProgress;
const moldStartMs = selectedMachine?.workOrders.moldChangeStartMs ?? null;
const moldStartLabel = moldStartMs
? new Date(moldStartMs).toLocaleTimeString(locale, { hour: "2-digit", minute: "2-digit" })
: "--:--";
const moldElapsedMin = moldStartMs ? Math.max(0, Math.floor((Date.now() - moldStartMs) / 60000)) : null;
const bannerStop = (selectedMachine?.downtime.ongoingStopMin ?? 0) > 0;
return (
<div className="p-4 sm:p-6">
<div className="mb-4 rounded-2xl border border-white/10 bg-black/40 p-4">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div>
<h1 className="text-2xl font-semibold text-white">{t("recap.title")}</h1>
<p className="text-sm text-zinc-400">
{t("recap.subtitle")} · {new Date(data.range.start).toLocaleString(locale)} - {new Date(data.range.end).toLocaleString(locale)}
</p>
</div>
<div className="flex flex-wrap gap-2 text-sm">
<select
value={machineId}
onChange={(event) => setMachineId(event.target.value)}
className="rounded-xl border border-white/10 bg-black/40 px-3 py-2 text-zinc-200"
>
<option value="">{t("recap.allMachines")}</option>
{data.machines.map((m) => (
<option key={m.machineId} value={m.machineId}>
{m.machineName}
</option>
))}
</select>
<select
value={mode}
onChange={(event) => setMode(event.target.value as RangeMode)}
className="rounded-xl border border-white/10 bg-black/40 px-3 py-2 text-zinc-200"
>
<option value="24h">24h</option>
<option value="shift">{t("recap.range.shift")}</option>
<option value="custom">{t("recap.range.custom")}</option>
</select>
{mode === "shift" ? (
<select
value={shift}
onChange={(event) => setShift(event.target.value)}
className="rounded-xl border border-white/10 bg-black/40 px-3 py-2 text-zinc-200"
>
{shiftOptions.map((option) => (
<option key={option.id} value={option.id}>
{option.name}
</option>
))}
</select>
) : null}
{mode === "custom" ? (
<>
<input
type="datetime-local"
value={customStart}
onChange={(event) => setCustomStart(event.target.value)}
className="rounded-xl border border-white/10 bg-black/40 px-3 py-2 text-zinc-200"
/>
<input
type="datetime-local"
value={customEnd}
onChange={(event) => setCustomEnd(event.target.value)}
className="rounded-xl border border-white/10 bg-black/40 px-3 py-2 text-zinc-200"
/>
</>
) : null}
</div>
</div>
</div>
{bannerMold ? (
<div className="mb-3 rounded-2xl border border-amber-500/40 bg-amber-500/10 p-3 text-sm text-amber-300">
{t("recap.banner.mold")} {moldStartLabel}
{moldElapsedMin != null ? ` · ${moldElapsedMin} min` : ""}
</div>
) : null}
{bannerStop ? (
<div className="mb-3 rounded-2xl border border-red-500/40 bg-red-500/10 p-3 text-sm text-red-300">
{t("recap.banner.stopped", { minutes: toMinutesLabel(selectedMachine?.downtime.ongoingStopMin ?? null) })}
</div>
) : null}
{loading ? <div className="mb-3 text-sm text-zinc-400">{t("common.loading")}</div> : null}
{timeline ? (
<RecapTimeline
rangeStart={timeline.range.start}
rangeEnd={timeline.range.end}
segments={timeline.segments}
locale={locale}
/>
) : null}
<RecapKpiRow oeeAvg={fleet.oeeAvg} goodParts={fleet.good} totalStops={fleet.stops} scrapParts={fleet.scrap} />
<div className="mt-4 grid grid-cols-1 gap-4 xl:grid-cols-2">
<RecapProductionBySku rows={selectedMachine?.production.bySku ?? []} />
<RecapDowntimeTop rows={selectedMachine?.downtime.topReasons ?? []} />
</div>
<div className="mt-4 grid grid-cols-1 gap-4 xl:grid-cols-2">
<RecapWorkOrderStatus
workOrders={
selectedMachine?.workOrders ?? {
completed: [],
active: null,
moldChangeInProgress: false,
moldChangeStartMs: null,
}
}
/>
<RecapMachineStatus machine={selectedMachine as RecapMachine | null} />
</div>
</div>
);
}

View File

@@ -0,0 +1,153 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useI18n } from "@/lib/i18n/useI18n";
import type { RecapMachineStatus, RecapSummaryResponse } from "@/lib/recap/types";
import RecapMachineCard from "@/components/recap/RecapMachineCard";
type Props = {
initialData: RecapSummaryResponse;
};
function statusLabel(status: RecapMachineStatus, t: (key: string) => string) {
if (status === "running") return t("recap.status.running");
if (status === "mold-change") return t("recap.status.moldChange");
if (status === "stopped") return t("recap.status.stopped");
return t("recap.status.offline");
}
export default function RecapGridClient({ initialData }: Props) {
const { t } = useI18n();
const [data, setData] = useState<RecapSummaryResponse>(initialData);
const [loading, setLoading] = useState(false);
const [locationFilter, setLocationFilter] = useState("all");
const [statusFilter, setStatusFilter] = useState<"all" | RecapMachineStatus>("all");
const [nowMs, setNowMs] = useState(() => Date.now());
useEffect(() => {
const timer = window.setInterval(() => setNowMs(Date.now()), 1000);
return () => window.clearInterval(timer);
}, []);
useEffect(() => {
let alive = true;
async function refresh() {
setLoading(true);
try {
const res = await fetch(`/api/recap/summary?hours=${data.range.hours}`, { cache: "no-store" });
const json = await res.json().catch(() => null);
if (!alive || !json || !res.ok) return;
setData(json as RecapSummaryResponse);
} finally {
if (alive) setLoading(false);
}
}
const onFocus = () => {
void refresh();
};
const interval = window.setInterval(onFocus, 60000);
window.addEventListener("focus", onFocus);
return () => {
alive = false;
window.clearInterval(interval);
window.removeEventListener("focus", onFocus);
};
}, [data.range.hours]);
const locationOptions = useMemo(() => {
const set = new Set<string>();
for (const machine of data.machines) {
if (machine.location) set.add(machine.location);
}
return [...set].sort((a, b) => a.localeCompare(b));
}, [data.machines]);
const filteredMachines = useMemo(() => {
return data.machines.filter((machine) => {
if (locationFilter !== "all" && machine.location !== locationFilter) return false;
if (statusFilter !== "all" && machine.status !== statusFilter) return false;
return true;
});
}, [data.machines, locationFilter, statusFilter]);
const generatedAtMs = new Date(data.generatedAt).getTime();
const freshAgeSec = Number.isFinite(generatedAtMs) ? Math.max(0, Math.floor((nowMs - generatedAtMs) / 1000)) : null;
return (
<div className="p-4 sm:p-6">
<div className="mb-4 rounded-2xl border border-white/10 bg-black/40 p-4">
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<h1 className="text-2xl font-semibold text-white">{t("recap.grid.title")}</h1>
<p className="text-sm text-zinc-400">{t("recap.grid.subtitle")}</p>
{freshAgeSec != null ? (
<p className="mt-1 text-xs text-zinc-500">{t("recap.grid.updatedAgo", { sec: freshAgeSec })}</p>
) : null}
</div>
<div className="flex flex-wrap gap-2 text-sm">
<select
value={locationFilter}
onChange={(event) => setLocationFilter(event.target.value)}
className="rounded-xl border border-white/10 bg-black/40 px-3 py-2 text-zinc-200"
>
<option value="all">{t("recap.filter.allLocations")}</option>
{locationOptions.map((location) => (
<option key={location} value={location}>
{location}
</option>
))}
</select>
<select
value={statusFilter}
onChange={(event) => setStatusFilter(event.target.value as "all" | RecapMachineStatus)}
className="rounded-xl border border-white/10 bg-black/40 px-3 py-2 text-zinc-200"
>
<option value="all">{t("recap.filter.allStatuses")}</option>
{(["running", "mold-change", "stopped", "offline"] as const).map((status) => (
<option key={status} value={status}>
{statusLabel(status, t)}
</option>
))}
</select>
</div>
</div>
</div>
{loading && data.machines.length === 0 ? (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
{Array.from({ length: 6 }).map((_, idx) => (
<div key={idx} className="h-[220px] animate-pulse rounded-2xl border border-white/10 bg-white/5" />
))}
</div>
) : null}
{loading && data.machines.length > 0 ? (
<div className="mb-3 text-xs text-zinc-500">{t("common.loading")}</div>
) : null}
{filteredMachines.length === 0 ? (
<div className="rounded-2xl border border-white/10 bg-black/30 p-4 text-sm text-zinc-400">
{t("recap.grid.empty")}
</div>
) : (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
{filteredMachines.map((machine) => (
<RecapMachineCard
key={machine.machineId}
machine={machine}
rangeStart={data.range.start}
rangeEnd={data.range.end}
/>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,213 @@
"use client";
import Link from "next/link";
import { useEffect, useState, useTransition } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useI18n } from "@/lib/i18n/useI18n";
import type { RecapDetailResponse, RecapRangeMode, RecapTimelineResponse } from "@/lib/recap/types";
import RecapBanners from "@/components/recap/RecapBanners";
import RecapKpiRow from "@/components/recap/RecapKpiRow";
import RecapProductionBySku from "@/components/recap/RecapProductionBySku";
import RecapDowntimeTop from "@/components/recap/RecapDowntimeTop";
import RecapWorkOrders from "@/components/recap/RecapWorkOrders";
import RecapMachineStatus from "@/components/recap/RecapMachineStatus";
import RecapFullTimeline from "@/components/recap/RecapFullTimeline";
type Props = {
machineId: string;
initialData: RecapDetailResponse;
};
function toInputDate(value: string) {
const d = new Date(value);
const pad = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
function normalizeInputDate(value: string) {
const d = new Date(value);
if (!Number.isFinite(d.getTime())) return null;
return d.toISOString();
}
export default function RecapDetailClient({ machineId, initialData }: Props) {
const { t, locale } = useI18n();
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const [isPending, startTransition] = useTransition();
const [timeline, setTimeline] = useState<RecapTimelineResponse | null>(null);
const [nowMs, setNowMs] = useState(() => Date.now());
const [customStart, setCustomStart] = useState(toInputDate(initialData.range.start));
const [customEnd, setCustomEnd] = useState(toInputDate(initialData.range.end));
const selectedRange = (searchParams.get("range") as RecapRangeMode | null) ?? initialData.range.mode;
function pushRange(nextRange: RecapRangeMode, start?: string, end?: string) {
const params = new URLSearchParams(searchParams.toString());
params.set("range", nextRange);
if (nextRange === "custom" && start && end) {
params.set("start", start);
params.set("end", end);
} else {
params.delete("start");
params.delete("end");
}
startTransition(() => {
router.push(`${pathname}?${params.toString()}`);
});
}
function applyCustomRange() {
const start = normalizeInputDate(customStart);
const end = normalizeInputDate(customEnd);
if (!start || !end || end <= start) return;
pushRange("custom", start, end);
}
const machine = initialData.machine;
const generatedAtMs = new Date(initialData.generatedAt).getTime();
const freshAgeSec = Number.isFinite(generatedAtMs) ? Math.max(0, Math.floor((nowMs - generatedAtMs) / 1000)) : null;
const timelineStart = timeline?.range.start ?? initialData.range.start;
const timelineEnd = timeline?.range.end ?? initialData.range.end;
const timelineSegments = timeline?.segments ?? machine.timeline;
const timelineHasData = timeline?.hasData ?? true;
useEffect(() => {
let alive = true;
async function loadTimeline() {
try {
const params = new URLSearchParams({
start: initialData.range.start,
end: initialData.range.end,
});
const res = await fetch(`/api/recap/${machineId}/timeline?${params.toString()}`, { cache: "no-store" });
const json = await res.json().catch(() => null);
if (!alive || !res.ok || !json) return;
setTimeline(json as RecapTimelineResponse);
} catch {
}
}
void loadTimeline();
return () => {
alive = false;
};
}, [initialData.range.end, initialData.range.start, machineId]);
useEffect(() => {
const timer = window.setInterval(() => setNowMs(Date.now()), 1000);
return () => window.clearInterval(timer);
}, []);
return (
<div className="p-4 sm:p-6">
<div className="mb-4 flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div>
<Link href="/recap" className="text-sm text-zinc-400 hover:text-zinc-200">
{`${t("recap.detail.back")}`}
</Link>
<h1 className="mt-1 text-2xl font-semibold text-white">{machine.name || machineId}</h1>
<div className="text-sm text-zinc-400">{machine.location || t("common.na")}</div>
{freshAgeSec != null ? (
<div className="mt-1 text-xs text-zinc-500">{t("recap.grid.updatedAgo", { sec: freshAgeSec })}</div>
) : null}
</div>
<div className="flex flex-wrap gap-2 text-sm">
{(["24h", "shift", "yesterday", "custom"] as const).map((range) => (
<button
key={range}
type="button"
onClick={() => {
if (range === "custom") {
pushRange("custom", normalizeInputDate(customStart) ?? undefined, normalizeInputDate(customEnd) ?? undefined);
return;
}
pushRange(range);
}}
className={`rounded-xl border px-3 py-2 ${
selectedRange === range
? "border-emerald-300/60 bg-emerald-500/20 text-emerald-100"
: "border-white/10 bg-black/40 text-zinc-200"
}`}
>
{range === "24h" ? t("recap.range.24h") : null}
{range === "shift" ? t("recap.range.shiftCurrent") : null}
{range === "yesterday" ? t("recap.range.yesterday") : null}
{range === "custom" ? t("recap.range.custom") : null}
</button>
))}
</div>
</div>
{selectedRange === "custom" ? (
<div className="mb-4 flex flex-wrap gap-2 text-sm">
<input
type="datetime-local"
value={customStart}
onChange={(event) => setCustomStart(event.target.value)}
className="rounded-xl border border-white/10 bg-black/40 px-3 py-2 text-zinc-200"
/>
<input
type="datetime-local"
value={customEnd}
onChange={(event) => setCustomEnd(event.target.value)}
className="rounded-xl border border-white/10 bg-black/40 px-3 py-2 text-zinc-200"
/>
<button
type="button"
onClick={applyCustomRange}
className="rounded-xl border border-emerald-300/50 bg-emerald-500/20 px-3 py-2 text-emerald-100"
>
{t("recap.range.apply")}
</button>
</div>
) : null}
{isPending ? <div className="mb-3 text-xs text-zinc-500">{t("common.loading")}</div> : null}
<div className="mb-4">
<RecapBanners
moldChangeStartMs={machine.moldChange?.active ? machine.moldChange.startMs : null}
offlineForMin={machine.offlineForMin}
ongoingStopMin={machine.ongoingStopMin}
/>
</div>
<RecapKpiRow
oeeAvg={machine.oee}
goodParts={machine.goodParts}
totalStops={Math.round(machine.stopMinutes)}
scrapParts={machine.scrap}
/>
<div className="mt-4">
<RecapFullTimeline
rangeStart={timelineStart}
rangeEnd={timelineEnd}
segments={timelineSegments}
hasData={timelineHasData}
locale={locale}
/>
</div>
<div className="mt-4 grid grid-cols-1 gap-4 xl:grid-cols-2">
<RecapProductionBySku rows={machine.productionBySku} />
<RecapDowntimeTop rows={machine.downtimeTop} />
</div>
<div className="mt-4">
<RecapWorkOrders workOrders={machine.workOrders} />
</div>
<div className="mt-4">
<RecapMachineStatus heartbeat={machine.heartbeat} />
</div>
</div>
);
}

View File

@@ -0,0 +1,13 @@
export default function LoadingRecapDetail() {
return (
<div className="p-4 sm:p-6">
<div className="h-16 animate-pulse rounded-2xl border border-white/10 bg-black/40" />
<div className="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
{Array.from({ length: 4 }).map((_, index) => (
<div key={index} className="h-24 animate-pulse rounded-2xl border border-white/10 bg-black/30" />
))}
</div>
<div className="mt-4 h-48 animate-pulse rounded-2xl border border-white/10 bg-black/30" />
</div>
);
}

View File

@@ -0,0 +1,35 @@
import { notFound, redirect } from "next/navigation";
import { requireSession } from "@/lib/auth/requireSession";
import { getRecapMachineDetailCached, parseRecapDetailRangeInput } from "@/lib/recap/redesign";
import RecapDetailClient from "./RecapDetailClient";
export default async function RecapMachineDetailPage({
params,
searchParams,
}: {
params: Promise<{ machineId: string }>;
searchParams?: Promise<Record<string, string | string[] | undefined>>;
}) {
const session = await requireSession();
const { machineId } = await params;
if (!session) redirect(`/login?next=/recap/${machineId}`);
const rawSearchParams = (await searchParams) ?? {};
const input = parseRecapDetailRangeInput(rawSearchParams);
const initialData = await getRecapMachineDetailCached({
orgId: session.orgId,
machineId,
input,
});
if (!initialData) notFound();
return (
<RecapDetailClient
key={`${machineId}:${initialData.range.mode}:${initialData.range.start}:${initialData.range.end}`}
machineId={machineId}
initialData={initialData}
/>
);
}

View File

@@ -0,0 +1,12 @@
export default function LoadingRecapGrid() {
return (
<div className="p-4 sm:p-6">
<div className="mb-4 h-24 animate-pulse rounded-2xl border border-white/10 bg-black/40" />
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
{Array.from({ length: 6 }).map((_, index) => (
<div key={index} className="h-[220px] animate-pulse rounded-2xl border border-white/10 bg-white/5" />
))}
</div>
</div>
);
}

View File

@@ -1,46 +1,16 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { requireSession } from "@/lib/auth/requireSession"; import { requireSession } from "@/lib/auth/requireSession";
import { getRecapDataCached, parseRecapQuery } from "@/lib/recap/getRecapData"; import { getRecapSummaryCached } from "@/lib/recap/redesign";
import RecapClient from "./RecapClient"; import RecapGridClient from "./RecapGridClient";
export default async function RecapPage({ export default async function RecapPage() {
searchParams,
}: {
searchParams?: Promise<Record<string, string | string[] | undefined>>;
}) {
const session = await requireSession(); const session = await requireSession();
if (!session) redirect("/login?next=/recap"); if (!session) redirect("/login?next=/recap");
const params = (await searchParams) ?? {}; const initialData = await getRecapSummaryCached({
const getParam = (key: string) => {
const value = params[key];
return Array.isArray(value) ? value[0] : value;
};
const parsed = parseRecapQuery({
machineId: getParam("machineId"),
start: getParam("start"),
end: getParam("end"),
shift: getParam("shift"),
});
const initialData = await getRecapDataCached({
orgId: session.orgId, orgId: session.orgId,
machineId: parsed.machineId, hours: 24,
start: parsed.start ?? undefined,
end: parsed.end ?? undefined,
shift: parsed.shift ?? undefined,
}); });
return ( return <RecapGridClient initialData={initialData} />;
<RecapClient
initialData={initialData}
initialFilters={{
machineId: parsed.machineId ?? "",
shift: parsed.shift ?? "",
start: parsed.start?.toISOString() ?? "",
end: parsed.end?.toISOString() ?? "",
}}
/>
);
} }

View File

@@ -0,0 +1,37 @@
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { requireSession } from "@/lib/auth/requireSession";
import { getRecapMachineDetailCached, parseRecapDetailRangeInput } from "@/lib/recap/redesign";
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ machineId: string }> }
) {
const session = await requireSession();
if (!session) {
return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
}
const { machineId } = await params;
if (!machineId) {
return NextResponse.json({ ok: false, error: "machineId is required" }, { status: 400 });
}
const url = new URL(req.url);
const input = parseRecapDetailRangeInput(url.searchParams);
const detail = await getRecapMachineDetailCached({
orgId: session.orgId,
machineId,
input,
});
if (!detail) {
return NextResponse.json({ ok: false, error: "Machine not found" }, { status: 404 });
}
return NextResponse.json(detail, {
headers: {
"Cache-Control": "private, max-age=60, stale-while-revalidate=60",
},
});
}

View File

@@ -0,0 +1,42 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { requireSession } from "@/lib/auth/requireSession";
import { prisma } from "@/lib/prisma";
import { getRecapTimelineForMachine, parseRecapTimelineRange } from "@/lib/recap/timelineApi";
function bad(status: number, error: string) {
return NextResponse.json({ ok: false, error }, { status });
}
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ machineId: string }> }
) {
const session = await requireSession();
if (!session) return bad(401, "Unauthorized");
const { machineId } = await params;
if (!machineId) return bad(400, "machineId is required");
const machine = await prisma.machine.findFirst({
where: { id: machineId, orgId: session.orgId },
select: { id: true },
});
if (!machine) return bad(404, "Machine not found");
const url = new URL(req.url);
const { start, end, maxSegments } = parseRecapTimelineRange(url.searchParams);
const response = await getRecapTimelineForMachine({
orgId: session.orgId,
machineId,
start,
end,
maxSegments,
});
return NextResponse.json(response, {
headers: {
"Cache-Control": "private, max-age=60, stale-while-revalidate=60",
},
});
}

View File

@@ -0,0 +1,21 @@
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { requireSession } from "@/lib/auth/requireSession";
import { getRecapSummaryCached, parseRecapSummaryHours } from "@/lib/recap/redesign";
export async function GET(req: NextRequest) {
const session = await requireSession();
if (!session) {
return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
}
const url = new URL(req.url);
const hours = parseRecapSummaryHours(url.searchParams.get("hours"));
const summary = await getRecapSummaryCached({ orgId: session.orgId, hours });
return NextResponse.json(summary, {
headers: {
"Cache-Control": "private, max-age=60, stale-while-revalidate=60",
},
});
}

View File

@@ -2,178 +2,12 @@ import { NextResponse } from "next/server";
import type { NextRequest } from "next/server"; import type { NextRequest } from "next/server";
import { requireSession } from "@/lib/auth/requireSession"; import { requireSession } from "@/lib/auth/requireSession";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import type { RecapTimelineResponse, RecapTimelineSegment } from "@/lib/recap/types"; import { getRecapTimelineForMachine, parseRecapTimelineRange } from "@/lib/recap/timelineApi";
type RawSegment =
| {
type: "production";
startMs: number;
endMs: number;
priority: number;
workOrderId: string | null;
sku: string | null;
label: string;
}
| {
type: "mold-change";
startMs: number;
endMs: number;
priority: number;
fromMoldId: string | null;
toMoldId: string | null;
durationSec: number;
label: string;
}
| {
type: "macrostop" | "microstop" | "slow-cycle";
startMs: number;
endMs: number;
priority: number;
reason: string | null;
durationSec: number;
label: string;
};
const EVENT_TYPES = ["mold-change", "macrostop", "microstop", "slow-cycle"] as const;
type TimelineEventType = (typeof EVENT_TYPES)[number];
const ACTIVE_STALE_MS = 2 * 60 * 1000;
const PRIORITY: Record<string, number> = {
idle: 0,
production: 1,
microstop: 2,
"slow-cycle": 2,
macrostop: 3,
"mold-change": 4,
};
function bad(status: number, error: string) { function bad(status: number, error: string) {
return NextResponse.json({ ok: false, error }, { status }); return NextResponse.json({ ok: false, error }, { status });
} }
function safeNum(value: unknown) {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string" && value.trim()) {
const parsed = Number(value);
if (Number.isFinite(parsed)) return parsed;
}
return null;
}
function normalizeToken(value: unknown) {
return String(value ?? "").trim();
}
function dedupeByKey<T>(rows: T[], keyFn: (row: T) => string) {
const seen = new Set<string>();
const out: T[] = [];
for (const row of rows) {
const key = keyFn(row);
if (seen.has(key)) continue;
seen.add(key);
out.push(row);
}
return out;
}
function parseHours(raw: string | null) {
const value = Math.trunc(Number(raw || "24"));
if (!Number.isFinite(value)) return 24;
return Math.max(1, Math.min(72, value));
}
function parseDateInput(raw: string | null) {
if (!raw) return null;
const asNum = Number(raw);
if (Number.isFinite(asNum)) {
const d = new Date(asNum);
return Number.isFinite(d.getTime()) ? d : null;
}
const d = new Date(raw);
return Number.isFinite(d.getTime()) ? d : null;
}
function extractData(value: unknown) {
let parsed: unknown = value;
if (typeof value === "string") {
try {
parsed = JSON.parse(value);
} catch {
parsed = null;
}
}
const record = typeof parsed === "object" && parsed && !Array.isArray(parsed) ? (parsed as Record<string, unknown>) : {};
const nested = record.data;
if (typeof nested === "object" && nested && !Array.isArray(nested)) return nested as Record<string, unknown>;
return record;
}
function clampToRange(startMs: number, endMs: number, rangeStartMs: number, rangeEndMs: number) {
const clampedStart = Math.max(rangeStartMs, Math.min(rangeEndMs, startMs));
const clampedEnd = Math.max(rangeStartMs, Math.min(rangeEndMs, endMs));
if (clampedEnd <= clampedStart) return null;
return { startMs: clampedStart, endMs: clampedEnd };
}
function eventIncidentKey(eventType: string, data: Record<string, unknown>, fallbackTsMs: number) {
const key = String(data.incidentKey ?? data.incident_key ?? "").trim();
if (key) return key;
const alertId = String(data.alert_id ?? data.alertId ?? "").trim();
if (alertId) return `${eventType}:${alertId}`;
const startMs = safeNum(data.start_ms) ?? safeNum(data.startMs);
if (startMs != null) return `${eventType}:${Math.trunc(startMs)}`;
return `${eventType}:${fallbackTsMs}`;
}
function reasonLabelFromData(data: Record<string, unknown>) {
const direct =
String(data.reasonText ?? data.reason_label ?? data.reasonLabel ?? "").trim() || null;
if (direct) return direct;
const reason = data.reason;
if (typeof reason === "string") {
const text = reason.trim();
return text || null;
}
if (reason && typeof reason === "object" && !Array.isArray(reason)) {
const rec = reason as Record<string, unknown>;
const reasonText =
String(rec.reasonText ?? rec.reason_label ?? rec.reasonLabel ?? "").trim() || null;
if (reasonText) return reasonText;
const detail =
String(rec.detailLabel ?? rec.detail_label ?? rec.detailId ?? rec.detail_id ?? "").trim() || null;
const category =
String(rec.categoryLabel ?? rec.category_label ?? rec.categoryId ?? rec.category_id ?? "").trim() || null;
if (category && detail) return `${category} > ${detail}`;
if (detail) return detail;
if (category) return category;
}
return null;
}
function labelForStop(type: "macrostop" | "microstop" | "slow-cycle", reason: string | null) {
if (type === "macrostop") return reason ? `Paro: ${reason}` : "Paro";
if (type === "microstop") return reason ? `Microparo: ${reason}` : "Microparo";
return "Ciclo lento";
}
function isEquivalent(a: RecapTimelineSegment, b: RecapTimelineSegment) {
if (a.type !== b.type) return false;
if (a.type === "idle" && b.type === "idle") return true;
if (a.type === "production" && b.type === "production") {
return a.workOrderId === b.workOrderId && a.sku === b.sku && a.label === b.label;
}
if (a.type === "mold-change" && b.type === "mold-change") {
return a.fromMoldId === b.fromMoldId && a.toMoldId === b.toMoldId;
}
if (
(a.type === "macrostop" || a.type === "microstop" || a.type === "slow-cycle") &&
(b.type === "macrostop" || b.type === "microstop" || b.type === "slow-cycle")
) {
return a.type === b.type && a.reason === b.reason;
}
return false;
}
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
const session = await requireSession(); const session = await requireSession();
if (!session) return bad(401, "Unauthorized"); if (!session) return bad(401, "Unauthorized");
@@ -181,9 +15,6 @@ export async function GET(req: NextRequest) {
const url = new URL(req.url); const url = new URL(req.url);
const machineId = url.searchParams.get("machineId"); const machineId = url.searchParams.get("machineId");
if (!machineId) return bad(400, "machineId is required"); if (!machineId) return bad(400, "machineId is required");
const hours = parseHours(url.searchParams.get("hours"));
const startParam = parseDateInput(url.searchParams.get("start"));
const endParam = parseDateInput(url.searchParams.get("end"));
const machine = await prisma.machine.findFirst({ const machine = await prisma.machine.findFirst({
where: { id: machineId, orgId: session.orgId }, where: { id: machineId, orgId: session.orgId },
@@ -191,271 +22,18 @@ export async function GET(req: NextRequest) {
}); });
if (!machine) return bad(404, "Machine not found"); if (!machine) return bad(404, "Machine not found");
const end = endParam ?? new Date(); const { start, end, maxSegments } = parseRecapTimelineRange(url.searchParams);
const start = startParam && startParam < end ? startParam : new Date(end.getTime() - hours * 60 * 60 * 1000); const response = await getRecapTimelineForMachine({
const rangeStartMs = start.getTime(); orgId: session.orgId,
const rangeEndMs = end.getTime(); machineId,
start,
end,
maxSegments,
});
const [cycles, events] = await Promise.all([ return NextResponse.json(response, {
prisma.machineCycle.findMany({ headers: {
where: { "Cache-Control": "private, max-age=60, stale-while-revalidate=60",
orgId: session.orgId,
machineId,
ts: { gte: start, lte: end },
},
orderBy: { ts: "asc" },
select: {
ts: true,
cycleCount: true,
actualCycleTime: true,
workOrderId: true,
sku: true,
},
}),
prisma.machineEvent.findMany({
where: {
orgId: session.orgId,
machineId,
eventType: { in: EVENT_TYPES as unknown as string[] },
ts: { gte: new Date(start.getTime() - 24 * 60 * 60 * 1000), lte: end },
},
orderBy: { ts: "asc" },
select: {
ts: true,
eventType: true,
data: true,
},
}),
]);
const dedupedCycles = dedupeByKey(
cycles,
(cycle) =>
`${cycle.ts.getTime()}:${safeNum(cycle.cycleCount) ?? "na"}:${normalizeToken(cycle.workOrderId).toUpperCase()}:${normalizeToken(cycle.sku).toUpperCase()}:${safeNum(cycle.actualCycleTime) ?? "na"}`
);
const rawSegments: RawSegment[] = [];
let currentProduction: RawSegment | null = null;
for (const cycle of dedupedCycles) {
if (!cycle.workOrderId) continue;
const cycleStartMs = cycle.ts.getTime();
const cycleDurationMs = Math.max(1000, Math.min(600000, Math.trunc((safeNum(cycle.actualCycleTime) ?? 1) * 1000)));
const cycleEndMs = cycleStartMs + cycleDurationMs;
if (
currentProduction &&
currentProduction.type === "production" &&
currentProduction.workOrderId === cycle.workOrderId &&
currentProduction.sku === cycle.sku &&
cycleStartMs <= currentProduction.endMs + 5 * 60 * 1000
) {
currentProduction.endMs = Math.max(currentProduction.endMs, cycleEndMs);
continue;
}
if (currentProduction) rawSegments.push(currentProduction);
currentProduction = {
type: "production",
startMs: cycleStartMs,
endMs: cycleEndMs,
priority: PRIORITY.production,
workOrderId: cycle.workOrderId,
sku: cycle.sku,
label: cycle.workOrderId,
};
}
if (currentProduction) rawSegments.push(currentProduction);
const eventEpisodes = new Map<
string,
{
type: "mold-change" | "macrostop" | "microstop" | "slow-cycle";
firstTsMs: number;
lastTsMs: number;
startMs: number | null;
endMs: number | null;
durationSec: number | null;
statusActive: boolean;
statusResolved: boolean;
reason: string | null;
fromMoldId: string | null;
toMoldId: string | null;
}
>();
for (const event of events) {
const eventType = String(event.eventType || "").toLowerCase() as TimelineEventType;
if (!EVENT_TYPES.includes(eventType)) continue;
const data = extractData(event.data);
const tsMs = event.ts.getTime();
const key = eventIncidentKey(eventType, data, tsMs);
const status = String(data.status ?? "").trim().toLowerCase();
const episode = eventEpisodes.get(key) ?? {
type: eventType,
firstTsMs: tsMs,
lastTsMs: tsMs,
startMs: null,
endMs: null,
durationSec: null,
statusActive: false,
statusResolved: false,
reason: null,
fromMoldId: null,
toMoldId: null,
};
episode.firstTsMs = Math.min(episode.firstTsMs, tsMs);
episode.lastTsMs = Math.max(episode.lastTsMs, tsMs);
const startMs = safeNum(data.start_ms) ?? safeNum(data.startMs);
const endMs = safeNum(data.end_ms) ?? safeNum(data.endMs);
const durationSec =
safeNum(data.duration_sec) ??
safeNum(data.stoppage_duration_seconds) ??
safeNum(data.stop_duration_seconds) ??
safeNum(data.duration_seconds);
if (startMs != null) episode.startMs = episode.startMs == null ? startMs : Math.min(episode.startMs, startMs);
if (endMs != null) episode.endMs = episode.endMs == null ? endMs : Math.max(episode.endMs, endMs);
if (durationSec != null) episode.durationSec = Math.max(0, Math.trunc(durationSec));
if (status === "active") episode.statusActive = true;
if (status === "resolved") episode.statusResolved = true;
const reason = reasonLabelFromData(data);
if (reason) episode.reason = reason;
const fromMoldId = String(data.from_mold_id ?? data.fromMoldId ?? "").trim() || null;
const toMoldId = String(data.to_mold_id ?? data.toMoldId ?? "").trim() || null;
if (fromMoldId) episode.fromMoldId = fromMoldId;
if (toMoldId) episode.toMoldId = toMoldId;
eventEpisodes.set(key, episode);
}
for (const episode of eventEpisodes.values()) {
const startMs = Math.trunc(episode.startMs ?? episode.firstTsMs);
let endMs = Math.trunc(episode.endMs ?? episode.lastTsMs);
if (episode.statusActive && !episode.statusResolved) {
const isFreshActive = rangeEndMs - episode.lastTsMs <= ACTIVE_STALE_MS;
endMs = isFreshActive ? rangeEndMs : episode.lastTsMs;
} else if (endMs <= startMs && episode.durationSec != null && episode.durationSec > 0) {
endMs = startMs + episode.durationSec * 1000;
}
if (endMs <= startMs) continue;
if (episode.type === "mold-change") {
rawSegments.push({
type: "mold-change",
startMs,
endMs,
priority: PRIORITY["mold-change"],
fromMoldId: episode.fromMoldId,
toMoldId: episode.toMoldId,
durationSec: Math.max(0, Math.trunc((endMs - startMs) / 1000)),
label: episode.toMoldId ? `Cambio molde ${episode.toMoldId}` : "Cambio molde",
});
continue;
}
const stopType = episode.type;
rawSegments.push({
type: stopType,
startMs,
endMs,
priority: PRIORITY[stopType],
reason: episode.reason,
durationSec: Math.max(0, Math.trunc((endMs - startMs) / 1000)),
label: labelForStop(stopType, episode.reason),
});
}
const clipped = rawSegments
.map((segment) => {
const range = clampToRange(segment.startMs, segment.endMs, rangeStartMs, rangeEndMs);
return range ? { ...segment, ...range } : null;
})
.filter((segment): segment is RawSegment => !!segment);
const boundaries = new Set<number>([rangeStartMs, rangeEndMs]);
for (const segment of clipped) {
boundaries.add(segment.startMs);
boundaries.add(segment.endMs);
}
const orderedBoundaries = Array.from(boundaries).sort((a, b) => a - b);
const timeline: RecapTimelineSegment[] = [];
for (let i = 0; i < orderedBoundaries.length - 1; i += 1) {
const intervalStart = orderedBoundaries[i];
const intervalEnd = orderedBoundaries[i + 1];
if (intervalEnd <= intervalStart) continue;
const covering = clipped
.filter((segment) => segment.startMs < intervalEnd && segment.endMs > intervalStart)
.sort((a, b) => b.priority - a.priority || b.startMs - a.startMs);
const winner = covering[0];
if (!winner) {
timeline.push({ type: "idle", startMs: intervalStart, endMs: intervalEnd, label: "Idle" });
continue;
}
if (winner.type === "production") {
timeline.push({
type: "production",
startMs: intervalStart,
endMs: intervalEnd,
workOrderId: winner.workOrderId,
sku: winner.sku,
label: winner.label,
});
continue;
}
if (winner.type === "mold-change") {
timeline.push({
type: "mold-change",
startMs: intervalStart,
endMs: intervalEnd,
fromMoldId: winner.fromMoldId,
toMoldId: winner.toMoldId,
durationSec: Math.max(0, Math.trunc((intervalEnd - intervalStart) / 1000)),
label: winner.label,
});
continue;
}
timeline.push({
type: winner.type,
startMs: intervalStart,
endMs: intervalEnd,
reason: winner.reason,
durationSec: Math.max(0, Math.trunc((intervalEnd - intervalStart) / 1000)),
label: winner.label,
});
}
const merged: RecapTimelineSegment[] = [];
for (const segment of timeline) {
const prev = merged[merged.length - 1];
if (!prev || !isEquivalent(prev, segment) || prev.endMs !== segment.startMs) {
merged.push(segment);
continue;
}
prev.endMs = segment.endMs;
if (prev.type === "mold-change") prev.durationSec = Math.max(0, Math.trunc((prev.endMs - prev.startMs) / 1000));
if (prev.type === "macrostop" || prev.type === "microstop" || prev.type === "slow-cycle") {
prev.durationSec = Math.max(0, Math.trunc((prev.endMs - prev.startMs) / 1000));
}
}
const response: RecapTimelineResponse = {
range: {
start: start.toISOString(),
end: end.toISOString(),
}, },
segments: merged, });
};
return NextResponse.json(response);
} }

View File

@@ -0,0 +1,44 @@
"use client";
import { useI18n } from "@/lib/i18n/useI18n";
type Props = {
moldChangeStartMs: number | null;
offlineForMin: number | null;
ongoingStopMin: number | null;
};
function toInt(value: number | null | undefined) {
if (value == null || Number.isNaN(value)) return 0;
return Math.max(0, Math.round(value));
}
export default function RecapBanners({ moldChangeStartMs, offlineForMin, ongoingStopMin }: Props) {
const { t, locale } = useI18n();
const moldStartLabel = moldChangeStartMs
? new Date(moldChangeStartMs).toLocaleTimeString(locale, { hour: "2-digit", minute: "2-digit" })
: "--:--";
return (
<div className="space-y-2">
{moldChangeStartMs ? (
<div className="rounded-xl border border-amber-400/40 bg-amber-400/10 px-3 py-2 text-sm text-amber-200">
{t("recap.banner.moldChange", { time: moldStartLabel })}
</div>
) : null}
{offlineForMin != null && offlineForMin > 10 ? (
<div className="rounded-xl border border-red-500/40 bg-red-500/10 px-3 py-2 text-sm text-red-200">
{t("recap.banner.offline", { min: toInt(offlineForMin) })}
</div>
) : null}
{ongoingStopMin != null && ongoingStopMin > 0 ? (
<div className="rounded-xl border border-red-500/40 bg-red-500/10 px-3 py-2 text-sm text-red-200">
{t("recap.banner.ongoingStop", { min: toInt(ongoingStopMin) })}
</div>
) : null}
</div>
);
}

View File

@@ -1,54 +1,32 @@
"use client"; "use client";
import { Bar, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis, BarChart } from "recharts";
import { useI18n } from "@/lib/i18n/useI18n"; import { useI18n } from "@/lib/i18n/useI18n";
import type { RecapDowntimeTopRow } from "@/lib/recap/types";
type Row = {
reasonLabel: string;
minutes: number;
count: number;
};
type Props = { type Props = {
rows: Row[]; rows: RecapDowntimeTopRow[];
}; };
export default function RecapDowntimeTop({ rows }: Props) { export default function RecapDowntimeTop({ rows }: Props) {
const { t } = useI18n(); const { t } = useI18n();
const data = rows.slice(0, 3).map((row) => ({ ...row, label: row.reasonLabel.slice(0, 20) }));
return ( return (
<div className="rounded-2xl border border-white/10 bg-black/40 p-4"> <div className="rounded-2xl border border-white/10 bg-black/40 p-4">
<div className="mb-3 text-sm font-semibold text-white">{t("recap.downtime.title")}</div> <div className="mb-3 text-sm font-semibold text-white">{t("recap.downtime.top")}</div>
{data.length === 0 ? (
{rows.length === 0 ? (
<div className="text-sm text-zinc-400">{t("recap.empty.production")}</div> <div className="text-sm text-zinc-400">{t("recap.empty.production")}</div>
) : ( ) : (
<> <div className="space-y-3">
<div className="h-[170px]"> {rows.slice(0, 3).map((row) => (
<ResponsiveContainer width="100%" height="100%"> <div key={row.reasonLabel} className="rounded-xl border border-white/10 bg-black/20 p-3">
<BarChart data={data} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}> <div className="text-sm font-medium text-white">{row.reasonLabel}</div>
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.08)" /> <div className="mt-1 text-xs text-zinc-300">
<XAxis dataKey="label" tick={{ fill: "#a1a1aa", fontSize: 11 }} /> {row.minutes.toFixed(1)} min · {row.percent.toFixed(1)}%
<YAxis tick={{ fill: "#a1a1aa", fontSize: 11 }} />
<Tooltip
contentStyle={{ background: "rgba(0,0,0,0.85)", border: "1px solid rgba(255,255,255,0.12)" }}
labelStyle={{ color: "#e4e4e7" }}
/>
<Bar dataKey="minutes" fill="#34d399" radius={[6, 6, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
<div className="mt-2 space-y-1">
{data.map((row) => (
<div key={row.reasonLabel} className="flex items-center justify-between text-xs text-zinc-300">
<span className="truncate">{row.reasonLabel}</span>
<span>
{row.minutes.toFixed(1)} min · {row.count}
</span>
</div> </div>
))} </div>
</div> ))}
</> </div>
)} )}
</div> </div>
); );

View File

@@ -0,0 +1,83 @@
"use client";
import type { RecapTimelineSegment } from "@/lib/recap/types";
import {
computeWidths,
formatDuration,
formatTime,
LABEL_MIN_WIDTH_PCT,
normalizeTimelineSegments,
TIMELINE_COLORS,
} from "@/components/recap/timelineRender";
import { useI18n } from "@/lib/i18n/useI18n";
type Props = {
rangeStart: string;
rangeEnd: string;
segments: RecapTimelineSegment[];
locale: string;
hasData?: boolean;
};
const MIN_SEGMENT_PCT = 1.5;
export default function RecapFullTimeline({ rangeStart, rangeEnd, segments, locale, hasData = true }: Props) {
const { t } = useI18n();
const startMs = new Date(rangeStart).getTime();
const endMs = new Date(rangeEnd).getTime();
const totalMs = Math.max(1, endMs - startMs);
const normalized = normalizeTimelineSegments(segments, startMs, endMs);
const widths = computeWidths(normalized, totalMs, MIN_SEGMENT_PCT);
return (
<div className="rounded-2xl border border-white/10 bg-black/40 p-4">
<div className="mb-3 text-sm font-semibold text-white">{t("recap.timeline.title")}</div>
{!hasData ? (
<div className="rounded-xl border border-dashed border-white/10 bg-black/20 p-4 text-sm text-zinc-400">
{t("recap.timeline.noData")}
</div>
) : null}
{hasData ? (
<div className="overflow-x-auto">
<div className="min-w-[560px]">
<div className="flex h-14 w-full overflow-hidden rounded-xl">
{normalized.map((segment, index) => {
const widthPct = widths[index] ?? 0;
const typeLabel =
segment.type === "production"
? t("recap.timeline.type.production")
: segment.type === "mold-change"
? t("recap.timeline.type.moldChange")
: segment.type === "macrostop"
? t("recap.timeline.type.macrostop")
: segment.type === "microstop" || segment.type === "slow-cycle"
? t("recap.timeline.type.microstop")
: t("recap.timeline.type.idle");
const title = `${typeLabel} · ${formatTime(segment.startMs, locale)}-${formatTime(
segment.endMs,
locale
)} · ${formatDuration(segment.startMs, segment.endMs)}${segment.label ? ` · ${segment.label}` : ""}`;
return (
<div
key={`${segment.type}:${segment.startMs}:${segment.endMs}:${segment.label}`}
className={`flex h-full shrink-0 items-center justify-center truncate px-2 text-xs font-semibold ${
TIMELINE_COLORS[segment.type]
} ${index === 0 ? "rounded-l-xl" : ""} ${
index === normalized.length - 1 ? "rounded-r-xl" : ""
}`}
style={{ width: `${Math.max(0, widthPct)}%` }}
title={title}
>
{widthPct > LABEL_MIN_WIDTH_PCT ? segment.label : ""}
</div>
);
})}
</div>
</div>
</div>
) : null}
</div>
);
}

View File

@@ -9,23 +9,26 @@ type Props = {
scrapParts: number; scrapParts: number;
}; };
function fmtPct(v: number | null) {
if (v == null || Number.isNaN(v)) return "--";
return `${v.toFixed(1)}%`;
}
export default function RecapKpiRow({ oeeAvg, goodParts, totalStops, scrapParts }: Props) { export default function RecapKpiRow({ oeeAvg, goodParts, totalStops, scrapParts }: Props) {
const { t } = useI18n(); const { t } = useI18n();
const items = [ const items = [
{ label: t("recap.kpi.oee"), value: fmtPct(oeeAvg), valueClass: "text-emerald-400" },
{ label: t("recap.kpi.good"), value: String(goodParts), valueClass: "text-white" }, { label: t("recap.kpi.good"), value: String(goodParts), valueClass: "text-white" },
{ label: t("recap.kpi.stops"), value: String(totalStops), valueClass: totalStops > 0 ? "text-amber-400" : "text-white" }, { label: t("recap.kpi.stops"), value: String(totalStops), valueClass: totalStops > 0 ? "text-amber-300" : "text-white" },
{ label: t("recap.kpi.scrap"), value: String(scrapParts), valueClass: scrapParts > 0 ? "text-red-400" : "text-white" }, { label: t("recap.kpi.scrap"), value: String(scrapParts), valueClass: scrapParts > 0 ? "text-red-300" : "text-white" },
]; ];
return ( return (
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-4"> <div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
<div className="rounded-2xl border border-white/10 bg-black/40 p-4">
<div className={`text-2xl font-semibold ${oeeAvg == null || Number.isNaN(oeeAvg) ? "text-zinc-400" : "text-emerald-300"}`}>
{oeeAvg == null || Number.isNaN(oeeAvg) ? "—" : `${oeeAvg.toFixed(1)}%`}
</div>
<div className="mt-1 text-xs uppercase tracking-wide text-zinc-400">{t("recap.kpi.oee")}</div>
{oeeAvg == null || Number.isNaN(oeeAvg) ? (
<div className="mt-1 text-xs text-zinc-500">{t("recap.kpi.noData")}</div>
) : null}
</div>
{items.map((item) => ( {items.map((item) => (
<div key={item.label} className="rounded-2xl border border-white/10 bg-black/40 p-4"> <div key={item.label} className="rounded-2xl border border-white/10 bg-black/40 p-4">
<div className={`text-2xl font-semibold ${item.valueClass}`}>{item.value}</div> <div className={`text-2xl font-semibold ${item.valueClass}`}>{item.value}</div>

View File

@@ -0,0 +1,154 @@
"use client";
import Link from "next/link";
import { useEffect, useState } from "react";
import { useI18n } from "@/lib/i18n/useI18n";
import type { RecapSummaryMachine, RecapTimelineResponse } from "@/lib/recap/types";
import RecapMiniTimeline from "@/components/recap/RecapMiniTimeline";
type Props = {
machine: RecapSummaryMachine;
rangeStart: string;
rangeEnd: string;
};
const STATUS_DOT: Record<RecapSummaryMachine["status"], string> = {
running: "bg-emerald-400",
"mold-change": "bg-amber-400",
stopped: "bg-red-500",
offline: "bg-zinc-500",
};
function statusLabel(status: RecapSummaryMachine["status"], t: (key: string) => string) {
if (status === "running") return t("recap.status.running");
if (status === "mold-change") return t("recap.status.moldChange");
if (status === "stopped") return t("recap.status.stopped");
return t("recap.status.offline");
}
function toInt(value: number | null | undefined) {
if (value == null || Number.isNaN(value)) return 0;
return Math.max(0, Math.round(value));
}
export default function RecapMachineCard({ machine, rangeStart, rangeEnd }: Props) {
const { t, locale } = useI18n();
const [timeline, setTimeline] = useState<RecapTimelineResponse | null>(null);
const [nowMs, setNowMs] = useState(() => Date.now());
const zeroActivity = machine.goodParts === 0 && machine.scrap === 0 && machine.stopsCount === 0;
const primaryMetric = machine.oee == null ? "—" : `${machine.oee.toFixed(1)}%`;
const timelineSegments = timeline?.segments ?? machine.miniTimeline;
const timelineStart = timeline?.range.start ?? rangeStart;
const timelineEnd = timeline?.range.end ?? rangeEnd;
const hasTimelineData = timeline?.hasData ?? timelineSegments.length > 0;
const staleHeartbeat = machine.lastSeenMs == null ? true : nowMs - machine.lastSeenMs > 5 * 60 * 1000;
const lastSeenLabel =
machine.lastActivityMin == null
? t("common.never")
: t("recap.card.lastActivity", { min: toInt(machine.lastActivityMin) });
const footerText = machine.activeWorkOrderId
? t("recap.card.activeWorkOrder", { id: machine.activeWorkOrderId })
: lastSeenLabel;
const moldMinutes = machine.moldChange?.active ? machine.moldChange.elapsedMin : null;
useEffect(() => {
const timer = window.setInterval(() => setNowMs(Date.now()), 60000);
return () => window.clearInterval(timer);
}, []);
useEffect(() => {
let alive = true;
async function loadTimeline() {
try {
const res = await fetch(
`/api/recap/${machine.machineId}/timeline?range=24h&compact=1&maxSegments=30`,
{ cache: "no-store" }
);
const json = await res.json().catch(() => null);
if (!alive || !res.ok || !json) return;
setTimeline(json as RecapTimelineResponse);
} catch {
}
}
void loadTimeline();
const timer = window.setInterval(() => {
void loadTimeline();
}, 60000);
return () => {
alive = false;
window.clearInterval(timer);
};
}, [machine.machineId]);
return (
<Link
href={`/recap/${machine.machineId}`}
className="rounded-2xl border border-white/10 bg-white/5 p-4 transition hover:bg-white/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-300/80"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-lg font-semibold text-white">{machine.name}</div>
<div className="mt-1 truncate text-xs text-zinc-400">{machine.location || t("common.na")}</div>
</div>
<span className="inline-flex items-center gap-2 rounded-full border border-white/10 px-2 py-1 text-xs text-zinc-200">
<span
className={`inline-block h-2.5 w-2.5 rounded-full ${STATUS_DOT[machine.status]}`}
aria-label={statusLabel(machine.status, t)}
/>
{statusLabel(machine.status, t)}
</span>
</div>
<div className="mt-4 flex items-baseline gap-2">
<div className={`text-3xl font-semibold ${machine.oee == null ? "text-zinc-400" : "text-white"}`}>{primaryMetric}</div>
<div className="text-xs uppercase tracking-wide text-zinc-400">{t("recap.card.oee")}</div>
</div>
{machine.oee == null ? <div className="mt-1 text-xs text-zinc-500">{t("recap.kpi.noData")}</div> : null}
{zeroActivity ? <div className="mt-1 text-xs text-zinc-500">{t("recap.card.noProduction")}</div> : null}
<div className="mt-3 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-zinc-300">
<span>{t("recap.card.good")}: {machine.goodParts}</span>
<span>{t("recap.card.scrap")}: {machine.scrap}</span>
<span>{t("recap.card.stops")}: {machine.stopsCount}</span>
</div>
<div className="mt-3">
<RecapMiniTimeline
rangeStart={timelineStart}
rangeEnd={timelineEnd}
segments={timelineSegments}
locale={locale}
hasData={hasTimelineData}
muted={zeroActivity}
/>
</div>
{machine.moldChange?.active ? (
<div className="mt-3 rounded-lg border border-amber-400/40 bg-amber-400/10 px-2 py-1.5 text-xs text-amber-200">
{t("recap.card.moldChangeActive", { min: toInt(moldMinutes) })}
</div>
) : null}
{machine.offlineForMin != null && machine.offlineForMin > 10 ? (
<div className="mt-2 rounded-lg border border-red-500/40 bg-red-500/10 px-2 py-1.5 text-xs text-red-200">
{t("recap.banner.offline", { min: toInt(machine.offlineForMin) })}
</div>
) : null}
{staleHeartbeat ? (
<div className="mt-2 rounded-lg border border-amber-400/40 bg-amber-400/10 px-2 py-1.5 text-xs text-amber-200">
{t("recap.card.desynced")}
</div>
) : null}
<div className="mt-3 text-xs text-zinc-400">{footerText}</div>
</Link>
);
}

View File

@@ -1,44 +1,32 @@
"use client"; "use client";
import { useI18n } from "@/lib/i18n/useI18n"; import { useI18n } from "@/lib/i18n/useI18n";
import type { RecapMachine } from "@/lib/recap/types";
type Props = { type Props = {
machine: RecapMachine | null; heartbeat: {
lastSeenAt: string | null;
uptimePct: number | null;
connectionStatus: "online" | "offline";
};
}; };
export default function RecapMachineStatus({ machine }: Props) { export default function RecapMachineStatus({ heartbeat }: Props) {
const { t, locale } = useI18n(); const { t, locale } = useI18n();
if (!machine) {
return (
<div className="rounded-2xl border border-white/10 bg-black/40 p-4">
<div className="text-sm text-zinc-400">{t("recap.empty.production")}</div>
</div>
);
}
const isStopped = (machine.downtime.ongoingStopMin ?? 0) > 0;
return ( return (
<div className="rounded-2xl border border-white/10 bg-black/40 p-4"> <div className="rounded-2xl border border-white/10 bg-black/40 p-4">
<div className="mb-3 text-sm font-semibold text-white">{t("recap.machine.title")}</div> <div className="mb-3 text-sm font-semibold text-white">{t("recap.machine.title")}</div>
<ul className="space-y-2 text-sm text-zinc-200"> <ul className="space-y-2 text-sm text-zinc-200">
<li> <li>
<span className={isStopped ? "text-red-400" : "text-emerald-400"}> <span className={heartbeat.connectionStatus === "online" ? "text-emerald-300" : "text-red-300"}>
{isStopped ? t("recap.machine.stopped") : t("recap.machine.running")} {heartbeat.connectionStatus === "online" ? t("recap.machine.online") : t("recap.machine.offline")}
</span>
</li>
<li>
<span className={machine.workOrders.moldChangeInProgress ? "text-amber-400" : "text-zinc-300"}>
{t("recap.machine.mold")}: {machine.workOrders.moldChangeInProgress ? t("common.yes") : t("common.no")}
</span> </span>
</li> </li>
<li className="text-zinc-400"> <li className="text-zinc-400">
{t("recap.machine.lastHeartbeat")}: {machine.heartbeat.lastSeenAt ? new Date(machine.heartbeat.lastSeenAt).toLocaleString(locale) : "--"} {t("recap.machine.lastHeartbeat")}: {heartbeat.lastSeenAt ? new Date(heartbeat.lastSeenAt).toLocaleString(locale) : "--"}
</li> </li>
<li className="text-zinc-400"> <li className="text-zinc-400">
{t("recap.machine.uptime")}: {machine.heartbeat.uptimePct == null ? "--" : `${machine.heartbeat.uptimePct.toFixed(1)}%`} {t("recap.machine.uptime")}: {heartbeat.uptimePct == null ? "--" : `${heartbeat.uptimePct.toFixed(1)}%`}
</li> </li>
</ul> </ul>
</div> </div>

View File

@@ -0,0 +1,82 @@
"use client";
import type { RecapTimelineSegment } from "@/lib/recap/types";
import {
computeWidths,
formatDuration,
formatTime,
normalizeTimelineSegments,
TIMELINE_COLORS,
} from "@/components/recap/timelineRender";
import { useI18n } from "@/lib/i18n/useI18n";
type Props = {
rangeStart: string;
rangeEnd: string;
segments: RecapTimelineSegment[];
locale: string;
muted?: boolean;
hasData?: boolean;
};
const MIN_SEGMENT_PCT = 1.5;
export default function RecapMiniTimeline({
rangeStart,
rangeEnd,
segments,
locale,
muted = false,
hasData = true,
}: Props) {
const { t } = useI18n();
const startMs = new Date(rangeStart).getTime();
const endMs = new Date(rangeEnd).getTime();
const totalMs = Math.max(1, endMs - startMs);
const normalized = normalizeTimelineSegments(segments, startMs, endMs);
const widths = computeWidths(normalized, totalMs, MIN_SEGMENT_PCT);
if (!hasData) {
return (
<div className="flex h-5 w-full items-center justify-center rounded-md bg-zinc-800/70 text-[10px] text-zinc-400">
{t("recap.timeline.noData")}
</div>
);
}
if (!normalized.length) {
return <div className="h-5 w-full rounded-md bg-zinc-700/70" />;
}
return (
<div className="flex h-5 w-full overflow-hidden rounded-md">
{normalized.map((segment, index) => {
const widthPct = widths[index] ?? 0;
const typeLabel =
segment.type === "production"
? t("recap.timeline.type.production")
: segment.type === "mold-change"
? t("recap.timeline.type.moldChange")
: segment.type === "macrostop"
? t("recap.timeline.type.macrostop")
: segment.type === "microstop" || segment.type === "slow-cycle"
? t("recap.timeline.type.microstop")
: t("recap.timeline.type.idle");
const title = `${typeLabel} · ${formatTime(segment.startMs, locale)}-${formatTime(segment.endMs, locale)} · ${formatDuration(segment.startMs, segment.endMs)}${segment.label ? ` · ${segment.label}` : ""}`;
const color = muted ? "bg-zinc-700 text-zinc-300" : TIMELINE_COLORS[segment.type];
return (
<div
key={`${segment.type}:${segment.startMs}:${segment.endMs}:${segment.label}`}
className={`h-full shrink-0 ${color} ${index === 0 ? "rounded-l-md" : ""} ${
index === normalized.length - 1 ? "rounded-r-md" : ""
}`}
style={{ width: `${Math.max(0, widthPct)}%` }}
title={title}
/>
);
})}
</div>
);
}

View File

@@ -12,34 +12,37 @@ export default function RecapProductionBySku({ rows }: Props) {
return ( return (
<div className="rounded-2xl border border-white/10 bg-black/40 p-4"> <div className="rounded-2xl border border-white/10 bg-black/40 p-4">
<div className="mb-3 text-sm font-semibold text-white">{t("recap.production.title")}</div> <div className="mb-3 text-sm font-semibold text-white">{t("recap.production.bySku")}</div>
{rows.length === 0 ? ( {rows.length === 0 ? (
<div className="text-sm text-zinc-400">{t("recap.empty.production")}</div> <div className="text-sm text-zinc-400">{t("recap.empty.production")}</div>
) : ( ) : (
<div className="space-y-2"> <div className="overflow-x-auto">
<div className="grid grid-cols-6 gap-2 border-b border-white/10 pb-2 text-xs uppercase tracking-wide text-zinc-400"> <table className="min-w-full text-sm text-zinc-200">
<div>Maquina</div> <thead>
<div>SKU</div> <tr className="border-b border-white/10 text-left text-xs uppercase tracking-wide text-zinc-400">
<div>{t("recap.production.good")}</div> <th className="py-2 pr-3">{t("recap.production.sku")}</th>
<div>{t("recap.production.scrap")}</div> <th className="py-2 pr-3">{t("recap.production.good")}</th>
<div>{t("recap.production.target")}</div> <th className="py-2 pr-3">{t("recap.production.scrap")}</th>
<div>{t("recap.production.progress")}</div> <th className="py-2 pr-3">{t("recap.production.target")}</th>
</div> <th className="py-2">{t("recap.production.progress")}</th>
{rows.slice(0, 8).map((row) => { </tr>
const pct = row.progressPct == null ? "--" : `${Math.round(row.progressPct)}%`; </thead>
return ( <tbody>
<div key={`${row.machineName}:${row.sku}`} className="grid grid-cols-6 gap-2 text-sm text-zinc-200"> {rows.slice(0, 10).map((row) => {
<div className="truncate text-zinc-400">{row.machineName}</div> const progress = row.progressPct == null ? "--" : `${Math.round(row.progressPct)}%`;
<div className="truncate">{row.sku}</div> return (
<div>{row.good}</div> <tr key={`${row.sku}:${row.machineName}`} className="border-b border-white/5">
<div className={row.scrap > 0 ? "text-red-400" : "text-zinc-200"}>{row.scrap}</div> <td className="py-2 pr-3">{row.sku}</td>
<div>{row.target ?? "--"}</div> <td className="py-2 pr-3">{row.good}</td>
<div> <td className={`py-2 pr-3 ${row.scrap > 0 ? "text-red-300" : ""}`}>{row.scrap}</td>
<span className="text-emerald-400">{pct}</span> <td className="py-2 pr-3">{row.target ?? "--"}</td>
</div> <td className="py-2 text-emerald-300">{progress}</td>
</div> </tr>
); );
})} })}
</tbody>
</table>
</div> </div>
)} )}
</div> </div>

View File

@@ -10,13 +10,15 @@ type Props = {
}; };
const COLORS: Record<RecapTimelineSegment["type"], string> = { const COLORS: Record<RecapTimelineSegment["type"], string> = {
production: "bg-emerald-500 text-emerald-50", production: "bg-emerald-500 text-black",
"mold-change": "bg-blue-400 text-blue-950", "mold-change": "bg-sky-400 text-black",
macrostop: "bg-red-500 text-red-50", macrostop: "bg-red-500 text-white",
microstop: "bg-orange-500 text-orange-50", microstop: "bg-orange-500 text-black",
"slow-cycle": "bg-amber-500 text-amber-950", "slow-cycle": "bg-amber-500 text-black",
idle: "bg-zinc-600 text-zinc-100", idle: "bg-zinc-600 text-zinc-300",
}; };
const MIN_SEGMENT_PCT = 1.5;
const LABEL_MIN_PCT = 5;
function fmtTime(valueMs: number, locale: string) { function fmtTime(valueMs: number, locale: string) {
return new Date(valueMs).toLocaleTimeString(locale, { hour: "2-digit", minute: "2-digit" }); return new Date(valueMs).toLocaleTimeString(locale, { hour: "2-digit", minute: "2-digit" });
@@ -30,54 +32,141 @@ function fmtDuration(startMs: number, endMs: number) {
return `${h}h ${m}m`; return `${h}h ${m}m`;
} }
function shouldMergeByType(type: RecapTimelineSegment["type"]) {
return type === "macrostop" || type === "microstop" || type === "slow-cycle" || type === "idle";
}
function normalizeForRender(segments: RecapTimelineSegment[], startMs: number, endMs: number) {
const ordered = segments
.map((segment) => ({
...segment,
startMs: Math.max(startMs, segment.startMs),
endMs: Math.min(endMs, segment.endMs),
}))
.filter((segment) => segment.endMs > segment.startMs)
.sort((a, b) => a.startMs - b.startMs || a.endMs - b.endMs);
const out: RecapTimelineSegment[] = [];
let cursor = startMs;
for (const segment of ordered) {
if (segment.startMs > cursor) {
const prev = out[out.length - 1];
if (prev) {
prev.endMs = segment.startMs;
} else {
out.push({
type: "idle",
startMs: cursor,
endMs: segment.startMs,
durationSec: Math.max(0, Math.trunc((segment.startMs - cursor) / 1000)),
label: "Idle",
});
}
}
const normalizedStart = Math.max(cursor, segment.startMs);
const normalizedEnd = Math.min(endMs, segment.endMs);
if (normalizedEnd <= normalizedStart) continue;
const normalizedSegment: RecapTimelineSegment = {
...segment,
startMs: normalizedStart,
endMs: normalizedEnd,
};
const prev = out[out.length - 1];
if (
prev &&
prev.type === normalizedSegment.type &&
shouldMergeByType(prev.type) &&
prev.endMs === normalizedSegment.startMs
) {
prev.endMs = normalizedSegment.endMs;
} else {
out.push(normalizedSegment);
}
cursor = normalizedEnd;
if (cursor >= endMs) break;
}
if (cursor < endMs) {
const prev = out[out.length - 1];
if (prev) {
prev.endMs = endMs;
} else {
out.push({
type: "idle",
startMs: cursor,
endMs,
durationSec: Math.max(0, Math.trunc((endMs - cursor) / 1000)),
label: "Idle",
});
}
}
return out.filter((segment) => segment.endMs > segment.startMs);
}
function computeWidths(segments: RecapTimelineSegment[], totalMs: number, minPct: number) {
if (!segments.length) return [];
const base = segments.map((segment) => ((segment.endMs - segment.startMs) / totalMs) * 100);
const effectiveMin = Math.min(minPct, 100 / segments.length);
let widths = base.map((pct) => Math.max(pct, effectiveMin));
const sum = widths.reduce((acc, value) => acc + value, 0);
if (sum > 100) {
const overflow = sum - 100;
const slacks = widths.map((value) => Math.max(0, value - effectiveMin));
const totalSlack = slacks.reduce((acc, value) => acc + value, 0);
if (totalSlack > 0) {
widths = widths.map((value, index) => value - (overflow * slacks[index]) / totalSlack);
} else {
const scale = 100 / sum;
widths = widths.map((value) => value * scale);
}
} else if (sum < 100) {
const deficit = 100 - sum;
const totalBase = base.reduce((acc, value) => acc + (value > 0 ? value : 1), 0);
widths = widths.map((value, index) => value + (deficit * (base[index] > 0 ? base[index] : 1)) / totalBase);
}
const rounded = widths.map((value) => Number(value.toFixed(4)));
const roundedSum = rounded.reduce((acc, value) => acc + value, 0);
const delta = Number((100 - roundedSum).toFixed(4));
if (rounded.length > 0) {
rounded[rounded.length - 1] = Number(Math.max(0, rounded[rounded.length - 1] + delta).toFixed(4));
}
return rounded;
}
export default function RecapTimeline({ rangeStart, rangeEnd, segments, locale }: Props) { export default function RecapTimeline({ rangeStart, rangeEnd, segments, locale }: Props) {
const startMs = new Date(rangeStart).getTime(); const startMs = new Date(rangeStart).getTime();
const endMs = new Date(rangeEnd).getTime(); const endMs = new Date(rangeEnd).getTime();
const totalMs = Math.max(1, endMs - startMs); const totalMs = Math.max(1, endMs - startMs);
const normalized = normalizeForRender(segments, startMs, endMs);
const bars: RecapTimelineSegment[] = []; const widths = computeWidths(normalized, totalMs, MIN_SEGMENT_PCT);
const dots: Array<{ leftPct: number; segment: RecapTimelineSegment }> = [];
for (const segment of segments) {
const widthPct = ((segment.endMs - segment.startMs) / totalMs) * 100;
const leftPct = ((segment.startMs - startMs) / totalMs) * 100;
if (widthPct < 1) {
if (segment.type !== "idle" && leftPct > 0.5 && leftPct < 99.5) {
dots.push({ leftPct, segment });
}
} else {
bars.push(segment);
}
}
return ( return (
<div className="mb-4 rounded-2xl border border-white/10 bg-black/40 p-3"> <div className="mb-4 rounded-2xl border border-white/10 bg-black/40 p-3">
<div className="mb-2 text-xs uppercase tracking-wide text-zinc-400">Timeline 24h</div> <div className="mb-2 text-xs uppercase tracking-wide text-zinc-400">Timeline 24h</div>
<div className="relative"> <div className="flex h-14 w-full overflow-hidden rounded-xl border border-white/10">
<div className="flex h-12 w-full overflow-hidden rounded-xl border border-white/10"> {normalized.map((segment, index) => {
{bars.map((segment) => { const widthPct = widths[index] ?? 0;
const widthPct = ((segment.endMs - segment.startMs) / totalMs) * 100; const title = `${segment.type} · ${fmtTime(segment.startMs, locale)}-${fmtTime(segment.endMs, locale)} · ${fmtDuration(segment.startMs, segment.endMs)}${segment.label ? ` · ${segment.label}` : ""}`;
const title = `${segment.type} · ${fmtTime(segment.startMs, locale)}-${fmtTime(segment.endMs, locale)} · ${fmtDuration(segment.startMs, segment.endMs)}${segment.label ? ` · ${segment.label}` : ""}`; return (
return ( <div
<div key={`${segment.type}:${segment.startMs}:${segment.endMs}:${segment.label}`}
key={`${segment.type}:${segment.startMs}:${segment.endMs}:${segment.label}`} className={`flex h-full shrink-0 items-center justify-center truncate px-2 text-xs font-semibold ${COLORS[segment.type]} ${
className={`flex items-center justify-center truncate px-1 text-xs font-medium ${COLORS[segment.type]}`} index === 0 ? "rounded-l-xl" : ""
style={{ width: `${widthPct}%` }} } ${index === normalized.length - 1 ? "rounded-r-xl" : ""}`}
title={title} style={{ width: `${Math.max(0, widthPct)}%` }}
> title={title}
{widthPct >= 6 ? segment.label : ""} >
</div> {widthPct > LABEL_MIN_PCT ? segment.label : ""}
); </div>
})} );
</div> })}
{dots.map(({ leftPct, segment }) => (
<div
key={`dot:${segment.type}:${segment.startMs}:${segment.endMs}`}
className={`absolute top-1/2 h-2 w-2 -translate-x-1/2 -translate-y-1/2 rounded-full border border-black/30 ${COLORS[segment.type].split(" ")[0]}`}
style={{ left: `${Math.max(0.3, Math.min(99.7, leftPct))}%` }}
title={`${segment.type} · ${fmtTime(segment.startMs, locale)}-${fmtTime(segment.endMs, locale)} · ${fmtDuration(segment.startMs, segment.endMs)}`}
/>
))}
</div> </div>
</div> </div>
); );

View File

@@ -0,0 +1,59 @@
"use client";
import { useI18n } from "@/lib/i18n/useI18n";
import type { RecapWorkOrders as RecapWorkOrdersType } from "@/lib/recap/types";
type Props = {
workOrders: RecapWorkOrdersType;
};
export default function RecapWorkOrders({ workOrders }: Props) {
const { t, locale } = useI18n();
return (
<div className="rounded-2xl border border-white/10 bg-black/40 p-4">
<div className="mb-3 text-sm font-semibold text-white">{t("recap.workOrders.title")}</div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<div>
<div className="text-xs uppercase tracking-wide text-zinc-400">{t("recap.workOrders.completed")}</div>
{workOrders.completed.length === 0 ? (
<div className="mt-2 text-sm text-zinc-400">{t("recap.workOrders.none")}</div>
) : (
<div className="mt-2 space-y-2">
{workOrders.completed.slice(0, 6).map((row) => (
<div key={row.id} className="rounded-lg border border-white/10 bg-black/20 p-2 text-xs text-zinc-300">
<div className="font-medium text-white">{row.id}</div>
<div>{t("recap.workOrders.sku")}: {row.sku || "--"}</div>
<div>{t("recap.workOrders.goodParts")}: {row.goodParts}</div>
<div>{t("recap.workOrders.duration")}: {row.durationHrs.toFixed(2)}h</div>
</div>
))}
</div>
)}
</div>
<div>
<div className="text-xs uppercase tracking-wide text-zinc-400">{t("recap.workOrders.active")}</div>
{!workOrders.active ? (
<div className="mt-2 text-sm text-zinc-400">{t("recap.workOrders.none")}</div>
) : (
<div className="mt-2 rounded-lg border border-white/10 bg-black/20 p-3 text-sm text-zinc-200">
<div className="font-medium text-white">{workOrders.active.id}</div>
<div className="text-zinc-400">{t("recap.workOrders.sku")}: {workOrders.active.sku || "--"}</div>
<div className="mt-2 h-2 rounded-full bg-white/10">
<div
className="h-2 rounded-full bg-emerald-400"
style={{ width: `${Math.max(0, Math.min(100, workOrders.active.progressPct ?? 0))}%` }}
/>
</div>
<div className="mt-2 text-xs text-zinc-400">
{t("recap.workOrders.startedAt")}: {workOrders.active.startedAt ? new Date(workOrders.active.startedAt).toLocaleString(locale) : "--"}
</div>
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,150 @@
import type { RecapTimelineSegment } from "@/lib/recap/types";
export const TIMELINE_COLORS: Record<RecapTimelineSegment["type"], string> = {
production: "bg-emerald-500 text-black",
"mold-change": "bg-sky-400 text-black",
macrostop: "bg-red-500 text-white",
microstop: "bg-orange-500 text-black",
"slow-cycle": "bg-orange-500 text-black",
idle: "bg-zinc-700 text-zinc-300",
};
export const LABEL_MIN_WIDTH_PCT = 5;
export function formatTime(valueMs: number, locale: string) {
return new Date(valueMs).toLocaleTimeString(locale, {
hour: "2-digit",
minute: "2-digit",
});
}
export function formatDuration(startMs: number, endMs: number) {
const totalMin = Math.max(0, Math.round((endMs - startMs) / 60000));
if (totalMin < 60) return `${totalMin}m`;
const h = Math.floor(totalMin / 60);
const m = totalMin % 60;
return `${h}h ${m}m`;
}
export function normalizeTimelineSegments(
segments: RecapTimelineSegment[],
rangeStartMs: number,
rangeEndMs: number
) {
const ordered = [...segments]
.map((segment) => ({
...segment,
startMs: Math.max(rangeStartMs, segment.startMs),
endMs: Math.min(rangeEndMs, segment.endMs),
}))
.filter((segment) => segment.endMs > segment.startMs)
.sort((a, b) => a.startMs - b.startMs || a.endMs - b.endMs);
const out: RecapTimelineSegment[] = [];
let cursor = rangeStartMs;
for (const segment of ordered) {
if (segment.startMs > cursor) {
out.push({
type: "idle",
startMs: cursor,
endMs: segment.startMs,
durationSec: Math.max(0, Math.trunc((segment.startMs - cursor) / 1000)),
label: "Idle",
});
}
const startMs = Math.max(cursor, segment.startMs);
const endMs = Math.min(rangeEndMs, segment.endMs);
if (endMs <= startMs) continue;
if (segment.type === "production") {
out.push({
type: "production",
startMs,
endMs,
durationSec: Math.max(0, Math.trunc((endMs - startMs) / 1000)),
workOrderId: segment.workOrderId,
sku: segment.sku,
label: segment.label,
});
} else if (segment.type === "mold-change") {
out.push({
type: "mold-change",
startMs,
endMs,
fromMoldId: segment.fromMoldId,
toMoldId: segment.toMoldId,
durationSec: Math.max(0, Math.trunc((endMs - startMs) / 1000)),
label: segment.label,
});
} else if (segment.type === "macrostop" || segment.type === "microstop" || segment.type === "slow-cycle") {
out.push({
type: segment.type === "slow-cycle" ? "microstop" : segment.type,
startMs,
endMs,
reason: segment.reason,
reasonLabel: segment.reasonLabel ?? segment.reason,
durationSec: Math.max(0, Math.trunc((endMs - startMs) / 1000)),
label: segment.label,
});
} else {
out.push({
type: "idle",
startMs,
endMs,
durationSec: Math.max(0, Math.trunc((endMs - startMs) / 1000)),
label: segment.label,
});
}
cursor = endMs;
if (cursor >= rangeEndMs) break;
}
if (cursor < rangeEndMs) {
out.push({
type: "idle",
startMs: cursor,
endMs: rangeEndMs,
durationSec: Math.max(0, Math.trunc((rangeEndMs - cursor) / 1000)),
label: "Idle",
});
}
return out;
}
export function computeWidths(segments: RecapTimelineSegment[], totalMs: number, minPct: number) {
if (!segments.length) return [];
const base = segments.map((segment) => ((segment.endMs - segment.startMs) / totalMs) * 100);
const effectiveMin = Math.min(minPct, 100 / segments.length);
let widths = base.map((pct) => Math.max(pct, effectiveMin));
const sum = widths.reduce((acc, value) => acc + value, 0);
if (sum > 100) {
const overflow = sum - 100;
const slacks = widths.map((value) => Math.max(0, value - effectiveMin));
const totalSlack = slacks.reduce((acc, value) => acc + value, 0);
if (totalSlack > 0) {
widths = widths.map((value, index) => value - (overflow * slacks[index]) / totalSlack);
} else {
const scale = 100 / sum;
widths = widths.map((value) => value * scale);
}
} else if (sum < 100) {
const deficit = 100 - sum;
const totalBase = base.reduce((acc, value) => acc + (value > 0 ? value : 1), 0);
widths = widths.map(
(value, index) => value + (deficit * (base[index] > 0 ? base[index] : 1)) / totalBase
);
}
const rounded = widths.map((value) => Number(value.toFixed(4)));
const roundedSum = rounded.reduce((acc, value) => acc + value, 0);
const delta = Number((100 - roundedSum).toFixed(4));
if (rounded.length > 0) {
rounded[rounded.length - 1] = Number(Math.max(0, rounded[rounded.length - 1] + delta).toFixed(4));
}
return rounded;
}

View File

@@ -111,26 +111,55 @@
"overview.recap.cta": "Open daily recap", "overview.recap.cta": "Open daily recap",
"recap.title": "Recap", "recap.title": "Recap",
"recap.subtitle": "Last 24h", "recap.subtitle": "Last 24h",
"recap.grid.title": "Machine recap",
"recap.grid.subtitle": "Last 24h · click to open details",
"recap.grid.updatedAgo": "Updated {sec}s ago",
"recap.grid.empty": "No machines match the current filters.",
"recap.detail.back": "All machines",
"recap.allMachines": "All machines", "recap.allMachines": "All machines",
"recap.filter.allLocations": "All locations",
"recap.filter.allStatuses": "All statuses",
"recap.status.running": "Running",
"recap.status.moldChange": "Mold change",
"recap.status.stopped": "Stopped",
"recap.status.offline": "Offline",
"recap.range.24h": "24h",
"recap.range.shift": "Shift", "recap.range.shift": "Shift",
"recap.range.custom": "Custom range", "recap.range.shiftCurrent": "Current shift",
"recap.range.yesterday": "Yesterday",
"recap.range.custom": "Custom",
"recap.range.apply": "Apply",
"recap.shift.1": "Shift 1", "recap.shift.1": "Shift 1",
"recap.shift.2": "Shift 2", "recap.shift.2": "Shift 2",
"recap.shift.3": "Shift 3", "recap.shift.3": "Shift 3",
"recap.kpi.oee": "Avg OEE", "recap.kpi.oee": "OEE",
"recap.kpi.noData": "No KPI data",
"recap.kpi.good": "Good parts", "recap.kpi.good": "Good parts",
"recap.kpi.stops": "Total stops", "recap.kpi.stops": "Total stops (min)",
"recap.kpi.scrap": "Scrap", "recap.kpi.scrap": "Scrap",
"recap.card.oee": "OEE",
"recap.card.good": "Good parts",
"recap.card.scrap": "Scrap",
"recap.card.stops": "Stops",
"recap.card.noProduction": "No production",
"recap.card.lastActivity": "Last activity {min} min ago",
"recap.card.activeWorkOrder": "Active WO: {id}",
"recap.card.moldChangeActive": "Mold change in progress · {min}m",
"recap.card.desynced": "CT desynchronized",
"recap.production.title": "Production by SKU", "recap.production.title": "Production by SKU",
"recap.production.bySku": "Production by SKU",
"recap.production.sku": "SKU",
"recap.production.good": "Good", "recap.production.good": "Good",
"recap.production.scrap": "Scrap", "recap.production.scrap": "Scrap",
"recap.production.target": "Target", "recap.production.target": "Target",
"recap.production.progress": "Progress", "recap.production.progress": "Progress%",
"recap.downtime.title": "Top downtime", "recap.downtime.title": "Top downtime",
"recap.downtime.top": "Top stops",
"recap.workOrders.title": "Work orders", "recap.workOrders.title": "Work orders",
"recap.workOrders.active": "Active", "recap.workOrders.active": "Active",
"recap.workOrders.completed": "Completed", "recap.workOrders.completed": "Completed",
"recap.workOrders.none": "No production recorded", "recap.workOrders.none": "No production recorded",
"recap.workOrders.sku": "SKU",
"recap.workOrders.startedAt": "Started", "recap.workOrders.startedAt": "Started",
"recap.workOrders.goodParts": "Good parts", "recap.workOrders.goodParts": "Good parts",
"recap.workOrders.duration": "Duration", "recap.workOrders.duration": "Duration",
@@ -138,10 +167,22 @@
"recap.machine.running": "Running", "recap.machine.running": "Running",
"recap.machine.stopped": "Stopped", "recap.machine.stopped": "Stopped",
"recap.machine.mold": "Mold change", "recap.machine.mold": "Mold change",
"recap.machine.online": "Connected",
"recap.machine.offline": "Disconnected",
"recap.machine.lastHeartbeat": "Last heartbeat", "recap.machine.lastHeartbeat": "Last heartbeat",
"recap.machine.uptime": "Uptime", "recap.machine.uptime": "Uptime",
"recap.banner.mold": "Mold change in progress since", "recap.banner.mold": "Mold change in progress since",
"recap.banner.moldChange": "Mold change in progress since {time}",
"recap.banner.offline": "No signal for {min} min",
"recap.banner.ongoingStop": "Machine stopped for {min} min",
"recap.banner.stopped": "Machine stopped for {minutes} min", "recap.banner.stopped": "Machine stopped for {minutes} min",
"recap.timeline.title": "24h timeline",
"recap.timeline.noData": "No timeline data",
"recap.timeline.type.production": "Production",
"recap.timeline.type.moldChange": "Mold change",
"recap.timeline.type.macrostop": "Macrostop",
"recap.timeline.type.microstop": "Microstop",
"recap.timeline.type.idle": "Idle",
"recap.empty.production": "No production recorded", "recap.empty.production": "No production recorded",
"machines.title": "Machines", "machines.title": "Machines",
"machines.subtitle": "Select a machine to view live KPIs.", "machines.subtitle": "Select a machine to view live KPIs.",

View File

@@ -111,37 +111,78 @@
"overview.recap.cta": "Abrir resumen diario", "overview.recap.cta": "Abrir resumen diario",
"recap.title": "Resumen", "recap.title": "Resumen",
"recap.subtitle": "Últimas 24h", "recap.subtitle": "Últimas 24h",
"recap.grid.title": "Resumen de máquinas",
"recap.grid.subtitle": "Últimas 24h · click para ver detalle",
"recap.grid.updatedAgo": "Actualizado hace {sec}s",
"recap.grid.empty": "No hay máquinas que coincidan con los filtros.",
"recap.detail.back": "Todas las máquinas",
"recap.allMachines": "Todas las máquinas", "recap.allMachines": "Todas las máquinas",
"recap.filter.allLocations": "Todas las ubicaciones",
"recap.filter.allStatuses": "Todos los estados",
"recap.status.running": "En marcha",
"recap.status.moldChange": "Cambio de molde",
"recap.status.stopped": "Detenida",
"recap.status.offline": "Sin señal",
"recap.range.24h": "24h",
"recap.range.shift": "Turno", "recap.range.shift": "Turno",
"recap.range.custom": "Rango personalizado", "recap.range.shiftCurrent": "Turno actual",
"recap.range.yesterday": "Ayer",
"recap.range.custom": "Personalizado",
"recap.range.apply": "Aplicar",
"recap.shift.1": "Turno 1", "recap.shift.1": "Turno 1",
"recap.shift.2": "Turno 2", "recap.shift.2": "Turno 2",
"recap.shift.3": "Turno 3", "recap.shift.3": "Turno 3",
"recap.kpi.oee": "OEE prom", "recap.kpi.oee": "OEE",
"recap.kpi.good": "Piezas buenas", "recap.kpi.noData": "Sin datos de KPI",
"recap.kpi.stops": "Paros totales", "recap.kpi.good": "Buenas",
"recap.kpi.stops": "Paros totales (min)",
"recap.kpi.scrap": "Scrap", "recap.kpi.scrap": "Scrap",
"recap.card.oee": "OEE",
"recap.card.good": "Piezas buenas",
"recap.card.scrap": "Scrap",
"recap.card.stops": "Paros",
"recap.card.noProduction": "Sin producción",
"recap.card.lastActivity": "Última actividad hace {min} min",
"recap.card.activeWorkOrder": "WO activa: {id}",
"recap.card.moldChangeActive": "Cambio de molde en curso · {min}m",
"recap.card.desynced": "CT desincronizado",
"recap.production.title": "Producción por SKU", "recap.production.title": "Producción por SKU",
"recap.production.bySku": "Producción por SKU",
"recap.production.sku": "SKU",
"recap.production.good": "Buenas", "recap.production.good": "Buenas",
"recap.production.scrap": "Scrap", "recap.production.scrap": "Scrap",
"recap.production.target": "Meta", "recap.production.target": "Meta",
"recap.production.progress": "Avance", "recap.production.progress": "Avance%",
"recap.downtime.title": "Top downtime", "recap.downtime.title": "Top downtime",
"recap.downtime.top": "Top paros",
"recap.workOrders.title": "Órdenes de trabajo", "recap.workOrders.title": "Órdenes de trabajo",
"recap.workOrders.active": "Activa", "recap.workOrders.active": "Activa",
"recap.workOrders.completed": "Completadas", "recap.workOrders.completed": "Completadas",
"recap.workOrders.none": "Sin producción registrada", "recap.workOrders.none": "Sin producción registrada",
"recap.workOrders.sku": "SKU",
"recap.workOrders.startedAt": "Inicio", "recap.workOrders.startedAt": "Inicio",
"recap.workOrders.goodParts": "Buenas", "recap.workOrders.goodParts": "Buenas",
"recap.workOrders.duration": "Duración", "recap.workOrders.duration": "Duración",
"recap.machine.title": "Estado de máquina", "recap.machine.title": "Estado máquina",
"recap.machine.running": "En marcha", "recap.machine.running": "En marcha",
"recap.machine.stopped": "Detenida", "recap.machine.stopped": "Detenida",
"recap.machine.mold": "Cambio de molde", "recap.machine.mold": "Cambio de molde",
"recap.machine.online": "Conectada",
"recap.machine.offline": "Sin conexión",
"recap.machine.lastHeartbeat": "Último heartbeat", "recap.machine.lastHeartbeat": "Último heartbeat",
"recap.machine.uptime": "Uptime", "recap.machine.uptime": "Uptime",
"recap.banner.mold": "Cambio de molde en curso desde", "recap.banner.mold": "Cambio de molde en curso desde",
"recap.banner.moldChange": "Cambio de molde en curso desde {time}",
"recap.banner.offline": "Sin señal hace {min} min",
"recap.banner.ongoingStop": "Máquina detenida hace {min} min",
"recap.banner.stopped": "Máquina detenida hace {minutes} min", "recap.banner.stopped": "Máquina detenida hace {minutes} min",
"recap.timeline.title": "Timeline 24h",
"recap.timeline.noData": "Sin datos de línea de tiempo",
"recap.timeline.type.production": "Producción",
"recap.timeline.type.moldChange": "Cambio de molde",
"recap.timeline.type.macrostop": "Macroparo",
"recap.timeline.type.microstop": "Microparo",
"recap.timeline.type.idle": "Idle",
"recap.empty.production": "Sin producción registrada", "recap.empty.production": "Sin producción registrada",
"machines.title": "Máquinas", "machines.title": "Máquinas",
"machines.subtitle": "Selecciona una máquina para ver KPIs en vivo.", "machines.subtitle": "Selecciona una máquina para ver KPIs en vivo.",

View File

@@ -1,4 +1,5 @@
import { unstable_cache } from "next/cache"; import { unstable_cache } from "next/cache";
import { Prisma } from "@prisma/client";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { normalizeShiftOverrides, type ShiftOverrideDay } from "@/lib/settings"; import { normalizeShiftOverrides, type ShiftOverrideDay } from "@/lib/settings";
import type { RecapMachine, RecapQuery, RecapResponse } from "@/lib/recap/types"; import type { RecapMachine, RecapQuery, RecapResponse } from "@/lib/recap/types";
@@ -25,8 +26,9 @@ const WEEKDAY_KEY_MAP: Record<string, ShiftOverrideDay> = {
const STOP_TYPES = new Set(["microstop", "macrostop"]); const STOP_TYPES = new Set(["microstop", "macrostop"]);
const STOP_STATUS = new Set(["STOP", "DOWN", "OFFLINE"]); const STOP_STATUS = new Set(["STOP", "DOWN", "OFFLINE"]);
const CACHE_TTL_SEC = 180; const CACHE_TTL_SEC = 60;
const MOLD_LOOKBACK_MS = 14 * 24 * 60 * 60 * 1000; const MOLD_LOOKBACK_MS = 14 * 24 * 60 * 60 * 1000;
let workOrderCountersAvailable: boolean | null = null;
function safeNum(value: unknown) { function safeNum(value: unknown) {
if (typeof value === "number" && Number.isFinite(value)) return value; if (typeof value === "number" && Number.isFinite(value)) return value;
@@ -37,6 +39,17 @@ function safeNum(value: unknown) {
return null; return null;
} }
function safeBool(value: unknown) {
if (typeof value === "boolean") return value;
if (typeof value === "number") return value !== 0;
if (typeof value === "string") {
const normalized = value.trim().toLowerCase();
if (!normalized) return false;
return normalized === "true" || normalized === "1" || normalized === "yes";
}
return false;
}
function normalizeToken(value: unknown) { function normalizeToken(value: unknown) {
return String(value ?? "").trim(); return String(value ?? "").trim();
} }
@@ -194,6 +207,14 @@ function eventStatus(data: unknown) {
return String(inner.status ?? "").trim().toLowerCase(); return String(inner.status ?? "").trim().toLowerCase();
} }
function isRealStopEvent(data: unknown) {
const inner = extractEventData(data);
const status = String(inner.status ?? "").trim().toLowerCase();
const isUpdate = safeBool(inner.is_update ?? inner.isUpdate);
const isAutoAck = safeBool(inner.is_auto_ack ?? inner.isAutoAck);
return status !== "active" && !isUpdate && !isAutoAck;
}
function eventIncidentKey(data: unknown, eventType: string, ts: Date) { function eventIncidentKey(data: unknown, eventType: string, ts: Date) {
const inner = extractEventData(data); const inner = extractEventData(data);
const direct = String(inner.incidentKey ?? inner.incident_key ?? "").trim(); const direct = String(inner.incidentKey ?? inner.incident_key ?? "").trim();
@@ -208,9 +229,75 @@ function moldStartMs(data: unknown, fallbackTs: Date) {
return Math.trunc(safeNum(inner.start_ms) ?? safeNum(inner.startMs) ?? fallbackTs.getTime()); return Math.trunc(safeNum(inner.start_ms) ?? safeNum(inner.startMs) ?? fallbackTs.getTime());
} }
function avg(sum: number, count: number) { type WorkOrderCounterColumnRow = {
if (!count) return null; column_name: string;
return round2(sum / count); };
type WorkOrderCounterRow = {
machineId: string;
workOrderId: string;
sku: string | null;
targetQty: number | null;
status: string;
createdAt: Date;
updatedAt: Date;
goodParts: number;
scrapParts: number;
cycleCount: number;
};
async function loadWorkOrderCounterRows(params: {
orgId: string;
machineIds: string[];
start: Date;
end: Date;
}) {
if (!params.machineIds.length) return [] as WorkOrderCounterRow[];
try {
if (workOrderCountersAvailable == null) {
const columns = await prisma.$queryRaw<WorkOrderCounterColumnRow[]>`
SELECT column_name
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'machine_work_orders'
AND column_name IN ('good_parts', 'scrap_parts', 'cycle_count')
`;
const availableColumns = new Set(columns.map((row) => row.column_name));
workOrderCountersAvailable =
availableColumns.has("good_parts") &&
availableColumns.has("scrap_parts") &&
availableColumns.has("cycle_count");
}
if (!workOrderCountersAvailable) {
return null;
}
const machineIdList = Prisma.join(params.machineIds.map((id) => Prisma.sql`${id}`));
const rows = await prisma.$queryRaw<WorkOrderCounterRow[]>(Prisma.sql`
SELECT
"machineId",
"workOrderId",
sku,
"targetQty",
status,
"createdAt",
"updatedAt",
COALESCE(good_parts, 0)::int AS "goodParts",
COALESCE(scrap_parts, 0)::int AS "scrapParts",
COALESCE(cycle_count, 0)::int AS "cycleCount"
FROM "machine_work_orders"
WHERE "orgId" = ${params.orgId}
AND "machineId" IN (${machineIdList})
AND "updatedAt" >= ${params.start}
AND "updatedAt" <= ${params.end}
`);
return rows;
} catch {
return null;
}
} }
export function parseRecapQuery(input: { export function parseRecapQuery(input: {
@@ -250,7 +337,7 @@ async function computeRecap(params: Required<Pick<RecapQuery, "orgId">> & {
const machineIds = machines.map((m) => m.id); const machineIds = machines.map((m) => m.id);
const moldStartLookback = new Date(params.end.getTime() - MOLD_LOOKBACK_MS); const moldStartLookback = new Date(params.end.getTime() - MOLD_LOOKBACK_MS);
const [settings, shifts, cyclesRaw, kpisRaw, eventsRaw, reasonsRaw, workOrdersRaw, hbRangeRaw, hbLatestRaw, moldEventsRaw] = const [settings, shifts, cyclesRaw, kpisRaw, eventsRaw, reasonsRaw, workOrdersRaw, workOrderCounterRowsRaw, hbRangeRaw, hbLatestRaw, moldEventsRaw] =
await Promise.all([ await Promise.all([
prisma.orgSettings.findUnique({ prisma.orgSettings.findUnique({
where: { orgId: params.orgId }, where: { orgId: params.orgId },
@@ -283,6 +370,7 @@ async function computeRecap(params: Required<Pick<RecapQuery, "orgId">> & {
machineId: { in: machineIds }, machineId: { in: machineIds },
ts: { gte: params.start, lte: params.end }, ts: { gte: params.start, lte: params.end },
}, },
orderBy: [{ machineId: "asc" }, { ts: "asc" }],
select: { select: {
machineId: true, machineId: true,
ts: true, ts: true,
@@ -344,6 +432,12 @@ async function computeRecap(params: Required<Pick<RecapQuery, "orgId">> & {
updatedAt: true, updatedAt: true,
}, },
}), }),
loadWorkOrderCounterRows({
orgId: params.orgId,
machineIds,
start: params.start,
end: params.end,
}),
prisma.machineHeartbeat.findMany({ prisma.machineHeartbeat.findMany({
where: { where: {
orgId: params.orgId, orgId: params.orgId,
@@ -412,6 +506,7 @@ async function computeRecap(params: Required<Pick<RecapQuery, "orgId">> & {
const eventsByMachine = new Map<string, typeof events>(); const eventsByMachine = new Map<string, typeof events>();
const reasonsByMachine = new Map<string, typeof reasons>(); const reasonsByMachine = new Map<string, typeof reasons>();
const workOrdersByMachine = new Map<string, typeof workOrdersRaw>(); const workOrdersByMachine = new Map<string, typeof workOrdersRaw>();
const workOrderCountersByMachine = new Map<string, WorkOrderCounterRow[]>();
const hbRangeByMachine = new Map<string, typeof hbRange>(); const hbRangeByMachine = new Map<string, typeof hbRange>();
const hbLatestByMachine = new Map(hbLatestRaw.map((row) => [row.machineId, row])); const hbLatestByMachine = new Map(hbLatestRaw.map((row) => [row.machineId, row]));
const moldEventsByMachine = new Map<string, typeof moldEventsRaw>(); const moldEventsByMachine = new Map<string, typeof moldEventsRaw>();
@@ -446,6 +541,12 @@ async function computeRecap(params: Required<Pick<RecapQuery, "orgId">> & {
workOrdersByMachine.set(row.machineId, list); workOrdersByMachine.set(row.machineId, list);
} }
for (const row of workOrderCounterRowsRaw ?? []) {
const list = workOrderCountersByMachine.get(row.machineId) ?? [];
list.push(row);
workOrderCountersByMachine.set(row.machineId, list);
}
for (const row of hbRange) { for (const row of hbRange) {
const list = hbRangeByMachine.get(row.machineId) ?? []; const list = hbRangeByMachine.get(row.machineId) ?? [];
list.push(row); list.push(row);
@@ -464,6 +565,7 @@ async function computeRecap(params: Required<Pick<RecapQuery, "orgId">> & {
const machineEvents = eventsByMachine.get(machine.id) ?? []; const machineEvents = eventsByMachine.get(machine.id) ?? [];
const machineReasons = reasonsByMachine.get(machine.id) ?? []; const machineReasons = reasonsByMachine.get(machine.id) ?? [];
const machineWorkOrders = workOrdersByMachine.get(machine.id) ?? []; const machineWorkOrders = workOrdersByMachine.get(machine.id) ?? [];
const machineWorkOrderCounters = workOrderCountersByMachine.get(machine.id) ?? [];
const machineHbRange = hbRangeByMachine.get(machine.id) ?? []; const machineHbRange = hbRangeByMachine.get(machine.id) ?? [];
const latestHb = hbLatestByMachine.get(machine.id) ?? null; const latestHb = hbLatestByMachine.get(machine.id) ?? null;
const machineMoldEvents = moldEventsByMachine.get(machine.id) ?? []; const machineMoldEvents = moldEventsByMachine.get(machine.id) ?? [];
@@ -660,7 +762,63 @@ async function computeRecap(params: Required<Pick<RecapQuery, "orgId">> & {
} }
if (latestTelemetry?.sku) ensureSkuRow(latestTelemetry.sku); if (latestTelemetry?.sku) ensureSkuRow(latestTelemetry.sku);
const bySku = [...skuMap.values()] const hasAuthoritativeWorkOrderCounters = machineWorkOrderCounters.length > 0;
const authoritativeWorkOrderProgress = new Map<
string,
{ goodParts: number; scrapParts: number; cycleCount: number; firstTs: Date | null; lastTs: Date | null }
>();
const authoritativeSkuMap = new Map<string, SkuAggregate>();
let authoritativeGoodParts = 0;
let authoritativeScrapParts = 0;
let authoritativeCycleCount = 0;
if (hasAuthoritativeWorkOrderCounters) {
for (const row of machineWorkOrderCounters) {
const safeGood = Math.max(0, Math.trunc(safeNum(row.goodParts) ?? 0));
const safeScrap = Math.max(0, Math.trunc(safeNum(row.scrapParts) ?? 0));
const safeCycleCount = Math.max(0, Math.trunc(safeNum(row.cycleCount) ?? 0));
const skuToken = normalizeToken(row.sku) || "N/A";
const skuTokenKey = skuKey(skuToken);
const target = safeNum(row.targetQty);
const skuAgg = authoritativeSkuMap.get(skuTokenKey) ?? {
machineName: machine.name,
sku: skuToken,
good: 0,
scrap: 0,
target: target != null && target > 0 ? Math.max(0, Math.trunc(target)) : null,
};
skuAgg.good += safeGood;
skuAgg.scrap += safeScrap;
if (target != null && target > 0) {
skuAgg.target = (skuAgg.target ?? 0) + Math.max(0, Math.trunc(target));
}
authoritativeSkuMap.set(skuTokenKey, skuAgg);
authoritativeGoodParts += safeGood;
authoritativeScrapParts += safeScrap;
authoritativeCycleCount += safeCycleCount;
const woKey = workOrderKey(row.workOrderId);
if (!woKey) continue;
const progress = authoritativeWorkOrderProgress.get(woKey) ?? {
goodParts: 0,
scrapParts: 0,
cycleCount: 0,
firstTs: null,
lastTs: null,
};
progress.goodParts += safeGood;
progress.scrapParts += safeScrap;
progress.cycleCount += safeCycleCount;
if (!progress.firstTs || row.createdAt < progress.firstTs) progress.firstTs = row.createdAt;
if (!progress.lastTs || row.updatedAt > progress.lastTs) progress.lastTs = row.updatedAt;
authoritativeWorkOrderProgress.set(woKey, progress);
}
}
const fallbackBySku = [...skuMap.values()]
.map((row) => { .map((row) => {
const target = row.target ?? targetBySku.get(skuKey(row.sku))?.target ?? null; const target = row.target ?? targetBySku.get(skuKey(row.sku))?.target ?? null;
const produced = row.good + row.scrap; const produced = row.good + row.scrap;
@@ -676,44 +834,52 @@ async function computeRecap(params: Required<Pick<RecapQuery, "orgId">> & {
}) })
.sort((a, b) => b.good - a.good); .sort((a, b) => b.good - a.good);
let oeeSum = 0; const authoritativeBySku = [...authoritativeSkuMap.values()]
let oeeCount = 0; .map((row) => {
let availabilitySum = 0; const target = row.target ?? targetBySku.get(skuKey(row.sku))?.target ?? null;
let availabilityCount = 0; const produced = row.good + row.scrap;
let performanceSum = 0; const progressPct = target && target > 0 ? round2((produced / target) * 100) : null;
let performanceCount = 0; return {
let qualitySum = 0; machineName: row.machineName,
let qualityCount = 0; sku: row.sku,
good: row.good,
scrap: row.scrap,
target,
progressPct,
};
})
.sort((a, b) => b.good - a.good);
for (const kpi of dedupedKpis) { const bySku = hasAuthoritativeWorkOrderCounters ? authoritativeBySku : fallbackBySku;
const oee = safeNum(kpi.oee); if (hasAuthoritativeWorkOrderCounters) {
const availability = safeNum(kpi.availability); goodParts = authoritativeGoodParts;
const performance = safeNum(kpi.performance); scrapParts = authoritativeScrapParts;
const quality = safeNum(kpi.quality);
if (oee != null) {
oeeSum += oee;
oeeCount += 1;
}
if (availability != null) {
availabilitySum += availability;
availabilityCount += 1;
}
if (performance != null) {
performanceSum += performance;
performanceCount += 1;
}
if (quality != null) {
qualitySum += quality;
qualityCount += 1;
}
} }
const sortedKpis = [...dedupedKpis].sort((a, b) => a.ts.getTime() - b.ts.getTime());
const weightedAvg = (field: "oee" | "availability" | "performance" | "quality") => {
if (!sortedKpis.length) return null;
let totalMs = 0;
let weightedSum = 0;
for (let i = 0; i < sortedKpis.length; i += 1) {
const current = sortedKpis[i];
const nextTsMs = (sortedKpis[i + 1]?.ts ?? params.end).getTime();
const dt = Math.max(0, nextTsMs - current.ts.getTime());
if (dt <= 0) continue;
weightedSum += (safeNum(current[field]) ?? 0) * dt;
totalMs += dt;
}
return totalMs > 0 ? round2(weightedSum / totalMs) : null;
};
let stopDurSecFromEvents = 0; let stopDurSecFromEvents = 0;
let stopsCount = 0; let stopsCount = 0;
for (const event of machineEvents) { for (const event of machineEvents) {
const type = String(event.eventType || "").toLowerCase(); const type = String(event.eventType || "").toLowerCase();
if (!STOP_TYPES.has(type)) continue; if (!STOP_TYPES.has(type)) continue;
if (!isRealStopEvent(event.data)) continue;
stopsCount += 1; stopsCount += 1;
stopDurSecFromEvents += eventDurationSec(event.data); stopDurSecFromEvents += eventDurationSec(event.data);
} }
@@ -759,12 +925,14 @@ async function computeRecap(params: Required<Pick<RecapQuery, "orgId">> & {
.filter((wo) => String(wo.status).toUpperCase() === "COMPLETED") .filter((wo) => String(wo.status).toUpperCase() === "COMPLETED")
.filter((wo) => wo.updatedAt >= params.start && wo.updatedAt <= params.end) .filter((wo) => wo.updatedAt >= params.start && wo.updatedAt <= params.end)
.map((wo) => { .map((wo) => {
const progress = rangeByWorkOrder.get(workOrderKey(wo.workOrderId)) ?? { const fallbackProgress = rangeByWorkOrder.get(workOrderKey(wo.workOrderId)) ?? {
goodParts: 0, goodParts: 0,
scrapParts: 0, scrapParts: 0,
firstTs: null, firstTs: null,
lastTs: null, lastTs: null,
}; };
const authoritativeProgress = authoritativeWorkOrderProgress.get(workOrderKey(wo.workOrderId)) ?? null;
const progress = authoritativeProgress ?? fallbackProgress;
const durationHrs = const durationHrs =
progress.firstTs && progress.lastTs progress.firstTs && progress.lastTs
? round2((progress.lastTs.getTime() - progress.firstTs.getTime()) / 3600000) ? round2((progress.lastTs.getTime() - progress.firstTs.getTime()) / 3600000)
@@ -788,24 +956,37 @@ async function computeRecap(params: Required<Pick<RecapQuery, "orgId">> & {
const activeWorkOrderSku = const activeWorkOrderSku =
normalizeToken(latestTelemetry?.sku) || normalizeToken(activeWo?.sku) || null; normalizeToken(latestTelemetry?.sku) || normalizeToken(activeWo?.sku) || null;
const activeWorkOrderKey = workOrderKey(activeWorkOrderId); const activeWorkOrderKey = workOrderKey(activeWorkOrderId);
const authoritativeActiveWo =
activeWorkOrderKey && hasAuthoritativeWorkOrderCounters
? machineWorkOrderCounters.find((wo) => workOrderKey(wo.workOrderId) === activeWorkOrderKey) ?? null
: null;
const activeTargetSource = const activeTargetSource =
activeWorkOrderKey activeWorkOrderKey
? machineWorkOrdersSorted.find((wo) => workOrderKey(wo.workOrderId) === activeWorkOrderKey) ?? activeWo ? machineWorkOrdersSorted.find((wo) => workOrderKey(wo.workOrderId) === activeWorkOrderKey) ??
activeWo ??
authoritativeActiveWo
: activeWo; : activeWo;
let activeProgressPct: number | null = null; let activeProgressPct: number | null = null;
let activeStartedAt: string | null = null; let activeStartedAt: string | null = null;
if (activeWorkOrderId) { if (activeWorkOrderId) {
const rangeProgress = activeWorkOrderKey ? rangeByWorkOrder.get(activeWorkOrderKey) : null; const rangeProgress = activeWorkOrderKey ? rangeByWorkOrder.get(activeWorkOrderKey) : null;
const authoritativeProgress = activeWorkOrderKey
? authoritativeWorkOrderProgress.get(activeWorkOrderKey) ?? null
: null;
const cumulativeProgress = activeWorkOrderKey ? kpiLatestByWorkOrder.get(activeWorkOrderKey) : null; const cumulativeProgress = activeWorkOrderKey ? kpiLatestByWorkOrder.get(activeWorkOrderKey) : null;
const producedForProgress = cumulativeProgress const producedForProgress = authoritativeProgress
? cumulativeProgress.good + cumulativeProgress.scrap ? authoritativeProgress.goodParts + authoritativeProgress.scrapParts
: (rangeProgress?.goodParts ?? 0) + (rangeProgress?.scrapParts ?? 0); : cumulativeProgress
? cumulativeProgress.good + cumulativeProgress.scrap
: (rangeProgress?.goodParts ?? 0) + (rangeProgress?.scrapParts ?? 0);
const targetQty = safeNum(activeTargetSource?.targetQty); const targetQty = safeNum(activeTargetSource?.targetQty);
if (targetQty && targetQty > 0) { if (targetQty && targetQty > 0) {
activeProgressPct = round2((producedForProgress / targetQty) * 100); activeProgressPct = round2((producedForProgress / targetQty) * 100);
} }
activeStartedAt = toIso(rangeProgress?.firstTs ?? activeWo?.createdAt ?? latestTelemetry?.ts ?? null); activeStartedAt = toIso(
authoritativeProgress?.firstTs ?? rangeProgress?.firstTs ?? activeWo?.createdAt ?? latestTelemetry?.ts ?? null
);
} }
const moldActiveByIncident = new Map<string, number>(); const moldActiveByIncident = new Map<string, number>();
@@ -843,14 +1024,14 @@ async function computeRecap(params: Required<Pick<RecapQuery, "orgId">> & {
production: { production: {
goodParts, goodParts,
scrapParts, scrapParts,
totalCycles: dedupedCycles.length, totalCycles: hasAuthoritativeWorkOrderCounters ? authoritativeCycleCount : dedupedCycles.length,
bySku, bySku,
}, },
oee: { oee: {
avg: avg(oeeSum, oeeCount), avg: weightedAvg("oee"),
availability: avg(availabilitySum, availabilityCount), availability: weightedAvg("availability"),
performance: avg(performanceSum, performanceCount), performance: weightedAvg("performance"),
quality: avg(qualitySum, qualityCount), quality: weightedAvg("quality"),
}, },
downtime: { downtime: {
totalMin, totalMin,

679
lib/recap/redesign.ts Normal file
View File

@@ -0,0 +1,679 @@
import { unstable_cache } from "next/cache";
import { prisma } from "@/lib/prisma";
import { normalizeShiftOverrides, type ShiftOverrideDay } from "@/lib/settings";
import { getRecapDataCached } from "@/lib/recap/getRecapData";
import {
buildTimelineSegments,
compressTimelineSegments,
TIMELINE_EVENT_TYPES,
type TimelineCycleRow,
type TimelineEventRow,
} from "@/lib/recap/timeline";
import type {
RecapDetailResponse,
RecapMachine,
RecapMachineDetail,
RecapMachineStatus,
RecapRangeMode,
RecapSummaryMachine,
RecapSummaryResponse,
} from "@/lib/recap/types";
type DetailRangeInput = {
mode?: string | null;
start?: string | null;
end?: string | null;
};
const OFFLINE_THRESHOLD_MS = 10 * 60 * 1000;
const TIMELINE_EVENT_LOOKBACK_MS = 24 * 60 * 60 * 1000;
const RECAP_CACHE_TTL_SEC = 60;
const WEEKDAY_KEYS: ShiftOverrideDay[] = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"];
const WEEKDAY_KEY_MAP: Record<string, ShiftOverrideDay> = {
Mon: "mon",
Tue: "tue",
Wed: "wed",
Thu: "thu",
Fri: "fri",
Sat: "sat",
Sun: "sun",
};
function round2(value: number) {
return Math.round(value * 100) / 100;
}
function parseDate(input?: string | null) {
if (!input) return null;
const n = Number(input);
if (Number.isFinite(n)) {
const d = new Date(n);
return Number.isFinite(d.getTime()) ? d : null;
}
const d = new Date(input);
return Number.isFinite(d.getTime()) ? d : null;
}
function parseHours(input: string | null) {
const parsed = Math.trunc(Number(input ?? "24"));
if (!Number.isFinite(parsed)) return 24;
return Math.max(1, Math.min(72, parsed));
}
function parseTimeMinutes(input?: string | null) {
if (!input) return null;
const match = /^(\d{2}):(\d{2})$/.exec(input.trim());
if (!match) return null;
const hours = Number(match[1]);
const minutes = Number(match[2]);
if (!Number.isInteger(hours) || !Number.isInteger(minutes) || hours < 0 || hours > 23 || minutes < 0 || minutes > 59) {
return null;
}
return hours * 60 + minutes;
}
function getLocalParts(ts: Date, timeZone: string) {
try {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
weekday: "short",
hour12: false,
}).formatToParts(ts);
const value = (type: string) => parts.find((part) => part.type === type)?.value ?? "";
const year = Number(value("year"));
const month = Number(value("month"));
const day = Number(value("day"));
const hour = Number(value("hour"));
const minute = Number(value("minute"));
const weekday = value("weekday");
return {
year,
month,
day,
hour,
minute,
weekday: WEEKDAY_KEY_MAP[weekday] ?? WEEKDAY_KEYS[ts.getUTCDay()],
minutesOfDay: hour * 60 + minute,
};
} catch {
return {
year: ts.getUTCFullYear(),
month: ts.getUTCMonth() + 1,
day: ts.getUTCDate(),
hour: ts.getUTCHours(),
minute: ts.getUTCMinutes(),
weekday: WEEKDAY_KEYS[ts.getUTCDay()],
minutesOfDay: ts.getUTCHours() * 60 + ts.getUTCMinutes(),
};
}
}
function parseOffsetMinutes(offsetLabel: string | null) {
if (!offsetLabel) return null;
const normalized = offsetLabel.replace("UTC", "GMT");
const match = /^GMT([+-])(\d{1,2})(?::?(\d{2}))?$/.exec(normalized);
if (!match) return null;
const sign = match[1] === "-" ? -1 : 1;
const hour = Number(match[2]);
const minute = Number(match[3] ?? "0");
if (!Number.isFinite(hour) || !Number.isFinite(minute)) return null;
return sign * (hour * 60 + minute);
}
function getTzOffsetMinutes(utcDate: Date, timeZone: string) {
try {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone,
timeZoneName: "shortOffset",
hour: "2-digit",
}).formatToParts(utcDate);
const offsetPart = parts.find((part) => part.type === "timeZoneName")?.value ?? null;
return parseOffsetMinutes(offsetPart);
} catch {
return null;
}
}
function zonedToUtcDate(input: {
year: number;
month: number;
day: number;
hours: number;
minutes: number;
timeZone: string;
}) {
const baseUtc = Date.UTC(input.year, input.month - 1, input.day, input.hours, input.minutes, 0, 0);
const guessDate = new Date(baseUtc);
const offsetA = getTzOffsetMinutes(guessDate, input.timeZone);
if (offsetA == null) return guessDate;
let corrected = new Date(baseUtc - offsetA * 60000);
const offsetB = getTzOffsetMinutes(corrected, input.timeZone);
if (offsetB != null && offsetB !== offsetA) {
corrected = new Date(baseUtc - offsetB * 60000);
}
return corrected;
}
function addDays(input: { year: number; month: number; day: number }, days: number) {
const base = new Date(Date.UTC(input.year, input.month - 1, input.day));
base.setUTCDate(base.getUTCDate() + days);
return {
year: base.getUTCFullYear(),
month: base.getUTCMonth() + 1,
day: base.getUTCDate(),
};
}
function statusFromMachine(machine: RecapMachine, endMs: number) {
const lastSeenMs = machine.heartbeat.lastSeenAt ? new Date(machine.heartbeat.lastSeenAt).getTime() : null;
const offlineForMs = lastSeenMs == null ? Number.POSITIVE_INFINITY : Math.max(0, endMs - lastSeenMs);
const offline = !Number.isFinite(lastSeenMs ?? Number.NaN) || offlineForMs > OFFLINE_THRESHOLD_MS;
const ongoingStopMin = machine.downtime.ongoingStopMin ?? 0;
const moldActive = machine.workOrders.moldChangeInProgress;
let status: RecapMachineStatus = "running";
if (offline) status = "offline";
else if (moldActive) status = "mold-change";
else if (ongoingStopMin > 0) status = "stopped";
return {
status,
lastSeenMs,
offlineForMin: offline ? Math.max(0, Math.floor(offlineForMs / 60000)) : null,
ongoingStopMin: machine.downtime.ongoingStopMin,
};
}
async function loadTimelineRowsForMachines(params: {
orgId: string;
machineIds: string[];
start: Date;
end: Date;
}) {
if (!params.machineIds.length) {
return {
cyclesByMachine: new Map<string, TimelineCycleRow[]>(),
eventsByMachine: new Map<string, TimelineEventRow[]>(),
};
}
const [cycles, events] = await Promise.all([
prisma.machineCycle.findMany({
where: {
orgId: params.orgId,
machineId: { in: params.machineIds },
ts: { gte: params.start, lte: params.end },
},
orderBy: [{ machineId: "asc" }, { ts: "asc" }],
select: {
machineId: true,
ts: true,
cycleCount: true,
actualCycleTime: true,
workOrderId: true,
sku: true,
},
}),
prisma.machineEvent.findMany({
where: {
orgId: params.orgId,
machineId: { in: params.machineIds },
eventType: { in: TIMELINE_EVENT_TYPES as unknown as string[] },
ts: {
gte: new Date(params.start.getTime() - TIMELINE_EVENT_LOOKBACK_MS),
lte: params.end,
},
},
orderBy: [{ machineId: "asc" }, { ts: "asc" }],
select: {
machineId: true,
ts: true,
eventType: true,
data: true,
},
}),
]);
const cyclesByMachine = new Map<string, TimelineCycleRow[]>();
const eventsByMachine = new Map<string, TimelineEventRow[]>();
for (const row of cycles) {
const list = cyclesByMachine.get(row.machineId) ?? [];
list.push({
ts: row.ts,
cycleCount: row.cycleCount,
actualCycleTime: row.actualCycleTime,
workOrderId: row.workOrderId,
sku: row.sku,
});
cyclesByMachine.set(row.machineId, list);
}
for (const row of events) {
const list = eventsByMachine.get(row.machineId) ?? [];
list.push({
ts: row.ts,
eventType: row.eventType,
data: row.data,
});
eventsByMachine.set(row.machineId, list);
}
return { cyclesByMachine, eventsByMachine };
}
function toSummaryMachine(params: {
machine: RecapMachine;
miniTimeline: ReturnType<typeof compressTimelineSegments>;
rangeEndMs: number;
}): RecapSummaryMachine {
const { machine, miniTimeline, rangeEndMs } = params;
const status = statusFromMachine(machine, rangeEndMs);
return {
machineId: machine.machineId,
name: machine.machineName,
location: machine.location,
status: status.status,
oee: machine.oee.avg,
goodParts: machine.production.goodParts,
scrap: machine.production.scrapParts,
stopsCount: machine.downtime.stopsCount,
lastSeenMs: status.lastSeenMs,
lastActivityMin:
status.lastSeenMs == null ? null : Math.max(0, Math.floor((rangeEndMs - status.lastSeenMs) / 60000)),
offlineForMin: status.offlineForMin,
ongoingStopMin: status.ongoingStopMin,
activeWorkOrderId: machine.workOrders.active?.id ?? null,
moldChange: {
active: machine.workOrders.moldChangeInProgress,
startMs: machine.workOrders.moldChangeStartMs,
elapsedMin:
machine.workOrders.moldChangeStartMs == null
? null
: Math.max(0, Math.floor((rangeEndMs - machine.workOrders.moldChangeStartMs) / 60000)),
},
miniTimeline,
};
}
async function computeRecapSummary(params: { orgId: string; hours: number }) {
const now = new Date();
const end = new Date(Math.floor(now.getTime() / 60000) * 60000);
const start = new Date(end.getTime() - params.hours * 60 * 60 * 1000);
const recap = await getRecapDataCached({
orgId: params.orgId,
start,
end,
});
const machineIds = recap.machines.map((machine) => machine.machineId);
const timelineRows = await loadTimelineRowsForMachines({
orgId: params.orgId,
machineIds,
start,
end,
});
const machines = recap.machines.map((machine) => {
const segments = buildTimelineSegments({
cycles: timelineRows.cyclesByMachine.get(machine.machineId) ?? [],
events: timelineRows.eventsByMachine.get(machine.machineId) ?? [],
rangeStart: start,
rangeEnd: end,
});
const miniTimeline = compressTimelineSegments({
segments,
rangeStart: start,
rangeEnd: end,
maxSegments: 30,
});
return toSummaryMachine({
machine,
miniTimeline,
rangeEndMs: end.getTime(),
});
});
const response: RecapSummaryResponse = {
generatedAt: new Date().toISOString(),
range: {
start: start.toISOString(),
end: end.toISOString(),
hours: params.hours,
},
machines,
};
return response;
}
function normalizedRangeMode(mode?: string | null): RecapRangeMode {
const raw = String(mode ?? "").trim().toLowerCase();
if (raw === "shift") return "shift";
if (raw === "yesterday") return "yesterday";
if (raw === "custom") return "custom";
return "24h";
}
async function resolveCurrentShiftRange(params: { orgId: string; now: Date }) {
const settings = await prisma.orgSettings.findUnique({
where: { orgId: params.orgId },
select: {
timezone: true,
shiftScheduleOverridesJson: true,
},
});
const shifts = await prisma.orgShift.findMany({
where: { orgId: params.orgId },
orderBy: { sortOrder: "asc" },
select: {
name: true,
startTime: true,
endTime: true,
enabled: true,
sortOrder: true,
},
});
const enabledShifts = shifts.filter((shift) => shift.enabled !== false);
if (!enabledShifts.length) return null;
const timeZone = settings?.timezone || "UTC";
const local = getLocalParts(params.now, timeZone);
const overrides = normalizeShiftOverrides(settings?.shiftScheduleOverridesJson);
const dayOverrides = overrides?.[local.weekday];
const activeShifts = (dayOverrides?.length
? dayOverrides.map((shift) => ({
enabled: shift.enabled !== false,
start: shift.start,
end: shift.end,
}))
: enabledShifts.map((shift) => ({
enabled: shift.enabled !== false,
start: shift.startTime,
end: shift.endTime,
}))
).filter((shift) => shift.enabled);
for (const shift of activeShifts) {
const startMin = parseTimeMinutes(shift.start ?? null);
const endMin = parseTimeMinutes(shift.end ?? null);
if (startMin == null || endMin == null) continue;
const minutesNow = local.minutesOfDay;
let inRange = false;
let startDate = { year: local.year, month: local.month, day: local.day };
let endDate = { year: local.year, month: local.month, day: local.day };
if (startMin <= endMin) {
inRange = minutesNow >= startMin && minutesNow < endMin;
} else {
inRange = minutesNow >= startMin || minutesNow < endMin;
if (minutesNow >= startMin) {
endDate = addDays(endDate, 1);
} else {
startDate = addDays(startDate, -1);
}
}
if (!inRange) continue;
const start = zonedToUtcDate({
...startDate,
hours: Math.floor(startMin / 60),
minutes: startMin % 60,
timeZone,
});
const end = zonedToUtcDate({
...endDate,
hours: Math.floor(endMin / 60),
minutes: endMin % 60,
timeZone,
});
if (end <= start) continue;
return {
start,
end,
};
}
return null;
}
async function resolveDetailRange(params: { orgId: string; input: DetailRangeInput }) {
const now = new Date();
const mode = normalizedRangeMode(params.input.mode);
if (mode === "custom") {
const start = parseDate(params.input.start);
const end = parseDate(params.input.end);
if (start && end && end > start) {
return { mode, start, end };
}
}
if (mode === "yesterday") {
const end = new Date(now.getTime() - 24 * 60 * 60 * 1000);
const start = new Date(end.getTime() - 24 * 60 * 60 * 1000);
return { mode, start, end };
}
if (mode === "shift") {
const shiftRange = await resolveCurrentShiftRange({ orgId: params.orgId, now });
if (shiftRange) {
return {
mode,
start: shiftRange.start,
end: shiftRange.end,
};
}
}
return {
mode: "24h" as const,
start: new Date(now.getTime() - 24 * 60 * 60 * 1000),
end: now,
};
}
async function computeRecapMachineDetail(params: {
orgId: string;
machineId: string;
range: {
mode: RecapRangeMode;
start: Date;
end: Date;
};
}) {
const { range } = params;
const recap = await getRecapDataCached({
orgId: params.orgId,
machineId: params.machineId,
start: range.start,
end: range.end,
});
const machine = recap.machines.find((row) => row.machineId === params.machineId) ?? null;
if (!machine) return null;
const timelineRows = await loadTimelineRowsForMachines({
orgId: params.orgId,
machineIds: [params.machineId],
start: range.start,
end: range.end,
});
const timeline = buildTimelineSegments({
cycles: timelineRows.cyclesByMachine.get(params.machineId) ?? [],
events: timelineRows.eventsByMachine.get(params.machineId) ?? [],
rangeStart: range.start,
rangeEnd: range.end,
});
const status = statusFromMachine(machine, range.end.getTime());
const downtimeTotalMin = Math.max(0, machine.downtime.totalMin);
const downtimeTop = machine.downtime.topReasons.slice(0, 3).map((row) => ({
reasonLabel: row.reasonLabel,
minutes: row.minutes,
count: row.count,
percent: downtimeTotalMin > 0 ? round2((row.minutes / downtimeTotalMin) * 100) : 0,
}));
const machineDetail: RecapMachineDetail = {
machineId: machine.machineId,
name: machine.machineName,
location: machine.location,
status: status.status,
oee: machine.oee.avg,
goodParts: machine.production.goodParts,
scrap: machine.production.scrapParts,
stopsCount: machine.downtime.stopsCount,
stopMinutes: downtimeTotalMin,
activeWorkOrderId: machine.workOrders.active?.id ?? null,
lastSeenMs: status.lastSeenMs,
offlineForMin: status.offlineForMin,
ongoingStopMin: status.ongoingStopMin,
moldChange: {
active: machine.workOrders.moldChangeInProgress,
startMs: machine.workOrders.moldChangeStartMs,
},
timeline,
productionBySku: machine.production.bySku,
downtimeTop,
workOrders: {
completed: machine.workOrders.completed,
active: machine.workOrders.active,
},
heartbeat: {
lastSeenAt: machine.heartbeat.lastSeenAt,
uptimePct: machine.heartbeat.uptimePct,
connectionStatus: status.status === "offline" ? "offline" : "online",
},
};
const response: RecapDetailResponse = {
generatedAt: new Date().toISOString(),
range: {
mode: range.mode,
start: range.start.toISOString(),
end: range.end.toISOString(),
},
machine: machineDetail,
};
return response;
}
function summaryCacheKey(params: { orgId: string; hours: number }) {
return ["recap-summary-v1", params.orgId, String(params.hours)];
}
function detailCacheKey(params: {
orgId: string;
machineId: string;
mode: RecapRangeMode;
startMs: number;
endMs: number;
}) {
return [
"recap-detail-v1",
params.orgId,
params.machineId,
params.mode,
String(Math.trunc(params.startMs / 60000)),
String(Math.trunc(params.endMs / 60000)),
];
}
export function parseRecapSummaryHours(raw: string | null) {
return parseHours(raw);
}
export function parseRecapDetailRangeInput(searchParams: URLSearchParams | Record<string, string | string[] | undefined>) {
if (searchParams instanceof URLSearchParams) {
return {
mode: searchParams.get("range") ?? undefined,
start: searchParams.get("start") ?? undefined,
end: searchParams.get("end") ?? undefined,
};
}
const pick = (key: string) => {
const value = searchParams[key];
if (Array.isArray(value)) return value[0] ?? undefined;
return value ?? undefined;
};
return {
mode: pick("range"),
start: pick("start"),
end: pick("end"),
};
}
export async function getRecapSummaryCached(params: { orgId: string; hours: number }) {
const cache = unstable_cache(
() => computeRecapSummary(params),
summaryCacheKey(params),
{
revalidate: RECAP_CACHE_TTL_SEC,
tags: [`recap:${params.orgId}`],
}
);
return cache();
}
export async function getRecapMachineDetailCached(params: {
orgId: string;
machineId: string;
input: DetailRangeInput;
}) {
const resolved = await resolveDetailRange({
orgId: params.orgId,
input: params.input,
});
const cache = unstable_cache(
() =>
computeRecapMachineDetail({
orgId: params.orgId,
machineId: params.machineId,
range: {
mode: resolved.mode,
start: resolved.start,
end: resolved.end,
},
}),
detailCacheKey({
orgId: params.orgId,
machineId: params.machineId,
mode: resolved.mode,
startMs: resolved.start.getTime(),
endMs: resolved.end.getTime(),
}),
{
revalidate: RECAP_CACHE_TTL_SEC,
tags: [`recap:${params.orgId}`, `recap:${params.orgId}:${params.machineId}`],
}
);
return cache();
}

763
lib/recap/timeline.ts Normal file
View File

@@ -0,0 +1,763 @@
import type { RecapTimelineSegment } from "@/lib/recap/types";
const ACTIVE_STALE_MS = 2 * 60 * 1000;
const MERGE_GAP_MS = 30 * 1000;
const MICRO_CLUSTER_GAP_MS = 60 * 1000;
const ABSORB_SHORT_SEGMENT_MS = 30 * 1000;
export const TIMELINE_EVENT_TYPES = ["mold-change", "macrostop", "microstop"] as const;
type TimelineEventType = (typeof TIMELINE_EVENT_TYPES)[number];
type RawSegment =
| {
type: "production";
startMs: number;
endMs: number;
priority: number;
workOrderId: string | null;
sku: string | null;
label: string;
}
| {
type: "mold-change";
startMs: number;
endMs: number;
priority: number;
fromMoldId: string | null;
toMoldId: string | null;
durationSec: number;
label: string;
}
| {
type: "macrostop" | "microstop" | "slow-cycle";
startMs: number;
endMs: number;
priority: number;
reason: string | null;
durationSec: number;
label: string;
};
export type TimelineCycleRow = {
ts: Date;
cycleCount: number | null;
actualCycleTime: number;
workOrderId: string | null;
sku: string | null;
};
export type TimelineEventRow = {
ts: Date;
eventType: string;
data: unknown;
};
const PRIORITY: Record<string, number> = {
idle: 0,
production: 1,
microstop: 2,
"slow-cycle": 2,
macrostop: 3,
"mold-change": 4,
};
function safeNum(value: unknown) {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string" && value.trim()) {
const parsed = Number(value);
if (Number.isFinite(parsed)) return parsed;
}
return null;
}
function safeBool(value: unknown) {
if (typeof value === "boolean") return value;
if (typeof value === "number") return value !== 0;
if (typeof value === "string") {
const normalized = value.trim().toLowerCase();
if (!normalized) return false;
return normalized === "true" || normalized === "1" || normalized === "yes";
}
return false;
}
function normalizeToken(value: unknown) {
return String(value ?? "").trim();
}
function dedupeByKey<T>(rows: T[], keyFn: (row: T) => string) {
const seen = new Set<string>();
const out: T[] = [];
for (const row of rows) {
const key = keyFn(row);
if (seen.has(key)) continue;
seen.add(key);
out.push(row);
}
return out;
}
function extractData(value: unknown) {
let parsed: unknown = value;
if (typeof value === "string") {
try {
parsed = JSON.parse(value);
} catch {
parsed = null;
}
}
const record =
typeof parsed === "object" && parsed && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: {};
const nested = record.data;
if (typeof nested === "object" && nested && !Array.isArray(nested)) {
return nested as Record<string, unknown>;
}
return record;
}
function clampToRange(startMs: number, endMs: number, rangeStartMs: number, rangeEndMs: number) {
const clampedStart = Math.max(rangeStartMs, Math.min(rangeEndMs, startMs));
const clampedEnd = Math.max(rangeStartMs, Math.min(rangeEndMs, endMs));
if (clampedEnd <= clampedStart) return null;
return { startMs: clampedStart, endMs: clampedEnd };
}
function eventIncidentKey(eventType: string, data: Record<string, unknown>, fallbackTsMs: number) {
const key = String(data.incidentKey ?? data.incident_key ?? "").trim();
if (key) return key;
const alertId = String(data.alert_id ?? data.alertId ?? "").trim();
if (alertId) return `${eventType}:${alertId}`;
const startMs = safeNum(data.start_ms) ?? safeNum(data.startMs);
if (startMs != null) return `${eventType}:${Math.trunc(startMs)}`;
return `${eventType}:${fallbackTsMs}`;
}
function reasonLabelFromData(data: Record<string, unknown>) {
const direct =
String(data.reasonText ?? data.reason_label ?? data.reasonLabel ?? "").trim() || null;
if (direct) return direct;
const reason = data.reason;
if (typeof reason === "string") {
const text = reason.trim();
return text || null;
}
if (reason && typeof reason === "object" && !Array.isArray(reason)) {
const rec = reason as Record<string, unknown>;
const reasonText =
String(rec.reasonText ?? rec.reason_label ?? rec.reasonLabel ?? "").trim() || null;
if (reasonText) return reasonText;
const detail =
String(rec.detailLabel ?? rec.detail_label ?? rec.detailId ?? rec.detail_id ?? "").trim() ||
null;
const category =
String(rec.categoryLabel ?? rec.category_label ?? rec.categoryId ?? rec.category_id ?? "").trim() ||
null;
if (category && detail) return `${category} > ${detail}`;
if (detail) return detail;
if (category) return category;
}
return null;
}
function labelForStop(type: "macrostop" | "microstop" | "slow-cycle", reason: string | null) {
if (type === "macrostop") return reason ? `Paro: ${reason}` : "Paro";
if (type === "microstop") return reason ? `Microparo: ${reason}` : "Microparo";
return reason ? `Ciclo lento: ${reason}` : "Ciclo lento";
}
function normalizeStopType(type: "macrostop" | "microstop" | "slow-cycle"): "macrostop" | "microstop" {
return type === "macrostop" ? "macrostop" : "microstop";
}
function isEquivalent(a: RecapTimelineSegment, b: RecapTimelineSegment) {
if (a.type !== b.type) return false;
if (a.type === "idle" && b.type === "idle") return true;
if (a.type === "production" && b.type === "production") {
return a.workOrderId === b.workOrderId && a.sku === b.sku && a.label === b.label;
}
if (a.type === "mold-change" && b.type === "mold-change") {
return a.fromMoldId === b.fromMoldId && a.toMoldId === b.toMoldId;
}
if (
(a.type === "macrostop" || a.type === "microstop" || a.type === "slow-cycle") &&
(b.type === "macrostop" || b.type === "microstop" || b.type === "slow-cycle")
) {
return a.type === b.type && a.reason === b.reason;
}
return false;
}
function withDuration(segment: RecapTimelineSegment): RecapTimelineSegment {
if (segment.type === "production") {
return {
...segment,
durationSec: Math.max(0, Math.trunc((segment.endMs - segment.startMs) / 1000)),
};
}
if (segment.type === "mold-change") {
return {
...segment,
durationSec: Math.max(0, Math.trunc((segment.endMs - segment.startMs) / 1000)),
};
}
if (segment.type === "macrostop" || segment.type === "microstop" || segment.type === "slow-cycle") {
return {
...segment,
durationSec: Math.max(0, Math.trunc((segment.endMs - segment.startMs) / 1000)),
};
}
return {
...segment,
durationSec: Math.max(0, Math.trunc((segment.endMs - segment.startMs) / 1000)),
};
}
function cloneSegment(segment: RecapTimelineSegment): RecapTimelineSegment {
return { ...segment };
}
function mergeNearbyEquivalentSegments(segments: RecapTimelineSegment[], maxGapMs: number) {
const ordered = [...segments]
.map((segment) => withDuration(segment))
.filter((segment) => segment.endMs > segment.startMs)
.sort((a, b) => a.startMs - b.startMs || a.endMs - b.endMs);
const merged: RecapTimelineSegment[] = [];
for (const current of ordered) {
const prev = merged[merged.length - 1];
if (!prev) {
merged.push(cloneSegment(current));
continue;
}
const gapMs = current.startMs - prev.endMs;
if (gapMs <= maxGapMs && isEquivalent(prev, current)) {
prev.endMs = Math.max(prev.endMs, current.endMs);
const normalized = withDuration(prev);
Object.assign(prev, normalized);
continue;
}
if (current.startMs < prev.endMs) {
const clipped = { ...current, startMs: prev.endMs };
if (clipped.endMs <= clipped.startMs) continue;
merged.push(withDuration(clipped));
continue;
}
merged.push(cloneSegment(current));
}
return merged;
}
function fillGapsWithIdle(segments: RecapTimelineSegment[], rangeStartMs: number, rangeEndMs: number) {
const ordered = [...segments]
.map((segment) => {
const startMs = Math.max(rangeStartMs, segment.startMs);
const endMs = Math.min(rangeEndMs, segment.endMs);
if (endMs <= startMs) return null;
return withDuration({ ...segment, startMs, endMs });
})
.filter((segment): segment is RecapTimelineSegment => !!segment)
.sort((a, b) => a.startMs - b.startMs || a.endMs - b.endMs);
const out: RecapTimelineSegment[] = [];
let cursor = rangeStartMs;
for (const segment of ordered) {
if (segment.startMs > cursor) {
out.push({
type: "idle",
startMs: cursor,
endMs: segment.startMs,
durationSec: Math.max(0, Math.trunc((segment.startMs - cursor) / 1000)),
label: "Idle",
});
}
const startMs = Math.max(cursor, segment.startMs);
const endMs = Math.min(rangeEndMs, segment.endMs);
if (endMs <= startMs) continue;
out.push(withDuration({ ...segment, startMs, endMs }));
cursor = endMs;
if (cursor >= rangeEndMs) break;
}
if (cursor < rangeEndMs) {
out.push({
type: "idle",
startMs: cursor,
endMs: rangeEndMs,
durationSec: Math.max(0, Math.trunc((rangeEndMs - cursor) / 1000)),
label: "Idle",
});
}
return mergeNearbyEquivalentSegments(out, 0);
}
function absorbMicroStopClusters(segments: RecapTimelineSegment[], maxGapMs: number) {
const out: RecapTimelineSegment[] = [];
let i = 0;
while (i < segments.length) {
const first = segments[i];
if (first.type !== "microstop") {
out.push(cloneSegment(first));
i += 1;
continue;
}
let clusterEndMs = first.endMs;
let count = 1;
const reasons = new Set<string>();
if (first.reason) reasons.add(first.reason);
let cursor = i;
while (cursor + 2 < segments.length) {
const gap = segments[cursor + 1];
const next = segments[cursor + 2];
if (next.type !== "microstop") break;
if (gap.type === "macrostop" || gap.type === "mold-change") break;
const gapMs = Math.max(0, gap.endMs - gap.startMs);
if (gapMs >= maxGapMs) break;
clusterEndMs = next.endMs;
if (next.reason) reasons.add(next.reason);
count += 1;
cursor += 2;
}
if (count === 1) {
out.push(cloneSegment(first));
i += 1;
continue;
}
const reason = reasons.size === 1 ? (Array.from(reasons)[0] ?? null) : null;
out.push({
type: "microstop",
startMs: first.startMs,
endMs: clusterEndMs,
reason,
reasonLabel: reason,
durationSec: Math.max(0, Math.trunc((clusterEndMs - first.startMs) / 1000)),
label: reason ? `Microparo (${count}) · ${reason}` : `Microparo (${count})`,
});
i = cursor + 1;
}
return mergeNearbyEquivalentSegments(out, 0);
}
function absorbShortSegments(segments: RecapTimelineSegment[], minDurationMs: number) {
const out = segments.map((segment) => withDuration(cloneSegment(segment)));
let index = 0;
while (index < out.length) {
const current = out[index];
const durationMs = Math.max(0, current.endMs - current.startMs);
if (durationMs >= minDurationMs || out.length === 1) {
index += 1;
continue;
}
const prev = out[index - 1] ?? null;
const next = out[index + 1] ?? null;
if (!prev && !next) break;
if (!prev && next) {
next.startMs = current.startMs;
out.splice(index, 1);
continue;
}
if (prev && !next) {
prev.endMs = current.endMs;
out.splice(index, 1);
index = Math.max(0, index - 1);
continue;
}
const prevDurationMs = Math.max(0, (prev?.endMs ?? 0) - (prev?.startMs ?? 0));
const nextDurationMs = Math.max(0, (next?.endMs ?? 0) - (next?.startMs ?? 0));
const absorbIntoPrev = prevDurationMs >= nextDurationMs;
if (absorbIntoPrev && prev) {
prev.endMs = current.endMs;
out.splice(index, 1);
index = Math.max(0, index - 1);
continue;
}
if (next) {
next.startMs = current.startMs;
out.splice(index, 1);
continue;
}
index += 1;
}
return mergeNearbyEquivalentSegments(out.map((segment) => withDuration(segment)), MERGE_GAP_MS);
}
function buildSegmentsFromBoundaries(rawSegments: RawSegment[], rangeStartMs: number, rangeEndMs: number) {
const clipped = rawSegments
.map((segment) => {
const range = clampToRange(segment.startMs, segment.endMs, rangeStartMs, rangeEndMs);
return range ? { ...segment, ...range } : null;
})
.filter((segment): segment is RawSegment => !!segment);
const boundaries = new Set<number>([rangeStartMs, rangeEndMs]);
for (const segment of clipped) {
boundaries.add(segment.startMs);
boundaries.add(segment.endMs);
}
const orderedBoundaries = Array.from(boundaries).sort((a, b) => a - b);
const timeline: RecapTimelineSegment[] = [];
for (let i = 0; i < orderedBoundaries.length - 1; i += 1) {
const intervalStart = orderedBoundaries[i];
const intervalEnd = orderedBoundaries[i + 1];
if (intervalEnd <= intervalStart) continue;
const covering = clipped
.filter((segment) => segment.startMs < intervalEnd && segment.endMs > intervalStart)
.sort((a, b) => b.priority - a.priority || b.startMs - a.startMs);
const winner = covering[0];
if (!winner) continue;
if (winner.type === "production") {
timeline.push({
type: "production",
startMs: intervalStart,
endMs: intervalEnd,
durationSec: Math.max(0, Math.trunc((intervalEnd - intervalStart) / 1000)),
workOrderId: winner.workOrderId,
sku: winner.sku,
label: winner.label,
});
continue;
}
if (winner.type === "mold-change") {
timeline.push({
type: "mold-change",
startMs: intervalStart,
endMs: intervalEnd,
fromMoldId: winner.fromMoldId,
toMoldId: winner.toMoldId,
durationSec: Math.max(0, Math.trunc((intervalEnd - intervalStart) / 1000)),
label: winner.label,
});
continue;
}
const stopType = normalizeStopType(winner.type);
timeline.push({
type: stopType,
startMs: intervalStart,
endMs: intervalEnd,
reason: winner.reason,
reasonLabel: winner.reason,
durationSec: Math.max(0, Math.trunc((intervalEnd - intervalStart) / 1000)),
label: labelForStop(stopType, winner.reason),
});
}
return timeline;
}
function segmentPriority(type: RecapTimelineSegment["type"]) {
if (type === "mold-change") return 4;
if (type === "macrostop") return 3;
if (type === "microstop" || type === "slow-cycle") return 2;
if (type === "production") return 1;
return 0;
}
function cloneForRange(segment: RecapTimelineSegment, startMs: number, endMs: number): RecapTimelineSegment {
if (segment.type === "production") {
return {
type: "production",
startMs,
endMs,
durationSec: Math.max(0, Math.trunc((endMs - startMs) / 1000)),
workOrderId: segment.workOrderId,
sku: segment.sku,
label: segment.label,
};
}
if (segment.type === "mold-change") {
return {
type: "mold-change",
startMs,
endMs,
fromMoldId: segment.fromMoldId,
toMoldId: segment.toMoldId,
durationSec: Math.max(0, Math.trunc((endMs - startMs) / 1000)),
label: segment.label,
};
}
if (segment.type === "macrostop" || segment.type === "microstop" || segment.type === "slow-cycle") {
const stopType = normalizeStopType(segment.type);
return {
type: stopType,
startMs,
endMs,
reason: segment.reason,
reasonLabel: segment.reasonLabel ?? segment.reason,
durationSec: Math.max(0, Math.trunc((endMs - startMs) / 1000)),
label: segment.label,
};
}
return {
type: "idle",
startMs,
endMs,
durationSec: Math.max(0, Math.trunc((endMs - startMs) / 1000)),
label: segment.label,
};
}
export function buildTimelineSegments(input: {
cycles: TimelineCycleRow[];
events: TimelineEventRow[];
rangeStart: Date;
rangeEnd: Date;
}) {
const rangeStartMs = input.rangeStart.getTime();
const rangeEndMs = input.rangeEnd.getTime();
if (!Number.isFinite(rangeStartMs) || !Number.isFinite(rangeEndMs) || rangeEndMs <= rangeStartMs) {
return [] as RecapTimelineSegment[];
}
const dedupedCycles = dedupeByKey(
input.cycles,
(cycle) =>
`${cycle.ts.getTime()}:${safeNum(cycle.cycleCount) ?? "na"}:${normalizeToken(cycle.workOrderId).toUpperCase()}:${normalizeToken(cycle.sku).toUpperCase()}:${safeNum(cycle.actualCycleTime) ?? "na"}`
);
const rawSegments: RawSegment[] = [];
let currentProduction: RawSegment | null = null;
for (const cycle of dedupedCycles) {
if (!cycle.workOrderId) continue;
const cycleStartMs = cycle.ts.getTime();
const cycleDurationMs = Math.max(
1000,
Math.min(600000, Math.trunc((safeNum(cycle.actualCycleTime) ?? 1) * 1000))
);
const cycleEndMs = cycleStartMs + cycleDurationMs;
if (
currentProduction &&
currentProduction.type === "production" &&
currentProduction.workOrderId === cycle.workOrderId &&
currentProduction.sku === cycle.sku &&
cycleStartMs <= currentProduction.endMs + 5 * 60 * 1000
) {
currentProduction.endMs = Math.max(currentProduction.endMs, cycleEndMs);
continue;
}
if (currentProduction) rawSegments.push(currentProduction);
currentProduction = {
type: "production",
startMs: cycleStartMs,
endMs: cycleEndMs,
priority: PRIORITY.production,
workOrderId: cycle.workOrderId,
sku: cycle.sku,
label: cycle.workOrderId,
};
}
if (currentProduction) rawSegments.push(currentProduction);
const eventEpisodes = new Map<
string,
{
type: "mold-change" | "macrostop" | "microstop";
firstTsMs: number;
lastTsMs: number;
startMs: number | null;
endMs: number | null;
durationSec: number | null;
statusActive: boolean;
statusResolved: boolean;
reason: string | null;
fromMoldId: string | null;
toMoldId: string | null;
}
>();
for (const event of input.events) {
const eventType = String(event.eventType || "").toLowerCase() as TimelineEventType;
if (!TIMELINE_EVENT_TYPES.includes(eventType)) continue;
const data = extractData(event.data);
const isUpdate = safeBool(data.is_update ?? data.isUpdate);
const isAutoAck = safeBool(data.is_auto_ack ?? data.isAutoAck);
if (isUpdate || isAutoAck) continue;
const tsMs = event.ts.getTime();
const key = eventIncidentKey(eventType, data, tsMs);
const status = String(data.status ?? "").trim().toLowerCase();
const episode = eventEpisodes.get(key) ?? {
type: eventType,
firstTsMs: tsMs,
lastTsMs: tsMs,
startMs: null,
endMs: null,
durationSec: null,
statusActive: false,
statusResolved: false,
reason: null,
fromMoldId: null,
toMoldId: null,
};
episode.firstTsMs = Math.min(episode.firstTsMs, tsMs);
episode.lastTsMs = Math.max(episode.lastTsMs, tsMs);
const startMs = safeNum(data.start_ms) ?? safeNum(data.startMs);
const endMs = safeNum(data.end_ms) ?? safeNum(data.endMs);
const durationSec =
safeNum(data.duration_sec) ??
safeNum(data.stoppage_duration_seconds) ??
safeNum(data.stop_duration_seconds) ??
safeNum(data.duration_seconds);
if (startMs != null) episode.startMs = episode.startMs == null ? startMs : Math.min(episode.startMs, startMs);
if (endMs != null) episode.endMs = episode.endMs == null ? endMs : Math.max(episode.endMs, endMs);
if (durationSec != null) episode.durationSec = Math.max(0, Math.trunc(durationSec));
if (status === "active") episode.statusActive = true;
if (status === "resolved") episode.statusResolved = true;
const reason = reasonLabelFromData(data);
if (reason) episode.reason = reason;
const fromMoldId = String(data.from_mold_id ?? data.fromMoldId ?? "").trim() || null;
const toMoldId = String(data.to_mold_id ?? data.toMoldId ?? "").trim() || null;
if (fromMoldId) episode.fromMoldId = fromMoldId;
if (toMoldId) episode.toMoldId = toMoldId;
eventEpisodes.set(key, episode);
}
for (const episode of eventEpisodes.values()) {
const startMs = Math.trunc(episode.startMs ?? episode.firstTsMs);
let endMs = Math.trunc(episode.endMs ?? episode.lastTsMs);
if (episode.statusActive && !episode.statusResolved) {
const isFreshActive = rangeEndMs - episode.lastTsMs <= ACTIVE_STALE_MS;
endMs = isFreshActive ? rangeEndMs : episode.lastTsMs;
} else if (endMs <= startMs && episode.durationSec != null && episode.durationSec > 0) {
endMs = startMs + episode.durationSec * 1000;
}
if (endMs <= startMs) continue;
if (episode.type === "mold-change") {
rawSegments.push({
type: "mold-change",
startMs,
endMs,
priority: PRIORITY["mold-change"],
fromMoldId: episode.fromMoldId,
toMoldId: episode.toMoldId,
durationSec: Math.max(0, Math.trunc((endMs - startMs) / 1000)),
label: episode.toMoldId ? `Cambio molde ${episode.toMoldId}` : "Cambio molde",
});
continue;
}
rawSegments.push({
type: episode.type,
startMs,
endMs,
priority: PRIORITY[episode.type],
reason: episode.reason,
durationSec: Math.max(0, Math.trunc((endMs - startMs) / 1000)),
label: labelForStop(episode.type, episode.reason),
});
}
const initial = buildSegmentsFromBoundaries(rawSegments, rangeStartMs, rangeEndMs);
const merged = mergeNearbyEquivalentSegments(initial, MERGE_GAP_MS);
const withIdle = fillGapsWithIdle(merged, rangeStartMs, rangeEndMs);
const clustered = absorbMicroStopClusters(withIdle, MICRO_CLUSTER_GAP_MS);
const normalized = fillGapsWithIdle(clustered, rangeStartMs, rangeEndMs);
const absorbed = absorbShortSegments(normalized, ABSORB_SHORT_SEGMENT_MS);
const finalSegments = fillGapsWithIdle(absorbed, rangeStartMs, rangeEndMs);
return finalSegments;
}
export function compressTimelineSegments(input: {
segments: RecapTimelineSegment[];
rangeStart: Date;
rangeEnd: Date;
maxSegments: number;
}) {
const rangeStartMs = input.rangeStart.getTime();
const rangeEndMs = input.rangeEnd.getTime();
const maxSegments = Math.max(1, Math.trunc(input.maxSegments || 1));
const normalized = fillGapsWithIdle(input.segments, rangeStartMs, rangeEndMs);
if (normalized.length <= maxSegments) return normalized;
const totalMs = Math.max(1, rangeEndMs - rangeStartMs);
const bucketMs = totalMs / maxSegments;
const buckets: RecapTimelineSegment[] = [];
for (let i = 0; i < maxSegments; i += 1) {
const bucketStart = Math.trunc(rangeStartMs + i * bucketMs);
const bucketEnd = i === maxSegments - 1 ? rangeEndMs : Math.trunc(rangeStartMs + (i + 1) * bucketMs);
if (bucketEnd <= bucketStart) continue;
let winner: RecapTimelineSegment | null = null;
let winnerOverlap = -1;
for (const segment of normalized) {
const overlapStart = Math.max(bucketStart, segment.startMs);
const overlapEnd = Math.min(bucketEnd, segment.endMs);
if (overlapEnd <= overlapStart) continue;
const overlap = overlapEnd - overlapStart;
const priorityBonus = segmentPriority(segment.type) / 1000;
const score = overlap + priorityBonus;
if (score > winnerOverlap) {
winner = segment;
winnerOverlap = score;
}
}
if (!winner) {
buckets.push({
type: "idle",
startMs: bucketStart,
endMs: bucketEnd,
durationSec: Math.max(0, Math.trunc((bucketEnd - bucketStart) / 1000)),
label: "Idle",
});
continue;
}
buckets.push(cloneForRange(winner, bucketStart, bucketEnd));
}
const merged = mergeNearbyEquivalentSegments(buckets, 0);
return fillGapsWithIdle(merged, rangeStartMs, rangeEndMs);
}

185
lib/recap/timelineApi.ts Normal file
View File

@@ -0,0 +1,185 @@
import { prisma } from "@/lib/prisma";
import {
buildTimelineSegments,
compressTimelineSegments,
TIMELINE_EVENT_TYPES,
type TimelineCycleRow,
type TimelineEventRow,
} from "@/lib/recap/timeline";
import type { RecapTimelineResponse } from "@/lib/recap/types";
const TIMELINE_EVENT_LOOKBACK_MS = 24 * 60 * 60 * 1000;
const DEFAULT_RANGE_MS = 24 * 60 * 60 * 1000;
const MIN_RANGE_MS = 60 * 1000;
const MAX_RANGE_MS = 72 * 60 * 60 * 1000;
function parseDateInput(raw: string | null) {
if (!raw) return null;
const asNum = Number(raw);
if (Number.isFinite(asNum)) {
const d = new Date(asNum);
return Number.isFinite(d.getTime()) ? d : null;
}
const d = new Date(raw);
return Number.isFinite(d.getTime()) ? d : null;
}
function parseRangeDurationMs(raw: string | null) {
if (!raw) return null;
const normalized = raw.trim().toLowerCase();
const match = /^(\d+)\s*([hm])$/.exec(normalized);
if (!match) return null;
const amount = Number(match[1]);
if (!Number.isFinite(amount) || amount <= 0) return null;
const unit = match[2];
const durationMs = unit === "m" ? amount * 60_000 : amount * 60 * 60_000;
return Math.max(MIN_RANGE_MS, Math.min(MAX_RANGE_MS, durationMs));
}
function parseHours(raw: string | null) {
if (!raw) return null;
const parsed = Math.trunc(Number(raw));
if (!Number.isFinite(parsed) || parsed <= 0) return null;
return Math.max(1, Math.min(72, parsed));
}
function parseMaxSegments(searchParams: URLSearchParams) {
const compact = searchParams.get("compact");
const maxSegmentsRaw = searchParams.get("maxSegments");
if (compact !== "1" && compact !== "true" && !maxSegmentsRaw) return null;
const parsed = Math.trunc(Number(maxSegmentsRaw ?? "30"));
if (!Number.isFinite(parsed) || parsed <= 0) return 30;
return Math.max(5, Math.min(120, parsed));
}
export function parseRecapTimelineRange(searchParams: URLSearchParams) {
const end = parseDateInput(searchParams.get("end")) ?? new Date();
const startParam = parseDateInput(searchParams.get("start"));
if (startParam && startParam < end) {
return {
start: startParam,
end,
maxSegments: parseMaxSegments(searchParams),
};
}
const rangeDurationMs =
parseRangeDurationMs(searchParams.get("range")) ??
(() => {
const hours = parseHours(searchParams.get("hours"));
return hours ? hours * 60 * 60 * 1000 : null;
})() ??
DEFAULT_RANGE_MS;
const start = new Date(end.getTime() - Math.max(MIN_RANGE_MS, Math.min(MAX_RANGE_MS, rangeDurationMs)));
return {
start,
end,
maxSegments: parseMaxSegments(searchParams),
};
}
export async function getRecapTimelineForMachine(params: {
orgId: string;
machineId: string;
start: Date;
end: Date;
maxSegments?: number | null;
}) {
const [cyclesRaw, eventsRaw, cycleCount, eventCount] = await Promise.all([
prisma.machineCycle.findMany({
where: {
orgId: params.orgId,
machineId: params.machineId,
ts: { gte: params.start, lte: params.end },
},
orderBy: { ts: "asc" },
select: {
ts: true,
cycleCount: true,
actualCycleTime: true,
workOrderId: true,
sku: true,
},
}),
prisma.machineEvent.findMany({
where: {
orgId: params.orgId,
machineId: params.machineId,
eventType: { in: TIMELINE_EVENT_TYPES as unknown as string[] },
ts: {
gte: new Date(params.start.getTime() - TIMELINE_EVENT_LOOKBACK_MS),
lte: params.end,
},
},
orderBy: { ts: "asc" },
select: {
ts: true,
eventType: true,
data: true,
},
}),
prisma.machineCycle.count({
where: {
orgId: params.orgId,
machineId: params.machineId,
ts: { gte: params.start, lte: params.end },
},
}),
prisma.machineEvent.count({
where: {
orgId: params.orgId,
machineId: params.machineId,
ts: { gte: params.start, lte: params.end },
},
}),
]);
const hasData = cycleCount > 0 || eventCount > 0;
const cycles: TimelineCycleRow[] = cyclesRaw.map((row) => ({
ts: row.ts,
cycleCount: row.cycleCount,
actualCycleTime: row.actualCycleTime,
workOrderId: row.workOrderId,
sku: row.sku,
}));
const events: TimelineEventRow[] = eventsRaw.map((row) => ({
ts: row.ts,
eventType: row.eventType,
data: row.data,
}));
let segments = hasData
? buildTimelineSegments({
cycles,
events,
rangeStart: params.start,
rangeEnd: params.end,
})
: [];
if (hasData && params.maxSegments && params.maxSegments > 0) {
segments = compressTimelineSegments({
segments,
rangeStart: params.start,
rangeEnd: params.end,
maxSegments: params.maxSegments,
});
}
const response: RecapTimelineResponse = {
range: {
start: params.start.toISOString(),
end: params.end.toISOString(),
},
segments,
hasData,
generatedAt: new Date().toISOString(),
};
return response;
}

View File

@@ -60,6 +60,7 @@ export type RecapTimelineSegment =
type: "production"; type: "production";
startMs: number; startMs: number;
endMs: number; endMs: number;
durationSec: number;
workOrderId: string | null; workOrderId: string | null;
sku: string | null; sku: string | null;
label: string; label: string;
@@ -78,6 +79,7 @@ export type RecapTimelineSegment =
startMs: number; startMs: number;
endMs: number; endMs: number;
reason: string | null; reason: string | null;
reasonLabel?: string | null;
durationSec: number; durationSec: number;
label: string; label: string;
} }
@@ -85,6 +87,7 @@ export type RecapTimelineSegment =
type: "idle"; type: "idle";
startMs: number; startMs: number;
endMs: number; endMs: number;
durationSec: number;
label: string; label: string;
}; };
@@ -94,6 +97,8 @@ export type RecapTimelineResponse = {
end: string; end: string;
}; };
segments: RecapTimelineSegment[]; segments: RecapTimelineSegment[];
hasData: boolean;
generatedAt: string;
}; };
export type RecapResponse = { export type RecapResponse = {
@@ -115,3 +120,100 @@ export type RecapQuery = {
end?: Date; end?: Date;
shift?: string; shift?: string;
}; };
export type RecapMachineStatus = "running" | "mold-change" | "stopped" | "offline";
export type RecapSummaryMachine = {
machineId: string;
name: string;
location: string | null;
status: RecapMachineStatus;
oee: number | null;
goodParts: number;
scrap: number;
stopsCount: number;
lastSeenMs: number | null;
lastActivityMin: number | null;
offlineForMin: number | null;
ongoingStopMin: number | null;
activeWorkOrderId: string | null;
moldChange: {
active: boolean;
startMs: number | null;
elapsedMin: number | null;
} | null;
miniTimeline: RecapTimelineSegment[];
};
export type RecapSummaryResponse = {
generatedAt: string;
range: {
start: string;
end: string;
hours: number;
};
machines: RecapSummaryMachine[];
};
export type RecapRangeMode = "24h" | "shift" | "yesterday" | "custom";
export type RecapDowntimeTopRow = {
reasonLabel: string;
minutes: number;
count: number;
percent: number;
};
export type RecapWorkOrders = {
completed: Array<{
id: string;
sku: string | null;
goodParts: number;
durationHrs: number;
}>;
active: {
id: string;
sku: string | null;
progressPct: number | null;
startedAt: string | null;
} | null;
};
export type RecapMachineDetail = {
machineId: string;
name: string;
location: string | null;
status: RecapMachineStatus;
oee: number | null;
goodParts: number;
scrap: number;
stopsCount: number;
stopMinutes: number;
activeWorkOrderId: string | null;
lastSeenMs: number | null;
offlineForMin: number | null;
ongoingStopMin: number | null;
moldChange: {
active: boolean;
startMs: number | null;
} | null;
timeline: RecapTimelineSegment[];
productionBySku: RecapSkuRow[];
downtimeTop: RecapDowntimeTopRow[];
workOrders: RecapWorkOrders;
heartbeat: {
lastSeenAt: string | null;
uptimePct: number | null;
connectionStatus: "online" | "offline";
};
};
export type RecapDetailResponse = {
generatedAt: string;
range: {
mode: RecapRangeMode;
start: string;
end: string;
};
machine: RecapMachineDetail;
};

144
recap_fix.md Normal file
View File

@@ -0,0 +1,144 @@
Recap Redesign — Handoff Prompt
Goal
Replace the current aggregated /recap view with a two-level drill-down: machine grid → machine-specific 24h detail. One machine = one clear story. No mixed averages.
Architecture
/recap → grid of machine cards (overview)
/recap/[machineId] → full recap detail for one machine
Level 1: /recap (grid)
Layout
Reuse the pattern from app/(app)/machines/MachinesClient.tsx. Same card grid, same responsive breakpoints, same filters (location, status).
Card contents (per machine)
Header: machine name + location + status dot (green=running, amber=mold-change, red=stopped, gray=offline)
Big number: today's OEE % (or good parts — pick one primary metric and stick with it)
Secondary row: good parts · scrap · stops count
Mini timeline bar: compressed 24h bar (height 20px), same color coding as detail page. No labels, tooltip only. Clicking anywhere navigates to detail.
Footer: "Última actividad hace X min" or current WO id if active
Banners (inline, colored):
If mold-change active → amber: "Cambio de molde en curso · Xm"
If machine offline >10 min → red: "Sin señal hace Xm"
Data source
New endpoint app/api/recap/summary/route.ts — returns array of per-machine summaries in one query. Cache 60s.
GET /api/recap/summary?hours=24
→ {
machines: [{
machineId, name, location, status,
oee, goodParts, scrap, stopsCount,
lastSeenMs, activeWorkOrderId,
moldChange: { active, startMs } | null,
miniTimeline: Segment[] // compressed, max ~30 segments
}]
}
Empty / loading states
Skeleton cards while loading (pulse animation, same size as real card).
Zero-activity machine: card renders but with "Sin producción" muted text, gray mini bar, metric "—".
Level 2: /recap/[machineId]
Layout (top to bottom)
Back arrow + machine name breadcrumb — ← Todas las máquinas / M4-5
Range picker — 24h / Turno actual / Ayer / Personalizado (top-right)
Banners — mold-change / offline / ongoing-stop (full-width, colored)
KPI row (4 cards) — OEE, Buenas, Paros totales (min), Scrap
Timeline 24h — full-width smooth bar (see fix from previous message: min 1.5% width, no dots, merged consecutive stops)
Two-column row:
Left: Producción por SKU (table) — SKU | Buenas | Scrap | Meta | Avance%
Right: Top downtime (pareto) — top 3 reasons with minutes + percent
Work orders — two side-by-side lists:
Completadas: id, SKU, parts, duration
Activa: id, SKU, progress bar, started-at
Estado máquina — last heartbeat, uptime %, connection status
Data source
Endpoint app/api/recap/[machineId]/route.ts — the detailed payload (shape I already documented in the earlier handoff). Cache 60s keyed by {machineId, range}.
Navigation
Sidebar "Resumen" stays → routes to /recap grid.
MachineCard onClick → router.push('/recap/' + machineId).
Breadcrumb on detail page navigates back to grid.
Deep link safe: /recap/<uuid> works standalone.
Shared components
Build these in components/recap/:
RecapMachineCard.tsx — the grid card. Props: machine summary object.
RecapMiniTimeline.tsx — 20px-high compressed bar, no labels, tooltip only.
RecapFullTimeline.tsx — 48-56px bar, labels on wide segments (>5% width), minimum segment width 1.5%, rounded only on first/last child.
RecapKpiRow.tsx — reused from prior design.
RecapProductionBySku.tsx, RecapDowntimeTop.tsx, RecapWorkOrders.tsx, RecapMachineStatus.tsx — detail-page sections.
RecapBanners.tsx — mold-change / offline / ongoing-stop alert bars.
Timeline specifics (fix the ugly-dots issue)
Both mini and full versions share segment-builder logic. Server-side:
Walk 24h chronologically, produce raw segments from MachineCycle, MachineEvent, MachineWorkOrder.
Gap-fill — any time between segments with no data → idle segment.
Merge pass — consecutive same-type segments separated by <30s → merge.
Absorb micro-runs — runs of microstops closer than 60s → single microstop-cluster segment with aggregated duration and count.
Minimum display width — server returns raw segments; client enforces Math.max(1.5, pct) so nothing renders as a dot.
Client:
display: flex; overflow: hidden; border-radius: 0.75rem on container.
Each child: width % only, no margin, no gap, no border-right.
Only labels if segment width >5% (else title tooltip).
Color map exactly:
production: bg-emerald-500 text-black
mold-change: bg-sky-400 text-black
macrostop: bg-red-500 text-white
microstop: bg-orange-500 text-black
idle: bg-zinc-700 text-zinc-300
i18n
Every user-visible string routes through useI18n(). Add to Spanish locale (primary):
recap.grid.title = "Resumen de máquinas"
recap.grid.subtitle = "Últimas 24h · click para ver detalle"
recap.detail.back = "Todas las máquinas"
recap.card.oee = "OEE"
recap.card.good = "Piezas buenas"
recap.card.stops = "Paros"
recap.banner.moldChange = "Cambio de molde en curso desde {time}"
recap.banner.offline = "Sin señal hace {min} min"
recap.banner.ongoingStop = "Máquina detenida hace {min} min"
recap.production.bySku = "Producción por SKU"
recap.downtime.top = "Top paros"
...
English keys mirror.
Accessibility / responsive
Cards collapse to single column <640px.
Timeline stays readable — horizontal scroll if really tight.
Keyboard navigable: cards are <button> or <Link>, not divs.
Status dots have aria-label.
Permissions
Same as /machines — any authenticated org member. No OWNER gate.
Files to create
app/(app)/recap/page.tsx (server, fetches summary)
app/(app)/recap/RecapGridClient.tsx (client, renders cards + filters)
app/(app)/recap/[machineId]/page.tsx (server, fetches detail)
app/(app)/recap/[machineId]/RecapDetailClient.tsx (client, renders detail)
app/api/recap/summary/route.ts
app/api/recap/[machineId]/route.ts
components/recap/* (per list above)
Files to delete / repurpose
The current aggregated recap (if it exists at /recap with mixed-machine view) — replace with the grid.
Any "global OEE average across all machines" widget — remove. Too misleading.
Testing checklist
not done
Grid renders for org with 5+ machines without lag (1 query, not N+1)
not done
Clicking a card navigates to correct detail page
not done
Detail page works for offline machine (no panic)
not done
Mold-change banner appears on both grid card AND detail page
not done
Timeline shows no dots — segments have visible width or get merged
not done
Mini timeline and full timeline use identical color palette
not done
Back navigation works, range picker persists in URL query
not done
Mobile layout: cards stack, detail sections stack
Non-goals
No real-time websockets — polling on focus is fine
No PDF/email export in this iteration
No shift-boundary magic (use wall-clock 24h unless user picks "Turno actual")
No schema changes