reliability semi-fix
This commit is contained in:
161
Reliability.md
Normal file
161
Reliability.md
Normal 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
|
||||||
@@ -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);
|
||||||
|
} finally {
|
||||||
|
if (alive) setTimelineLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (rows.length < 1) {
|
void loadTimeline();
|
||||||
return {
|
const timer = window.setInterval(() => {
|
||||||
windowSec,
|
void loadTimeline();
|
||||||
segments: [] as TimelineSeg[],
|
}, 30000);
|
||||||
start,
|
|
||||||
end,
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
window.clearInterval(timer);
|
||||||
};
|
};
|
||||||
}
|
}, [machineId]);
|
||||||
|
|
||||||
const segs: TimelineSeg[] = [];
|
const hasData = timeline?.hasData ?? false;
|
||||||
|
const startMs = timeline ? new Date(timeline.range.start).getTime() : Date.now() - 60 * 60 * 1000;
|
||||||
for (const cycle of rows) {
|
const endMs = timeline ? new Date(timeline.range.end).getTime() : Date.now();
|
||||||
const ideal = (cycle.ideal ?? cycleTarget ?? 0) as number;
|
const totalMs = Math.max(1, endMs - startMs);
|
||||||
const actual = cycle.actual ?? 0;
|
const normalized = useMemo(() => {
|
||||||
if (!ideal || ideal <= 0 || !actual || actual <= 0) continue;
|
if (!timeline || !hasData) return [] as RecapTimelineSegment[];
|
||||||
|
return normalizeTimelineSegments(timeline.segments, startMs, endMs);
|
||||||
const cycleEnd = cycle.t;
|
}, [timeline, hasData, startMs, endMs]);
|
||||||
const cycleStart = cycleEnd - actual * 1000;
|
const widths = useMemo(() => computeWidths(normalized, totalMs, 1.5), [normalized, totalMs]);
|
||||||
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);
|
|
||||||
|
|
||||||
return { windowSec, segments: segs, start, end };
|
|
||||||
}, [activeStoppage, cycles, cycleTarget, nowMs, thresholds]);
|
|
||||||
|
|
||||||
const { segments, windowSec } = timeline;
|
|
||||||
|
|
||||||
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>
|
||||||
|
{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-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">
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
153
app/(app)/recap/RecapGridClient.tsx
Normal file
153
app/(app)/recap/RecapGridClient.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
213
app/(app)/recap/[machineId]/RecapDetailClient.tsx
Normal file
213
app/(app)/recap/[machineId]/RecapDetailClient.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
13
app/(app)/recap/[machineId]/loading.tsx
Normal file
13
app/(app)/recap/[machineId]/loading.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
35
app/(app)/recap/[machineId]/page.tsx
Normal file
35
app/(app)/recap/[machineId]/page.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
app/(app)/recap/loading.tsx
Normal file
12
app/(app)/recap/loading.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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() ?? "",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
37
app/api/recap/[machineId]/route.ts
Normal file
37
app/api/recap/[machineId]/route.ts
Normal 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",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
42
app/api/recap/[machineId]/timeline/route.ts
Normal file
42
app/api/recap/[machineId]/timeline/route.ts
Normal 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",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
21
app/api/recap/summary/route.ts
Normal file
21
app/api/recap/summary/route.ts
Normal 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",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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();
|
|
||||||
const rangeEndMs = end.getTime();
|
|
||||||
|
|
||||||
const [cycles, events] = await Promise.all([
|
|
||||||
prisma.machineCycle.findMany({
|
|
||||||
where: {
|
|
||||||
orgId: session.orgId,
|
orgId: session.orgId,
|
||||||
machineId,
|
machineId,
|
||||||
ts: { gte: start, lte: end },
|
start,
|
||||||
},
|
end,
|
||||||
orderBy: { ts: "asc" },
|
maxSegments,
|
||||||
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;
|
return NextResponse.json(response, {
|
||||||
rawSegments.push({
|
headers: {
|
||||||
type: stopType,
|
"Cache-Control": "private, max-age=60, stale-while-revalidate=60",
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|||||||
44
components/recap/RecapBanners.tsx
Normal file
44
components/recap/RecapBanners.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
<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>
|
||||||
);
|
);
|
||||||
|
|||||||
83
components/recap/RecapFullTimeline.tsx
Normal file
83
components/recap/RecapFullTimeline.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
154
components/recap/RecapMachineCard.tsx
Normal file
154
components/recap/RecapMachineCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
82
components/recap/RecapMiniTimeline.tsx
Normal file
82
components/recap/RecapMiniTimeline.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
<tbody>
|
||||||
|
{rows.slice(0, 10).map((row) => {
|
||||||
|
const progress = row.progressPct == null ? "--" : `${Math.round(row.progressPct)}%`;
|
||||||
return (
|
return (
|
||||||
<div key={`${row.machineName}:${row.sku}`} className="grid grid-cols-6 gap-2 text-sm text-zinc-200">
|
<tr key={`${row.sku}:${row.machineName}`} className="border-b border-white/5">
|
||||||
<div className="truncate text-zinc-400">{row.machineName}</div>
|
<td className="py-2 pr-3">{row.sku}</td>
|
||||||
<div className="truncate">{row.sku}</div>
|
<td className="py-2 pr-3">{row.good}</td>
|
||||||
<div>{row.good}</div>
|
<td className={`py-2 pr-3 ${row.scrap > 0 ? "text-red-300" : ""}`}>{row.scrap}</td>
|
||||||
<div className={row.scrap > 0 ? "text-red-400" : "text-zinc-200"}>{row.scrap}</div>
|
<td className="py-2 pr-3">{row.target ?? "--"}</td>
|
||||||
<div>{row.target ?? "--"}</div>
|
<td className="py-2 text-emerald-300">{progress}</td>
|
||||||
<div>
|
</tr>
|
||||||
<span className="text-emerald-400">{pct}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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,55 +32,142 @@ 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 items-center justify-center truncate px-1 text-xs font-medium ${COLORS[segment.type]}`}
|
className={`flex h-full shrink-0 items-center justify-center truncate px-2 text-xs font-semibold ${COLORS[segment.type]} ${
|
||||||
style={{ width: `${widthPct}%` }}
|
index === 0 ? "rounded-l-xl" : ""
|
||||||
|
} ${index === normalized.length - 1 ? "rounded-r-xl" : ""}`}
|
||||||
|
style={{ width: `${Math.max(0, widthPct)}%` }}
|
||||||
title={title}
|
title={title}
|
||||||
>
|
>
|
||||||
{widthPct >= 6 ? segment.label : ""}
|
{widthPct > LABEL_MIN_PCT ? segment.label : ""}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
59
components/recap/RecapWorkOrders.tsx
Normal file
59
components/recap/RecapWorkOrders.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
150
components/recap/timelineRender.ts
Normal file
150
components/recap/timelineRender.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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) {
|
const sortedKpis = [...dedupedKpis].sort((a, b) => a.ts.getTime() - b.ts.getTime());
|
||||||
oeeSum += oee;
|
const weightedAvg = (field: "oee" | "availability" | "performance" | "quality") => {
|
||||||
oeeCount += 1;
|
if (!sortedKpis.length) return null;
|
||||||
}
|
let totalMs = 0;
|
||||||
if (availability != null) {
|
let weightedSum = 0;
|
||||||
availabilitySum += availability;
|
|
||||||
availabilityCount += 1;
|
for (let i = 0; i < sortedKpis.length; i += 1) {
|
||||||
}
|
const current = sortedKpis[i];
|
||||||
if (performance != null) {
|
const nextTsMs = (sortedKpis[i + 1]?.ts ?? params.end).getTime();
|
||||||
performanceSum += performance;
|
const dt = Math.max(0, nextTsMs - current.ts.getTime());
|
||||||
performanceCount += 1;
|
if (dt <= 0) continue;
|
||||||
}
|
weightedSum += (safeNum(current[field]) ?? 0) * dt;
|
||||||
if (quality != null) {
|
totalMs += dt;
|
||||||
qualitySum += quality;
|
|
||||||
qualityCount += 1;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
? authoritativeProgress.goodParts + authoritativeProgress.scrapParts
|
||||||
|
: cumulativeProgress
|
||||||
? cumulativeProgress.good + cumulativeProgress.scrap
|
? cumulativeProgress.good + cumulativeProgress.scrap
|
||||||
: (rangeProgress?.goodParts ?? 0) + (rangeProgress?.scrapParts ?? 0);
|
: (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
679
lib/recap/redesign.ts
Normal 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
763
lib/recap/timeline.ts
Normal 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
185
lib/recap/timelineApi.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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
144
recap_fix.md
Normal 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
|
||||||
Reference in New Issue
Block a user