Compare commits
15 Commits
80d27f83b6
...
sandbox-ma
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bfc1673d89 | ||
|
|
0491237bad | ||
|
|
4299ef3478 | ||
|
|
864be8d932 | ||
|
|
b2214ec46f | ||
|
|
5e7ddaa0db | ||
|
|
62169b163c | ||
|
|
7e0fe5c2e1 | ||
|
|
66c89f9bf4 | ||
|
|
30513ff73d | ||
|
|
5d3a2c533f | ||
|
|
6aaafb9115 | ||
|
|
4973c18dc3 | ||
|
|
e705f5e965 | ||
|
|
2707fd974a |
43
MACHINE_STATE_PROGRESS.md
Normal file
43
MACHINE_STATE_PROGRESS.md
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
# Machine State Progress
|
||||||
|
|
||||||
|
## Final State Model (5 states + sub-reasons)
|
||||||
|
|
||||||
|
| State | Color | Trigger |
|
||||||
|
|---|---|---|
|
||||||
|
| OFFLINE | dark gray | Heartbeat dead >2 min |
|
||||||
|
| STOPPED | red, pulse >5min | Active WO + no cycles (regardless of tracking) |
|
||||||
|
| - reason `machine_fault` | | Tracking on, macrostop event active |
|
||||||
|
| - reason `not_started` | | Tracking off, has WO |
|
||||||
|
| DATA_LOSS | red + icon, pulse | Tracking off + cycles arriving (>5 cycles or >10 min) |
|
||||||
|
| MOLD_CHANGE | blue | Active mold-change event |
|
||||||
|
| - sub at >3h | yellow accent | (Round 2) |
|
||||||
|
| - sub at >5h | red accent | (Round 2) |
|
||||||
|
| IDLE | calm gray | No tracking, no WO, no cycles |
|
||||||
|
| RUNNING | green | Tracking + WO + recent cycles |
|
||||||
|
|
||||||
|
## Round 1 — Foundation: classifier + IDLE + STOPPED collapse + DATA_LOSS
|
||||||
|
- [x] Step 1: Add `"idle"` and `"data-loss"` to `RecapMachineStatus` union
|
||||||
|
- [x] Step 2: Create `lib/recap/machineState.ts` shared classifier with all reasons
|
||||||
|
- [x] Step 3: Refactor `statusFromMachine` in redesign.ts to call classifier
|
||||||
|
- [x] Step 4: Plumb new fields (status reason, ongoing min) through types/responses
|
||||||
|
- [x] Step 5: UI rendering: IDLE (calm gray) on /recap, /machines, detail
|
||||||
|
- [x] Step 6: UI rendering: DATA_LOSS (red + icon) on all surfaces
|
||||||
|
- [x] Step 7: STOPPED reason text: show `not_started` vs `machine_fault` distinction
|
||||||
|
- [x] Step 8: i18n keys (en + es-MX)
|
||||||
|
- [x] Step 9: End-to-end verify each state transitions correctly
|
||||||
|
|
||||||
|
## Round 2 — Mold change duration escalation (CT-only)
|
||||||
|
- [ ] MOLD_CHANGE >3h yellow accent
|
||||||
|
- [ ] MOLD_CHANGE >5h red accent
|
||||||
|
- [ ] i18n strings
|
||||||
|
|
||||||
|
## Notes / parked items
|
||||||
|
- Prisma drift on (orgId,machineId,seq) unique indexes — pre-existing, not related to this work. Address as separate housekeeping task.
|
||||||
|
- Node-RED incidentKey rotation behavior verified: 10 distinct keys per real stoppage = correct.
|
||||||
|
|
||||||
|
## Path A — dead state cleanup (post Round 1)
|
||||||
|
- [x] Removed `not_started` and `data-loss` branches from classifier
|
||||||
|
- [x] Removed `RecapStoppedReason` and `RecapDataLossReason` types
|
||||||
|
- [x] Simplified `RecapStateContext` to empty struct (kept for future use)
|
||||||
|
- [x] Updated UI rendering: 5 states only (offline/stopped/mold-change/idle/running)
|
||||||
|
- [x] i18n: removed dead keys
|
||||||
30
README.md
30
README.md
@@ -75,6 +75,36 @@ sudo systemctl daemon-reload
|
|||||||
sudo systemctl enable --now mis-control-tower-reminders.timer
|
sudo systemctl enable --now mis-control-tower-reminders.timer
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Downtime Reason Backfill
|
||||||
|
|
||||||
|
Control-Tower now preserves manual downtime reasons from `downtime-acknowledged` events when later default stop events (`PENDIENTE` / `UNCLASSIFIED`) arrive for the same incident.
|
||||||
|
|
||||||
|
If historical rows were already overwritten, run the one-time backfill:
|
||||||
|
|
||||||
|
1) Dry run (default lookback: 30 days):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run backfill:downtime-reasons -- --dry-run --since 30d
|
||||||
|
```
|
||||||
|
|
||||||
|
2) Apply updates:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run backfill:downtime-reasons -- --since 30d
|
||||||
|
```
|
||||||
|
|
||||||
|
Optional filters:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run backfill:downtime-reasons -- --dry-run --since 14d --org-id <orgId> --machine-id <machineId>
|
||||||
|
```
|
||||||
|
|
||||||
|
Quick verification query (shows recent incidents with reason + source):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node -e 'const {PrismaClient}=require("@prisma/client");const p=new PrismaClient();(async()=>{const rows=await p.reasonEntry.findMany({where:{kind:"downtime"},orderBy:{capturedAt:"desc"},take:30,select:{id:true,orgId:true,machineId:true,episodeId:true,reasonCode:true,reasonLabel:true,capturedAt:true,meta:true}});console.log(JSON.stringify(rows,(_,v)=>typeof v==="bigint"?v.toString():v,2));})().finally(()=>p.$disconnect());'
|
||||||
|
```
|
||||||
|
|
||||||
## Production build and deploy
|
## Production build and deploy
|
||||||
|
|
||||||
**Dev uses Turbopack, production build uses Webpack.** Next.js 16 defaults to Turbopack for both, but Turbopack production builds have known issues. This project uses:
|
**Dev uses Turbopack, production build uses Webpack.** Next.js 16 defaults to Turbopack for both, but Turbopack production builds have known issues. This project uses:
|
||||||
|
|||||||
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
|
||||||
@@ -4,6 +4,7 @@ import Link from "next/link";
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useEffect, useState, type KeyboardEvent } from "react";
|
import { useEffect, useState, type KeyboardEvent } from "react";
|
||||||
import { useI18n } from "@/lib/i18n/useI18n";
|
import { useI18n } from "@/lib/i18n/useI18n";
|
||||||
|
import { RECAP_HEARTBEAT_STALE_MS } from "@/lib/recap/recapUiConstants";
|
||||||
|
|
||||||
type MachineRow = {
|
type MachineRow = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -18,8 +19,15 @@ type MachineRow = {
|
|||||||
ip?: string | null;
|
ip?: string | null;
|
||||||
fwVersion?: string | null;
|
fwVersion?: string | null;
|
||||||
};
|
};
|
||||||
|
latestMacrostop?: null | {
|
||||||
|
machineId: string;
|
||||||
|
ts: string;
|
||||||
|
status: "active" | "resolved" | "unknown";
|
||||||
|
startedAtMs: number;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
const LIVE_REFRESH_MS = 5000;
|
const LIVE_REFRESH_MS = 5000;
|
||||||
|
const OFFLINE_MS = RECAP_HEARTBEAT_STALE_MS;
|
||||||
|
|
||||||
function secondsAgo(ts: string | undefined, locale: string, fallback: string) {
|
function secondsAgo(ts: string | undefined, locale: string, fallback: string) {
|
||||||
if (!ts) return fallback;
|
if (!ts) return fallback;
|
||||||
@@ -31,7 +39,7 @@ function secondsAgo(ts: string | undefined, locale: string, fallback: string) {
|
|||||||
|
|
||||||
function isOffline(ts?: string) {
|
function isOffline(ts?: string) {
|
||||||
if (!ts) return true;
|
if (!ts) return true;
|
||||||
return Date.now() - new Date(ts).getTime() > 30000; // 30s threshold
|
return Date.now() - new Date(ts).getTime() > OFFLINE_MS;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeStatus(status?: string) {
|
function normalizeStatus(status?: string) {
|
||||||
@@ -49,6 +57,21 @@ function badgeClass(status?: string, offline?: boolean) {
|
|||||||
return "bg-white/10 text-white";
|
return "bg-white/10 text-white";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MACROSTOP_FRESH_MS = 2 * 60 * 1000;
|
||||||
|
|
||||||
|
function isMacrostopActive(macrostop: MachineRow["latestMacrostop"]) {
|
||||||
|
if (!macrostop) return false;
|
||||||
|
if (macrostop.status !== "active") return false;
|
||||||
|
// Fresh if last refresh was within 2 min — Node-RED refreshes every 10s,
|
||||||
|
// so anything older means the stoppage already ended without resolution event.
|
||||||
|
return Date.now() - new Date(macrostop.ts).getTime() <= MACROSTOP_FRESH_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ongoingMacrostopMin(macrostop: MachineRow["latestMacrostop"]) {
|
||||||
|
if (!macrostop) return 0;
|
||||||
|
return Math.max(0, Math.floor((Date.now() - macrostop.startedAtMs) / 60000));
|
||||||
|
}
|
||||||
|
|
||||||
export default function MachinesClient({ initialMachines = [] }: { initialMachines?: MachineRow[] }) {
|
export default function MachinesClient({ initialMachines = [] }: { initialMachines?: MachineRow[] }) {
|
||||||
const { t, locale } = useI18n();
|
const { t, locale } = useI18n();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -290,9 +313,28 @@ export default function MachinesClient({ initialMachines = [] }: { initialMachin
|
|||||||
const hbTs = hb?.tsServer ?? hb?.ts;
|
const hbTs = hb?.tsServer ?? hb?.ts;
|
||||||
const offline = isOffline(hbTs);
|
const offline = isOffline(hbTs);
|
||||||
const normalizedStatus = normalizeStatus(hb?.status);
|
const normalizedStatus = normalizeStatus(hb?.status);
|
||||||
const statusLabel = offline ? t("machines.status.offline") : (normalizedStatus || t("machines.status.unknown"));
|
|
||||||
const lastSeen = secondsAgo(hbTs, locale, t("common.never"));
|
const lastSeen = secondsAgo(hbTs, locale, t("common.never"));
|
||||||
|
|
||||||
|
const macrostopActive = isMacrostopActive(m.latestMacrostop);
|
||||||
|
const stoppedMin = macrostopActive ? ongoingMacrostopMin(m.latestMacrostop) : 0;
|
||||||
|
|
||||||
|
// Production-state badge: STOPPED if active macrostop, else heartbeat-based.
|
||||||
|
const productionBadgeLabel = offline
|
||||||
|
? t("machines.status.offline")
|
||||||
|
: macrostopActive
|
||||||
|
? t("machines.status.stopped")
|
||||||
|
: (normalizedStatus || t("machines.status.unknown"));
|
||||||
|
|
||||||
|
const productionBadgeClass = offline
|
||||||
|
? "bg-white/10 text-zinc-300"
|
||||||
|
: macrostopActive
|
||||||
|
? "bg-red-500/20 text-red-200 ring-2 ring-red-500/50 animate-pulse"
|
||||||
|
: badgeClass(normalizedStatus, offline);
|
||||||
|
|
||||||
|
const cardClass = macrostopActive
|
||||||
|
? "cursor-pointer rounded-2xl border border-red-500/60 bg-red-500/10 p-5 ring-2 ring-red-500/40 animate-pulse hover:bg-red-500/15"
|
||||||
|
: "cursor-pointer rounded-2xl border border-white/10 bg-white/5 p-5 hover:bg-white/10";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={m.id}
|
key={m.id}
|
||||||
@@ -300,7 +342,7 @@ export default function MachinesClient({ initialMachines = [] }: { initialMachin
|
|||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onClick={() => router.push(`/machines/${m.id}`)}
|
onClick={() => router.push(`/machines/${m.id}`)}
|
||||||
onKeyDown={(event) => handleCardKeyDown(event, m.id)}
|
onKeyDown={(event) => handleCardKeyDown(event, m.id)}
|
||||||
className="cursor-pointer rounded-2xl border border-white/10 bg-white/5 p-5 hover:bg-white/10"
|
className={cardClass}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
@@ -308,15 +350,17 @@ export default function MachinesClient({ initialMachines = [] }: { initialMachin
|
|||||||
<div className="mt-1 text-xs text-zinc-400">
|
<div className="mt-1 text-xs text-zinc-400">
|
||||||
{m.code ? m.code : t("common.na")} - {t("machines.lastSeen", { time: lastSeen })}
|
{m.code ? m.code : t("common.na")} - {t("machines.lastSeen", { time: lastSeen })}
|
||||||
</div>
|
</div>
|
||||||
|
{macrostopActive ? (
|
||||||
|
<div className="mt-1 text-xs font-semibold text-red-200">
|
||||||
|
{t("machines.stoppedFor", { min: stoppedMin })}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span
|
<span
|
||||||
className={`shrink-0 rounded-full px-3 py-1 text-xs ${badgeClass(
|
className={`shrink-0 rounded-full px-3 py-1 text-xs ${productionBadgeClass}`}
|
||||||
normalizedStatus,
|
|
||||||
offline
|
|
||||||
)}`}
|
|
||||||
>
|
>
|
||||||
{statusLabel}
|
{productionBadgeLabel}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
349
app/(app)/machines/MachinesClient.tsx.bak
Normal file
349
app/(app)/machines/MachinesClient.tsx.bak
Normal file
@@ -0,0 +1,349 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useEffect, useState, type KeyboardEvent } from "react";
|
||||||
|
import { useI18n } from "@/lib/i18n/useI18n";
|
||||||
|
import { RECAP_HEARTBEAT_STALE_MS } from "@/lib/recap/recapUiConstants";
|
||||||
|
|
||||||
|
type MachineRow = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
code?: string | null;
|
||||||
|
location?: string | null;
|
||||||
|
latestHeartbeat: null | {
|
||||||
|
ts: string;
|
||||||
|
tsServer?: string | null;
|
||||||
|
status: string;
|
||||||
|
message?: string | null;
|
||||||
|
ip?: string | null;
|
||||||
|
fwVersion?: string | null;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const LIVE_REFRESH_MS = 5000;
|
||||||
|
const OFFLINE_MS = RECAP_HEARTBEAT_STALE_MS;
|
||||||
|
|
||||||
|
function secondsAgo(ts: string | undefined, locale: string, fallback: string) {
|
||||||
|
if (!ts) return fallback;
|
||||||
|
const diff = Math.floor((Date.now() - new Date(ts).getTime()) / 1000);
|
||||||
|
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: "auto" });
|
||||||
|
if (diff < 60) return rtf.format(-diff, "second");
|
||||||
|
return rtf.format(-Math.floor(diff / 60), "minute");
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOffline(ts?: string) {
|
||||||
|
if (!ts) return true;
|
||||||
|
return Date.now() - new Date(ts).getTime() > OFFLINE_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeStatus(status?: string) {
|
||||||
|
const s = (status ?? "").toUpperCase();
|
||||||
|
if (s === "ONLINE") return "RUN";
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function badgeClass(status?: string, offline?: boolean) {
|
||||||
|
if (offline) return "bg-white/10 text-zinc-300";
|
||||||
|
const s = (status ?? "").toUpperCase();
|
||||||
|
if (s === "RUN") return "bg-emerald-500/15 text-emerald-300";
|
||||||
|
if (s === "IDLE") return "bg-yellow-500/15 text-yellow-300";
|
||||||
|
if (s === "STOP" || s === "DOWN") return "bg-red-500/15 text-red-300";
|
||||||
|
return "bg-white/10 text-white";
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MachinesClient({ initialMachines = [] }: { initialMachines?: MachineRow[] }) {
|
||||||
|
const { t, locale } = useI18n();
|
||||||
|
const router = useRouter();
|
||||||
|
const [machines, setMachines] = useState<MachineRow[]>(() => initialMachines);
|
||||||
|
const [loading, setLoading] = useState(() => initialMachines.length === 0);
|
||||||
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
|
const [createName, setCreateName] = useState("");
|
||||||
|
const [createCode, setCreateCode] = useState("");
|
||||||
|
const [createLocation, setCreateLocation] = useState("");
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [createError, setCreateError] = useState<string | null>(null);
|
||||||
|
const [createdMachine, setCreatedMachine] = useState<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
pairingCode: string;
|
||||||
|
pairingExpiresAt: string;
|
||||||
|
} | null>(null);
|
||||||
|
const [copyStatus, setCopyStatus] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
async function load(initial: boolean) {
|
||||||
|
try {
|
||||||
|
if (!initial && typeof document !== "undefined" && document.hidden) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch("/api/machines", { cache: "no-store" });
|
||||||
|
const json = await res.json();
|
||||||
|
if (alive) {
|
||||||
|
setMachines(json.machines ?? []);
|
||||||
|
if (initial) setLoading(false);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (alive && initial) setLoading(false);
|
||||||
|
} finally {
|
||||||
|
if (!alive) return;
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
void load(false);
|
||||||
|
}, LIVE_REFRESH_MS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void load(initialMachines.length === 0);
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
};
|
||||||
|
}, [initialMachines.length]);
|
||||||
|
|
||||||
|
async function createMachine() {
|
||||||
|
if (!createName.trim()) {
|
||||||
|
setCreateError(t("machines.create.error.nameRequired"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCreating(true);
|
||||||
|
setCreateError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/machines", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: createName,
|
||||||
|
code: createCode,
|
||||||
|
location: createLocation,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok || !data.ok) {
|
||||||
|
throw new Error(data.error || t("machines.create.error.failed"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextMachine = {
|
||||||
|
...data.machine,
|
||||||
|
latestHeartbeat: null,
|
||||||
|
};
|
||||||
|
setMachines((prev) => [nextMachine, ...prev]);
|
||||||
|
setCreatedMachine({
|
||||||
|
id: data.machine.id,
|
||||||
|
name: data.machine.name,
|
||||||
|
pairingCode: data.machine.pairingCode,
|
||||||
|
pairingExpiresAt: data.machine.pairingCodeExpiresAt,
|
||||||
|
});
|
||||||
|
setCreateName("");
|
||||||
|
setCreateCode("");
|
||||||
|
setCreateLocation("");
|
||||||
|
setShowCreate(false);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : null;
|
||||||
|
setCreateError(message || t("machines.create.error.failed"));
|
||||||
|
} finally {
|
||||||
|
setCreating(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyText(text: string) {
|
||||||
|
try {
|
||||||
|
if (navigator.clipboard?.writeText) {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
setCopyStatus(t("machines.pairing.copied"));
|
||||||
|
} else {
|
||||||
|
setCopyStatus(t("machines.pairing.copyUnsupported"));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setCopyStatus(t("machines.pairing.copyFailed"));
|
||||||
|
}
|
||||||
|
setTimeout(() => setCopyStatus(null), 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCardKeyDown(event: KeyboardEvent<HTMLDivElement>, machineId: string) {
|
||||||
|
if (event.key === "Enter" || event.key === " ") {
|
||||||
|
event.preventDefault();
|
||||||
|
router.push(`/machines/${machineId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const showCreateCard = showCreate || (!loading && machines.length === 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4 sm:p-6">
|
||||||
|
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold text-white">{t("machines.title")}</h1>
|
||||||
|
<p className="text-sm text-zinc-400">{t("machines.subtitle")}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex w-full flex-wrap items-center gap-2 sm:w-auto">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowCreate((prev) => !prev)}
|
||||||
|
className="w-full rounded-xl border border-emerald-400/40 bg-emerald-500/20 px-4 py-2 text-sm text-emerald-100 hover:bg-emerald-500/30 sm:w-auto"
|
||||||
|
>
|
||||||
|
{showCreate ? t("machines.cancel") : t("machines.addMachine")}
|
||||||
|
</button>
|
||||||
|
<Link
|
||||||
|
href="/overview"
|
||||||
|
className="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-center text-sm text-white hover:bg-white/10 sm:w-auto"
|
||||||
|
>
|
||||||
|
{t("machines.backOverview")}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showCreateCard && (
|
||||||
|
<div className="mb-6 rounded-2xl border border-white/10 bg-white/5 p-5">
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-semibold text-white">{t("machines.addCardTitle")}</div>
|
||||||
|
<div className="text-xs text-zinc-400">{t("machines.addCardSubtitle")}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 grid grid-cols-1 gap-3 md:grid-cols-3">
|
||||||
|
<label className="rounded-xl border border-white/10 bg-black/20 p-3 text-xs text-zinc-400">
|
||||||
|
{t("machines.field.name")}
|
||||||
|
<input
|
||||||
|
value={createName}
|
||||||
|
onChange={(event) => setCreateName(event.target.value)}
|
||||||
|
className="mt-2 w-full rounded-lg border border-white/10 bg-black/30 px-3 py-2 text-sm text-white"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="rounded-xl border border-white/10 bg-black/20 p-3 text-xs text-zinc-400">
|
||||||
|
{t("machines.field.code")}
|
||||||
|
<input
|
||||||
|
value={createCode}
|
||||||
|
onChange={(event) => setCreateCode(event.target.value)}
|
||||||
|
className="mt-2 w-full rounded-lg border border-white/10 bg-black/30 px-3 py-2 text-sm text-white"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="rounded-xl border border-white/10 bg-black/20 p-3 text-xs text-zinc-400">
|
||||||
|
{t("machines.field.location")}
|
||||||
|
<input
|
||||||
|
value={createLocation}
|
||||||
|
onChange={(event) => setCreateLocation(event.target.value)}
|
||||||
|
className="mt-2 w-full rounded-lg border border-white/10 bg-black/30 px-3 py-2 text-sm text-white"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 flex flex-wrap items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={createMachine}
|
||||||
|
disabled={creating}
|
||||||
|
className="rounded-xl border border-emerald-400/40 bg-emerald-500/20 px-4 py-2 text-sm text-emerald-100 hover:bg-emerald-500/30 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{creating ? t("machines.create.loading") : t("machines.create.default")}
|
||||||
|
</button>
|
||||||
|
{createError && <div className="text-xs text-red-200">{createError}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{createdMachine && (
|
||||||
|
<div className="mb-6 rounded-2xl border border-emerald-500/20 bg-emerald-500/10 p-5">
|
||||||
|
<div className="text-sm font-semibold text-white">{t("machines.pairing.title")}</div>
|
||||||
|
<div className="mt-2 text-xs text-zinc-300">
|
||||||
|
{t("machines.pairing.machine")} <span className="text-white">{createdMachine.name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 rounded-xl border border-white/10 bg-black/30 p-4">
|
||||||
|
<div className="text-xs uppercase tracking-wide text-zinc-400">{t("machines.pairing.codeLabel")}</div>
|
||||||
|
<div className="mt-2 text-3xl font-semibold text-white">{createdMachine.pairingCode}</div>
|
||||||
|
<div className="mt-2 text-xs text-zinc-400">
|
||||||
|
{t("machines.pairing.expires")}{" "}
|
||||||
|
{createdMachine.pairingExpiresAt
|
||||||
|
? new Date(createdMachine.pairingExpiresAt).toLocaleString(locale)
|
||||||
|
: t("machines.pairing.soon")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 text-xs text-zinc-300">
|
||||||
|
{t("machines.pairing.instructions")}
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex flex-wrap items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => copyText(createdMachine.pairingCode)}
|
||||||
|
className="rounded-xl border border-white/10 bg-white/5 px-3 py-2 text-sm text-white hover:bg-white/10"
|
||||||
|
>
|
||||||
|
{t("machines.pairing.copy")}
|
||||||
|
</button>
|
||||||
|
{copyStatus && <div className="text-xs text-zinc-300">{copyStatus}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading && <div className="mb-4 text-sm text-zinc-400">{t("machines.loading")}</div>}
|
||||||
|
|
||||||
|
{!loading && machines.length === 0 && (
|
||||||
|
<div className="mb-4 text-sm text-zinc-400">{t("machines.empty")}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{(!loading ? machines : []).map((m) => {
|
||||||
|
const hb = m.latestHeartbeat;
|
||||||
|
const hbTs = hb?.tsServer ?? hb?.ts;
|
||||||
|
const offline = isOffline(hbTs);
|
||||||
|
const normalizedStatus = normalizeStatus(hb?.status);
|
||||||
|
const statusLabel = offline ? t("machines.status.offline") : (normalizedStatus || t("machines.status.unknown"));
|
||||||
|
const lastSeen = secondsAgo(hbTs, locale, t("common.never"));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={m.id}
|
||||||
|
role="link"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={() => router.push(`/machines/${m.id}`)}
|
||||||
|
onKeyDown={(event) => handleCardKeyDown(event, m.id)}
|
||||||
|
className="cursor-pointer rounded-2xl border border-white/10 bg-white/5 p-5 hover:bg-white/10"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate text-lg font-semibold text-white">{m.name}</div>
|
||||||
|
<div className="mt-1 text-xs text-zinc-400">
|
||||||
|
{m.code ? m.code : t("common.na")} - {t("machines.lastSeen", { time: lastSeen })}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span
|
||||||
|
className={`shrink-0 rounded-full px-3 py-1 text-xs ${badgeClass(
|
||||||
|
normalizedStatus,
|
||||||
|
offline
|
||||||
|
)}`}
|
||||||
|
>
|
||||||
|
{statusLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 text-sm text-zinc-400">{t("machines.status")}</div>
|
||||||
|
<div className="mt-1 flex items-center gap-2 text-sm font-semibold text-white">
|
||||||
|
{offline ? (
|
||||||
|
<>
|
||||||
|
<span className="inline-flex h-2.5 w-2.5 rounded-full bg-zinc-500" aria-hidden="true" />
|
||||||
|
<span>{t("machines.status.noHeartbeat")}</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="relative flex h-2.5 w-2.5" aria-hidden="true">
|
||||||
|
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75" />
|
||||||
|
<span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-emerald-400" />
|
||||||
|
</span>
|
||||||
|
<span>{t("machines.status.ok")}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -20,6 +20,16 @@ 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 { RECAP_HEARTBEAT_STALE_MS } from "@/lib/recap/recapUiConstants";
|
||||||
|
import {
|
||||||
|
computeWidths,
|
||||||
|
formatDuration,
|
||||||
|
formatTime,
|
||||||
|
normalizeTimelineSegments,
|
||||||
|
SEGMENT_MIN_WIDTH_PCT,
|
||||||
|
TIMELINE_COLORS,
|
||||||
|
} from "@/components/recap/timelineRender";
|
||||||
|
|
||||||
type Heartbeat = {
|
type Heartbeat = {
|
||||||
ts: string;
|
ts: string;
|
||||||
@@ -87,20 +97,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;
|
||||||
@@ -112,6 +108,9 @@ type WorkOrderUpload = {
|
|||||||
sku?: string;
|
sku?: string;
|
||||||
targetQty?: number;
|
targetQty?: number;
|
||||||
cycleTime?: number;
|
cycleTime?: number;
|
||||||
|
mold?: string;
|
||||||
|
cavitiesTotal?: number;
|
||||||
|
cavitiesActive?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type WorkOrderRow = Record<string, string | number | boolean>;
|
type WorkOrderRow = Record<string, string | number | boolean>;
|
||||||
@@ -128,7 +127,7 @@ const TOL = 0.10;
|
|||||||
const DEFAULT_MICRO_MULT = 1.5;
|
const DEFAULT_MICRO_MULT = 1.5;
|
||||||
const DEFAULT_MACRO_MULT = 5;
|
const DEFAULT_MACRO_MULT = 5;
|
||||||
const NORMAL_TOL_SEC = 0.1;
|
const NORMAL_TOL_SEC = 0.1;
|
||||||
const LIVE_REFRESH_MS = 5000;
|
const LIVE_REFRESH_MS = 15000;
|
||||||
|
|
||||||
const BUCKET = {
|
const BUCKET = {
|
||||||
normal: {
|
normal: {
|
||||||
@@ -198,8 +197,41 @@ const WORK_ORDER_KEYS = {
|
|||||||
"theoretical_cycle_time",
|
"theoretical_cycle_time",
|
||||||
]),
|
]),
|
||||||
target: new Set(["targetquantity", "targetqty", "target", "target_qty"]),
|
target: new Set(["targetquantity", "targetqty", "target", "target_qty"]),
|
||||||
|
mold: new Set(["mold", "molde", "moldid", "mold_id"]),
|
||||||
|
cavitiesTotal: new Set([
|
||||||
|
"totalcavities",
|
||||||
|
"cavitiestotal",
|
||||||
|
"cavities_total",
|
||||||
|
"total_cavities",
|
||||||
|
]),
|
||||||
|
cavitiesActive: new Set([
|
||||||
|
"activecavities",
|
||||||
|
"cavitiesactive",
|
||||||
|
"cavities_active",
|
||||||
|
"active_cavities",
|
||||||
|
]),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const WORK_ORDER_TEMPLATE_HEADERS = [
|
||||||
|
"Work Order ID",
|
||||||
|
"SKU",
|
||||||
|
"Theoretical Cycle Time (Seconds)",
|
||||||
|
"Target Quantity",
|
||||||
|
"Mold",
|
||||||
|
"Total Cavities",
|
||||||
|
"Active Cavities",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const WORK_ORDER_TEMPLATE_EXAMPLE_ROW = [
|
||||||
|
"*borra esta fila al subir excel)",
|
||||||
|
"SKU-12345",
|
||||||
|
35,
|
||||||
|
10000,
|
||||||
|
"MOLD-01",
|
||||||
|
8,
|
||||||
|
8,
|
||||||
|
] as const;
|
||||||
|
|
||||||
function normalizeKey(value: string) {
|
function normalizeKey(value: string) {
|
||||||
return value.toLowerCase().replace(/[^a-z0-9]/g, "");
|
return value.toLowerCase().replace(/[^a-z0-9]/g, "");
|
||||||
}
|
}
|
||||||
@@ -284,7 +316,26 @@ function rowsToWorkOrders(rows: WorkOrderRow[]): WorkOrderUpload[] {
|
|||||||
const targetQty = Number.isFinite(Number(targetRaw)) ? Math.trunc(Number(targetRaw)) : undefined;
|
const targetQty = Number.isFinite(Number(targetRaw)) ? Math.trunc(Number(targetRaw)) : undefined;
|
||||||
const cycleTime = Number.isFinite(Number(cycleRaw)) ? Number(cycleRaw) : undefined;
|
const cycleTime = Number.isFinite(Number(cycleRaw)) ? Number(cycleRaw) : undefined;
|
||||||
|
|
||||||
out.push({ workOrderId, sku: sku || undefined, targetQty, cycleTime });
|
const moldRaw = pickRowValue(row, WORK_ORDER_KEYS.mold);
|
||||||
|
const mold = String(moldRaw ?? "").trim();
|
||||||
|
const totalCavRaw = pickRowValue(row, WORK_ORDER_KEYS.cavitiesTotal);
|
||||||
|
const activeCavRaw = pickRowValue(row, WORK_ORDER_KEYS.cavitiesActive);
|
||||||
|
const cavitiesTotal = Number.isFinite(Number(totalCavRaw))
|
||||||
|
? Math.trunc(Number(totalCavRaw))
|
||||||
|
: undefined;
|
||||||
|
const cavitiesActive = Number.isFinite(Number(activeCavRaw))
|
||||||
|
? Math.trunc(Number(activeCavRaw))
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
out.push({
|
||||||
|
workOrderId,
|
||||||
|
sku: sku || undefined,
|
||||||
|
targetQty,
|
||||||
|
cycleTime,
|
||||||
|
mold: mold || undefined,
|
||||||
|
cavitiesTotal,
|
||||||
|
cavitiesActive,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
return out;
|
return out;
|
||||||
@@ -308,6 +359,184 @@ function toErrorMessage(value: unknown, fallback: string): string {
|
|||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type MachineActivityTimelineProps = {
|
||||||
|
machineId?: string;
|
||||||
|
locale: string;
|
||||||
|
t: (key: string, vars?: Record<string, string | number>) => string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function getMinuteFlooredOneHourRange(referenceMs = Date.now()) {
|
||||||
|
const endMs = Math.floor(referenceMs / 60000) * 60000;
|
||||||
|
return {
|
||||||
|
startMs: endMs - 60 * 60 * 1000,
|
||||||
|
endMs,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function MachineActivityTimeline({ machineId, locale, t }: MachineActivityTimelineProps) {
|
||||||
|
const [timeline, setTimeline] = useState<RecapTimelineResponse | null>(null);
|
||||||
|
const [timelineLoading, setTimelineLoading] = useState(true);
|
||||||
|
const [showWindowInfo, setShowWindowInfo] = useState(false);
|
||||||
|
const timelineHashRef = useRef("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!machineId) return;
|
||||||
|
let alive = true;
|
||||||
|
timelineHashRef.current = "";
|
||||||
|
setTimelineLoading(true);
|
||||||
|
|
||||||
|
async function loadTimeline() {
|
||||||
|
try {
|
||||||
|
const range = getMinuteFlooredOneHourRange();
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
start: String(range.startMs),
|
||||||
|
end: String(range.endMs),
|
||||||
|
});
|
||||||
|
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;
|
||||||
|
const nextTimeline = json as RecapTimelineResponse;
|
||||||
|
const nextHash = JSON.stringify({
|
||||||
|
start: nextTimeline.range.start,
|
||||||
|
end: nextTimeline.range.end,
|
||||||
|
hasData: nextTimeline.hasData,
|
||||||
|
segments: nextTimeline.segments.map((segment) => ({
|
||||||
|
type: segment.type,
|
||||||
|
startMs: segment.startMs,
|
||||||
|
endMs: segment.endMs,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
if (timelineHashRef.current === nextHash) return;
|
||||||
|
timelineHashRef.current = nextHash;
|
||||||
|
setTimeline(nextTimeline);
|
||||||
|
} finally {
|
||||||
|
if (alive) setTimelineLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void loadTimeline();
|
||||||
|
const timer = window.setInterval(() => {
|
||||||
|
void loadTimeline();
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
window.clearInterval(timer);
|
||||||
|
};
|
||||||
|
}, [machineId]);
|
||||||
|
|
||||||
|
const hasData = timeline?.hasData ?? false;
|
||||||
|
const fallbackRange = getMinuteFlooredOneHourRange();
|
||||||
|
const startMs = timeline ? new Date(timeline.range.start).getTime() : fallbackRange.startMs;
|
||||||
|
const endMs = timeline ? new Date(timeline.range.end).getTime() : fallbackRange.endMs;
|
||||||
|
const totalMs = Math.max(1, endMs - startMs);
|
||||||
|
const normalized = useMemo(() => {
|
||||||
|
if (!timeline || !hasData) return [] as RecapTimelineSegment[];
|
||||||
|
return normalizeTimelineSegments(timeline.segments, startMs, endMs);
|
||||||
|
}, [timeline, hasData, startMs, endMs]);
|
||||||
|
const widths = useMemo(
|
||||||
|
() => computeWidths(normalized, totalMs, SEGMENT_MIN_WIDTH_PCT),
|
||||||
|
[normalized, totalMs]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<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>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowWindowInfo(true)}
|
||||||
|
className="rounded-md border border-white/20 px-2 py-1 text-xs text-zinc-300 hover:border-white/40 hover:text-white"
|
||||||
|
>
|
||||||
|
{t("machine.detail.activity.windowBadge")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 flex flex-wrap items-center gap-4 text-xs text-zinc-300">
|
||||||
|
{(["production", "mold-change", "macrostop", "microstop", "idle"] as const).map((type) => (
|
||||||
|
<div key={type} className="flex items-center gap-2">
|
||||||
|
<span className={`h-2.5 w-2.5 rounded-full ${TIMELINE_COLORS[type]}`} />
|
||||||
|
<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 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">
|
||||||
|
<span>{timelineLoading ? t("common.loading") : formatTime(startMs, locale)}</span>
|
||||||
|
<span>{formatTime(endMs, locale)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex h-14 w-full overflow-hidden rounded-2xl">
|
||||||
|
{!hasData ? (
|
||||||
|
<div className="flex h-full w-full items-center justify-center text-xs text-zinc-400">
|
||||||
|
{t("machine.detail.activity.noData")}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
normalized.map((segment, idx) => {
|
||||||
|
const widthPct = widths[idx] ?? 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} · ${formatDuration(segment.startMs, segment.endMs)}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`${segment.type}:${segment.startMs}:${segment.endMs}:${idx}`}
|
||||||
|
title={title}
|
||||||
|
className={`h-full ${TIMELINE_COLORS[segment.type]}`}
|
||||||
|
style={{ width: `${Math.max(0, widthPct)}%` }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showWindowInfo ? (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="machine-timeline-window-title"
|
||||||
|
>
|
||||||
|
<div className="w-full max-w-sm rounded-2xl border border-white/15 bg-zinc-950 p-5">
|
||||||
|
<h3 id="machine-timeline-window-title" className="text-sm font-semibold text-white">
|
||||||
|
{t("machine.detail.activity.windowModalTitle")}
|
||||||
|
</h3>
|
||||||
|
<p className="mt-2 text-sm text-zinc-300">{t("machine.detail.activity.windowModalBody")}</p>
|
||||||
|
<div className="mt-4 flex justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowWindowInfo(false)}
|
||||||
|
className="rounded-lg border border-white/20 px-3 py-1.5 text-sm text-zinc-200 hover:border-white/40 hover:text-white"
|
||||||
|
>
|
||||||
|
{t("common.close")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function MachineDetailClient() {
|
export default function MachineDetailClient() {
|
||||||
const { t, locale } = useI18n();
|
const { t, locale } = useI18n();
|
||||||
const { screenlessMode } = useScreenlessMode();
|
const { screenlessMode } = useScreenlessMode();
|
||||||
@@ -324,7 +553,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 +600,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 {
|
||||||
@@ -467,6 +694,26 @@ export default function MachineDetailClient() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function downloadWorkOrderTemplate() {
|
||||||
|
const xlsx = await import("xlsx");
|
||||||
|
const wb = xlsx.utils.book_new();
|
||||||
|
const ws = xlsx.utils.aoa_to_sheet([
|
||||||
|
Array.from(WORK_ORDER_TEMPLATE_HEADERS),
|
||||||
|
Array.from(WORK_ORDER_TEMPLATE_EXAMPLE_ROW),
|
||||||
|
]);
|
||||||
|
xlsx.utils.book_append_sheet(wb, ws, "Work Orders");
|
||||||
|
const wbout = xlsx.write(wb, { bookType: "xlsx", type: "array" });
|
||||||
|
const blob = new Blob([wbout], {
|
||||||
|
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
});
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = "work-orders-template.xlsx";
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
async function handleWorkOrderUpload(event: ChangeEvent<HTMLInputElement>) {
|
async function handleWorkOrderUpload(event: ChangeEvent<HTMLInputElement>) {
|
||||||
const file = event.target.files?.[0];
|
const file = event.target.files?.[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
@@ -586,7 +833,7 @@ export default function MachineDetailClient() {
|
|||||||
|
|
||||||
function isOffline(ts?: string) {
|
function isOffline(ts?: string) {
|
||||||
if (!ts) return true;
|
if (!ts) return true;
|
||||||
return Date.now() - new Date(ts).getTime() > 30000;
|
return Date.now() - new Date(ts).getTime() > RECAP_HEARTBEAT_STALE_MS;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeStatus(status?: string) {
|
function normalizeStatus(status?: string) {
|
||||||
@@ -691,145 +938,6 @@ export default function MachineDetailClient() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function MachineActivityTimeline({
|
|
||||||
cycles,
|
|
||||||
cycleTarget,
|
|
||||||
thresholds,
|
|
||||||
activeStoppage,
|
|
||||||
}: {
|
|
||||||
cycles: CycleRow[];
|
|
||||||
cycleTarget: number | null;
|
|
||||||
thresholds: Thresholds | null;
|
|
||||||
activeStoppage: ActiveStoppage | null;
|
|
||||||
}) {
|
|
||||||
const [nowMs, setNowMs] = useState(() => Date.now());
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const timer = setInterval(() => setNowMs(Date.now()), 1000);
|
|
||||||
return () => clearInterval(timer);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const timeline = useMemo(() => {
|
|
||||||
const rows = cycles ?? [];
|
|
||||||
const windowSec = rows.length < 1 ? 10800 : 3600;
|
|
||||||
const end = nowMs;
|
|
||||||
const start = end - windowSec * 1000;
|
|
||||||
|
|
||||||
if (rows.length < 1) {
|
|
||||||
return {
|
|
||||||
windowSec,
|
|
||||||
segments: [] as TimelineSeg[],
|
|
||||||
start,
|
|
||||||
end,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const segs: TimelineSeg[] = [];
|
|
||||||
|
|
||||||
for (const cycle of rows) {
|
|
||||||
const ideal = (cycle.ideal ?? cycleTarget ?? 0) as number;
|
|
||||||
const actual = cycle.actual ?? 0;
|
|
||||||
if (!ideal || ideal <= 0 || !actual || actual <= 0) continue;
|
|
||||||
|
|
||||||
const cycleEnd = cycle.t;
|
|
||||||
const cycleStart = cycleEnd - actual * 1000;
|
|
||||||
if (cycleEnd <= start || cycleStart >= end) continue;
|
|
||||||
|
|
||||||
const segStart = Math.max(cycleStart, start);
|
|
||||||
const segEnd = Math.min(cycleEnd, end);
|
|
||||||
if (segEnd <= segStart) continue;
|
|
||||||
|
|
||||||
const state = classifyCycleDuration(actual, ideal, thresholds);
|
|
||||||
|
|
||||||
segs.push({
|
|
||||||
start: segStart,
|
|
||||||
end: segEnd,
|
|
||||||
durationSec: (segEnd - segStart) / 1000,
|
|
||||||
state,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (activeStoppage?.startedAt) {
|
|
||||||
const stoppageStart = new Date(activeStoppage.startedAt).getTime();
|
|
||||||
const segStart = Math.max(stoppageStart, start);
|
|
||||||
const segEnd = Math.min(end, nowMs);
|
|
||||||
if (segEnd > segStart) {
|
|
||||||
segs.push({
|
|
||||||
start: segStart,
|
|
||||||
end: segEnd,
|
|
||||||
durationSec: (segEnd - segStart) / 1000,
|
|
||||||
state: activeStoppage.state,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
segs.sort((a, b) => a.start - b.start);
|
|
||||||
|
|
||||||
return { windowSec, segments: segs, start, end };
|
|
||||||
}, [activeStoppage, cycles, cycleTarget, nowMs, thresholds]);
|
|
||||||
|
|
||||||
const { segments, windowSec } = timeline;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
|
|
||||||
<div className="flex items-start justify-between gap-4">
|
|
||||||
<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>
|
|
||||||
<div className="text-xs text-zinc-400">{windowSec}s</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4 flex flex-wrap items-center gap-4 text-xs text-zinc-300">
|
|
||||||
{(["normal", "slow", "microstop", "macrostop"] as const).map((key) => (
|
|
||||||
<div key={key} className="flex items-center gap-2">
|
|
||||||
<span className="h-2.5 w-2.5 rounded-full" style={{ backgroundColor: BUCKET[key].dot }} />
|
|
||||||
<span>{t(BUCKET[key].labelKey)}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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">
|
|
||||||
<span>0s</span>
|
|
||||||
<span>1h</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex h-14 w-full overflow-hidden rounded-2xl">
|
|
||||||
{segments.length === 0 ? (
|
|
||||||
<div className="flex h-full w-full items-center justify-center text-xs text-zinc-400">
|
|
||||||
{t("machine.detail.activity.noData")}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
segments.map((seg, idx) => {
|
|
||||||
const wPct = Math.max(0, (seg.durationSec / windowSec) * 100);
|
|
||||||
const meta = BUCKET[seg.state];
|
|
||||||
const glow =
|
|
||||||
seg.state === "microstop" || seg.state === "macrostop"
|
|
||||||
? `0 0 22px ${meta.glow}`
|
|
||||||
: `0 0 12px ${meta.glow}`;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={`${seg.start}-${seg.end}-${idx}`}
|
|
||||||
title={`${t(meta.labelKey)}: ${seg.durationSec.toFixed(1)}s`}
|
|
||||||
className="h-full"
|
|
||||||
style={{
|
|
||||||
width: `${wPct}%`,
|
|
||||||
background: meta.dot,
|
|
||||||
boxShadow: glow,
|
|
||||||
opacity: 0.95,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Modal({
|
function Modal({
|
||||||
open,
|
open,
|
||||||
onClose,
|
onClose,
|
||||||
@@ -1025,6 +1133,13 @@ export default function MachineDetailClient() {
|
|||||||
className="hidden"
|
className="hidden"
|
||||||
onChange={handleWorkOrderUpload}
|
onChange={handleWorkOrderUpload}
|
||||||
/>
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void downloadWorkOrderTemplate()}
|
||||||
|
className="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-white transition hover:bg-white/10 sm:w-auto"
|
||||||
|
>
|
||||||
|
{t("machine.detail.workOrders.downloadTemplate")}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => fileInputRef.current?.click()}
|
onClick={() => fileInputRef.current?.click()}
|
||||||
@@ -1105,13 +1220,20 @@ 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">{t("machine.detail.kpi.oeeCurrent")}</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 +1253,7 @@ export default function MachineDetailClient() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
<MachineActivityTimeline
|
<MachineActivityTimeline machineId={machineId} locale={locale} t={t} />
|
||||||
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">
|
||||||
|
|||||||
@@ -3,9 +3,10 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Suspense, lazy, useEffect, useMemo, useState } from "react";
|
import { Suspense, lazy, useEffect, useMemo, useState } from "react";
|
||||||
import { useI18n } from "@/lib/i18n/useI18n";
|
import { useI18n } from "@/lib/i18n/useI18n";
|
||||||
|
import { RECAP_HEARTBEAT_STALE_MS } from "@/lib/recap/recapUiConstants";
|
||||||
import type { EventRow, Heartbeat, MachineRow } from "./types";
|
import type { EventRow, Heartbeat, MachineRow } from "./types";
|
||||||
|
|
||||||
const OFFLINE_MS = 30000;
|
const OFFLINE_MS = RECAP_HEARTBEAT_STALE_MS;
|
||||||
const MAX_EVENT_MACHINES = 6;
|
const MAX_EVENT_MACHINES = 6;
|
||||||
const OverviewTimeline = lazy(() => import("./OverviewTimeline"));
|
const OverviewTimeline = lazy(() => import("./OverviewTimeline"));
|
||||||
|
|
||||||
@@ -199,20 +200,65 @@ export default function OverviewClient({
|
|||||||
.map((m) => {
|
.map((m) => {
|
||||||
const hb = m.latestHeartbeat;
|
const hb = m.latestHeartbeat;
|
||||||
const offline = isOffline(heartbeatTime(hb));
|
const offline = isOffline(heartbeatTime(hb));
|
||||||
|
const status = normalizeStatus(hb?.status);
|
||||||
const k = m.latestKpi;
|
const k = m.latestKpi;
|
||||||
const oee = k?.oee ?? null;
|
const oee = k?.oee ?? null;
|
||||||
|
const good = k?.good ?? null;
|
||||||
|
const scrap = k?.scrap ?? null;
|
||||||
|
const availability = k?.availability ?? null;
|
||||||
|
|
||||||
|
const reasons: string[] = [];
|
||||||
let score = 0;
|
let score = 0;
|
||||||
if (offline) score += 100;
|
|
||||||
if (oee != null && oee < 75) score += 50;
|
// Trigger 1: offline (highest priority — can't tell what's wrong)
|
||||||
if (oee != null && oee < 85) score += 25;
|
if (offline) {
|
||||||
return { machine: m, offline, oee, score };
|
score += 100;
|
||||||
|
reasons.push(t("overview.attention.offline"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger 2: stopped right now (and online — operator should act)
|
||||||
|
if (!offline && (status === "STOP" || status === "DOWN")) {
|
||||||
|
score += 60;
|
||||||
|
reasons.push(t("overview.attention.stopped"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger 3: low OEE
|
||||||
|
if (!offline && oee != null) {
|
||||||
|
if (oee < 50) {
|
||||||
|
score += 50;
|
||||||
|
reasons.push(t("overview.attention.oeeCritical", { value: oee.toFixed(0) }));
|
||||||
|
} else if (oee < 75) {
|
||||||
|
score += 30;
|
||||||
|
reasons.push(t("overview.attention.oeeLow", { value: oee.toFixed(0) }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger 4: scrap rate >5% on active WO
|
||||||
|
if (!offline && good != null && scrap != null && good + scrap > 0) {
|
||||||
|
const scrapPct = (scrap / (good + scrap)) * 100;
|
||||||
|
if (scrapPct > 10) {
|
||||||
|
score += 40;
|
||||||
|
reasons.push(t("overview.attention.scrapHigh", { value: scrapPct.toFixed(1) }));
|
||||||
|
} else if (scrapPct > 5) {
|
||||||
|
score += 20;
|
||||||
|
reasons.push(t("overview.attention.scrapMod", { value: scrapPct.toFixed(1) }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger 5: availability collapse (often means undeclared stops)
|
||||||
|
if (!offline && availability != null && availability < 60) {
|
||||||
|
score += 25;
|
||||||
|
reasons.push(t("overview.attention.availLow", { value: availability.toFixed(0) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
return { machine: m, offline, oee, score, reasons };
|
||||||
})
|
})
|
||||||
.filter((x) => x.score > 0)
|
.filter((x) => x.score > 0)
|
||||||
.sort((a, b) => b.score - a.score)
|
.sort((a, b) => b.score - a.score)
|
||||||
.slice(0, 6);
|
.slice(0, 6);
|
||||||
|
|
||||||
return list;
|
return list;
|
||||||
}, [machines]);
|
}, [machines, t]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 sm:p-6">
|
<div className="p-4 sm:p-6">
|
||||||
@@ -232,6 +278,21 @@ export default function OverviewClient({
|
|||||||
|
|
||||||
{loading && <div className="mb-4 text-sm text-zinc-400">{t("overview.loading")}</div>}
|
{loading && <div className="mb-4 text-sm text-zinc-400">{t("overview.loading")}</div>}
|
||||||
|
|
||||||
|
<div className="mb-4 rounded-2xl border border-white/10 bg-black/40 p-4">
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-semibold text-white">{t("overview.recap.title")}</div>
|
||||||
|
<div className="text-xs text-zinc-400">{t("overview.recap.subtitle")}</div>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href="/recap"
|
||||||
|
className="rounded-xl border border-emerald-500/30 bg-emerald-500/10 px-3 py-2 text-sm text-emerald-300 hover:bg-emerald-500/20"
|
||||||
|
>
|
||||||
|
{t("overview.recap.cta")}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4 xl:grid-cols-3">
|
<div className="grid grid-cols-1 gap-4 xl:grid-cols-3">
|
||||||
<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">{t("overview.fleetHealth")}</div>
|
<div className="text-xs text-zinc-400">{t("overview.fleetHealth")}</div>
|
||||||
@@ -331,8 +392,12 @@ export default function OverviewClient({
|
|||||||
<div className="text-sm text-zinc-400">{t("overview.noUrgent")}</div>
|
<div className="text-sm text-zinc-400">{t("overview.noUrgent")}</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{attention.map(({ machine, offline, oee }) => (
|
{attention.map(({ machine, offline, oee, reasons }) => (
|
||||||
<div key={machine.id} className="rounded-xl border border-white/10 bg-black/20 p-3">
|
<Link
|
||||||
|
key={machine.id}
|
||||||
|
href={`/recap/${machine.id}`}
|
||||||
|
className="block rounded-xl border border-white/10 bg-black/20 p-3 hover:border-white/20 hover:bg-black/30 transition"
|
||||||
|
>
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="truncate text-sm font-semibold text-white">{machine.name}</div>
|
<div className="truncate text-sm font-semibold text-white">{machine.name}</div>
|
||||||
@@ -344,7 +409,7 @@ export default function OverviewClient({
|
|||||||
{secondsAgo(heartbeatTime(machine.latestHeartbeat), locale, t("common.never"))}
|
{secondsAgo(heartbeatTime(machine.latestHeartbeat), locale, t("common.never"))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 flex items-center gap-2 text-xs">
|
<div className="mt-2 flex flex-wrap items-center gap-1.5 text-xs">
|
||||||
<span
|
<span
|
||||||
className={`rounded-full px-2 py-0.5 ${
|
className={`rounded-full px-2 py-0.5 ${
|
||||||
offline ? "bg-white/10 text-zinc-300" : "bg-emerald-500/15 text-emerald-300"
|
offline ? "bg-white/10 text-zinc-300" : "bg-emerald-500/15 text-emerald-300"
|
||||||
@@ -352,13 +417,20 @@ export default function OverviewClient({
|
|||||||
>
|
>
|
||||||
{offline ? t("overview.status.offline") : t("overview.status.online")}
|
{offline ? t("overview.status.offline") : t("overview.status.online")}
|
||||||
</span>
|
</span>
|
||||||
{oee != null && (
|
{oee != null && !offline && (
|
||||||
<span className="rounded-full bg-yellow-500/15 px-2 py-0.5 text-yellow-300">
|
<span className="rounded-full bg-yellow-500/15 px-2 py-0.5 text-yellow-300">
|
||||||
OEE {fmtPct(oee)}
|
OEE {fmtPct(oee)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{reasons.length > 0 && (
|
||||||
|
<ul className="mt-2 space-y-0.5 text-[11px] text-zinc-400">
|
||||||
|
{reasons.map((r, i) => (
|
||||||
|
<li key={i}>· {r}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
388
app/(app)/overview/OverviewClient.tsx.bak
Normal file
388
app/(app)/overview/OverviewClient.tsx.bak
Normal file
@@ -0,0 +1,388 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Suspense, lazy, useEffect, useMemo, useState } from "react";
|
||||||
|
import { useI18n } from "@/lib/i18n/useI18n";
|
||||||
|
import type { EventRow, Heartbeat, MachineRow } from "./types";
|
||||||
|
|
||||||
|
const OFFLINE_MS = 30000;
|
||||||
|
const MAX_EVENT_MACHINES = 6;
|
||||||
|
const OverviewTimeline = lazy(() => import("./OverviewTimeline"));
|
||||||
|
|
||||||
|
function secondsAgo(ts: string | undefined, locale: string, fallback: string) {
|
||||||
|
if (!ts) return fallback;
|
||||||
|
const diff = Math.floor((Date.now() - new Date(ts).getTime()) / 1000);
|
||||||
|
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: "auto" });
|
||||||
|
if (diff < 60) return rtf.format(-diff, "second");
|
||||||
|
if (diff < 3600) return rtf.format(-Math.floor(diff / 60), "minute");
|
||||||
|
return rtf.format(-Math.floor(diff / 3600), "hour");
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOffline(ts?: string) {
|
||||||
|
if (!ts) return true;
|
||||||
|
return Date.now() - new Date(ts).getTime() > OFFLINE_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeStatus(status?: string) {
|
||||||
|
const s = (status ?? "").toUpperCase();
|
||||||
|
if (s === "ONLINE") return "RUN";
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function heartbeatTime(hb?: Heartbeat | null) {
|
||||||
|
return hb?.tsServer ?? hb?.ts;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtPct(v?: number | null) {
|
||||||
|
if (v === null || v === undefined || Number.isNaN(v)) return "--";
|
||||||
|
return `${v.toFixed(1)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtNum(v?: number | null) {
|
||||||
|
if (v === null || v === undefined || Number.isNaN(v)) return "--";
|
||||||
|
return `${Math.round(v)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function OverviewTimelineSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border border-white/10 bg-white/5 p-5 xl:col-span-2">
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<div className="h-4 w-32 rounded bg-white/10" />
|
||||||
|
<div className="h-3 w-20 rounded bg-white/5" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{Array.from({ length: 4 }).map((_, idx) => (
|
||||||
|
<div key={idx} className="h-20 rounded-xl border border-white/10 bg-black/20" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function OverviewClient({
|
||||||
|
initialMachines = [],
|
||||||
|
initialEvents = [],
|
||||||
|
}: {
|
||||||
|
initialMachines?: MachineRow[];
|
||||||
|
initialEvents?: EventRow[];
|
||||||
|
}) {
|
||||||
|
const { t, locale } = useI18n();
|
||||||
|
const [machines, setMachines] = useState<MachineRow[]>(() => initialMachines);
|
||||||
|
const [events, setEvents] = useState<EventRow[]>(() => initialEvents);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [eventsLoading, setEventsLoading] = useState(() => initialEvents.length === 0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
setEventsLoading(true);
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/overview?detail=1&events=critical&eventMachines=${MAX_EVENT_MACHINES}`,
|
||||||
|
{
|
||||||
|
cache: "no-cache",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (res.status === 304) {
|
||||||
|
if (alive) setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const json = await res.json().catch(() => ({}));
|
||||||
|
if (!alive) return;
|
||||||
|
setMachines(json.machines ?? []);
|
||||||
|
setEvents(json.events ?? []);
|
||||||
|
setLoading(false);
|
||||||
|
} catch {
|
||||||
|
if (!alive) return;
|
||||||
|
setMachines([]);
|
||||||
|
setEvents([]);
|
||||||
|
setLoading(false);
|
||||||
|
} finally {
|
||||||
|
if (alive) setEventsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
load();
|
||||||
|
const t = setInterval(load, 30000);
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
clearInterval(t);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const stats = useMemo(() => {
|
||||||
|
const total = machines.length;
|
||||||
|
let online = 0;
|
||||||
|
let running = 0;
|
||||||
|
let idle = 0;
|
||||||
|
let stopped = 0;
|
||||||
|
let oeeSum = 0;
|
||||||
|
let oeeCount = 0;
|
||||||
|
let availSum = 0;
|
||||||
|
let availCount = 0;
|
||||||
|
let perfSum = 0;
|
||||||
|
let perfCount = 0;
|
||||||
|
let qualSum = 0;
|
||||||
|
let qualCount = 0;
|
||||||
|
let goodSum = 0;
|
||||||
|
let scrapSum = 0;
|
||||||
|
let targetSum = 0;
|
||||||
|
let hasKpi = false;
|
||||||
|
|
||||||
|
for (const m of machines) {
|
||||||
|
const hb = m.latestHeartbeat;
|
||||||
|
const offline = isOffline(heartbeatTime(hb));
|
||||||
|
if (!offline) online += 1;
|
||||||
|
|
||||||
|
const status = normalizeStatus(hb?.status);
|
||||||
|
if (!offline) {
|
||||||
|
if (status === "RUN") running += 1;
|
||||||
|
else if (status === "IDLE") idle += 1;
|
||||||
|
else if (status === "STOP" || status === "DOWN") stopped += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const k = m.latestKpi;
|
||||||
|
if (k?.oee != null) {
|
||||||
|
oeeSum += Number(k.oee);
|
||||||
|
oeeCount += 1;
|
||||||
|
hasKpi = true;
|
||||||
|
}
|
||||||
|
if (k?.availability != null) {
|
||||||
|
availSum += Number(k.availability);
|
||||||
|
availCount += 1;
|
||||||
|
hasKpi = true;
|
||||||
|
}
|
||||||
|
if (k?.performance != null) {
|
||||||
|
perfSum += Number(k.performance);
|
||||||
|
perfCount += 1;
|
||||||
|
hasKpi = true;
|
||||||
|
}
|
||||||
|
if (k?.quality != null) {
|
||||||
|
qualSum += Number(k.quality);
|
||||||
|
qualCount += 1;
|
||||||
|
hasKpi = true;
|
||||||
|
}
|
||||||
|
if (k?.good != null) {
|
||||||
|
goodSum += Number(k.good);
|
||||||
|
hasKpi = true;
|
||||||
|
}
|
||||||
|
if (k?.scrap != null) {
|
||||||
|
scrapSum += Number(k.scrap);
|
||||||
|
hasKpi = true;
|
||||||
|
}
|
||||||
|
if (k?.target != null) {
|
||||||
|
targetSum += Number(k.target);
|
||||||
|
hasKpi = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
total,
|
||||||
|
online,
|
||||||
|
offline: total - online,
|
||||||
|
running,
|
||||||
|
idle,
|
||||||
|
stopped,
|
||||||
|
oee: oeeCount ? oeeSum / oeeCount : null,
|
||||||
|
availability: availCount ? availSum / availCount : null,
|
||||||
|
performance: perfCount ? perfSum / perfCount : null,
|
||||||
|
quality: qualCount ? qualSum / qualCount : null,
|
||||||
|
goodSum: hasKpi ? goodSum : null,
|
||||||
|
scrapSum: hasKpi ? scrapSum : null,
|
||||||
|
targetSum: hasKpi ? targetSum : null,
|
||||||
|
};
|
||||||
|
}, [machines]);
|
||||||
|
|
||||||
|
const attention = useMemo(() => {
|
||||||
|
const list = machines
|
||||||
|
.map((m) => {
|
||||||
|
const hb = m.latestHeartbeat;
|
||||||
|
const offline = isOffline(heartbeatTime(hb));
|
||||||
|
const k = m.latestKpi;
|
||||||
|
const oee = k?.oee ?? null;
|
||||||
|
let score = 0;
|
||||||
|
if (offline) score += 100;
|
||||||
|
if (oee != null && oee < 75) score += 50;
|
||||||
|
if (oee != null && oee < 85) score += 25;
|
||||||
|
return { machine: m, offline, oee, score };
|
||||||
|
})
|
||||||
|
.filter((x) => x.score > 0)
|
||||||
|
.sort((a, b) => b.score - a.score)
|
||||||
|
.slice(0, 6);
|
||||||
|
|
||||||
|
return list;
|
||||||
|
}, [machines]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4 sm:p-6">
|
||||||
|
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold text-white">{t("overview.title")}</h1>
|
||||||
|
<p className="text-sm text-zinc-400">{t("overview.subtitle")}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href="/machines"
|
||||||
|
className="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-center text-sm text-white hover:bg-white/10 sm:w-auto"
|
||||||
|
>
|
||||||
|
{t("overview.viewMachines")}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && <div className="mb-4 text-sm text-zinc-400">{t("overview.loading")}</div>}
|
||||||
|
|
||||||
|
<div className="mb-4 rounded-2xl border border-white/10 bg-black/40 p-4">
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-semibold text-white">{t("overview.recap.title")}</div>
|
||||||
|
<div className="text-xs text-zinc-400">{t("overview.recap.subtitle")}</div>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href="/recap"
|
||||||
|
className="rounded-xl border border-emerald-500/30 bg-emerald-500/10 px-3 py-2 text-sm text-emerald-300 hover:bg-emerald-500/20"
|
||||||
|
>
|
||||||
|
{t("overview.recap.cta")}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 xl:grid-cols-3">
|
||||||
|
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
|
||||||
|
<div className="text-xs text-zinc-400">{t("overview.fleetHealth")}</div>
|
||||||
|
<div className="mt-2 text-3xl font-semibold text-white">{stats.total}</div>
|
||||||
|
<div className="mt-2 text-xs text-zinc-400">{t("overview.machinesTotal")}</div>
|
||||||
|
<div className="mt-4 flex flex-wrap gap-2 text-xs">
|
||||||
|
<span className="rounded-full bg-emerald-500/15 px-2 py-0.5 text-emerald-300">
|
||||||
|
{t("overview.online")} {stats.online}
|
||||||
|
</span>
|
||||||
|
<span className="rounded-full bg-white/10 px-2 py-0.5 text-zinc-300">
|
||||||
|
{t("overview.offline")} {stats.offline}
|
||||||
|
</span>
|
||||||
|
<span className="rounded-full bg-emerald-500/10 px-2 py-0.5 text-emerald-200">
|
||||||
|
{t("overview.run")} {stats.running}
|
||||||
|
</span>
|
||||||
|
<span className="rounded-full bg-yellow-500/15 px-2 py-0.5 text-yellow-300">
|
||||||
|
{t("overview.idle")} {stats.idle}
|
||||||
|
</span>
|
||||||
|
<span className="rounded-full bg-red-500/15 px-2 py-0.5 text-red-300">
|
||||||
|
{t("overview.stop")} {stats.stopped}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
|
||||||
|
<div className="text-xs text-zinc-400">{t("overview.productionTotals")}</div>
|
||||||
|
<div className="mt-2 grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||||
|
<div className="rounded-xl border border-white/10 bg-black/20 p-3">
|
||||||
|
<div className="text-[11px] text-zinc-400">{t("overview.good")}</div>
|
||||||
|
<div className="mt-1 text-sm font-semibold text-white">{fmtNum(stats.goodSum)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-white/10 bg-black/20 p-3">
|
||||||
|
<div className="text-[11px] text-zinc-400">{t("overview.scrap")}</div>
|
||||||
|
<div className="mt-1 text-sm font-semibold text-white">{fmtNum(stats.scrapSum)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-white/10 bg-black/20 p-3">
|
||||||
|
<div className="text-[11px] text-zinc-400">{t("overview.target")}</div>
|
||||||
|
<div className="mt-1 text-sm font-semibold text-white">{fmtNum(stats.targetSum)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 text-xs text-zinc-400">{t("overview.kpiSumNote")}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
|
||||||
|
<div className="text-xs text-zinc-400">{t("overview.activityFeed")}</div>
|
||||||
|
<div className="mt-2 text-3xl font-semibold text-white">{events.length}</div>
|
||||||
|
<div className="mt-2 text-xs text-zinc-400">
|
||||||
|
{eventsLoading ? t("overview.eventsRefreshing") : t("overview.eventsLast30")}
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 space-y-2">
|
||||||
|
{events.slice(0, 3).map((e) => (
|
||||||
|
<div key={e.id} className="flex items-center justify-between text-xs text-zinc-300">
|
||||||
|
<div className="truncate">
|
||||||
|
{e.machineName ? `${e.machineName}: ` : ""}
|
||||||
|
{e.title}
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0 text-zinc-500">
|
||||||
|
{secondsAgo(e.ts, locale, t("common.never"))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{events.length === 0 && !eventsLoading ? (
|
||||||
|
<div className="text-xs text-zinc-500">{t("overview.eventsNone")}</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 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="text-xs text-zinc-400">{t("overview.oeeAvg")}</div>
|
||||||
|
<div className="mt-2 text-3xl font-semibold text-emerald-300">{fmtPct(stats.oee)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
|
||||||
|
<div className="text-xs text-zinc-400">{t("overview.availabilityAvg")}</div>
|
||||||
|
<div className="mt-2 text-2xl font-semibold text-white">{fmtPct(stats.availability)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
|
||||||
|
<div className="text-xs text-zinc-400">{t("overview.performanceAvg")}</div>
|
||||||
|
<div className="mt-2 text-2xl font-semibold text-white">{fmtPct(stats.performance)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
|
||||||
|
<div className="text-xs text-zinc-400">{t("overview.qualityAvg")}</div>
|
||||||
|
<div className="mt-2 text-2xl font-semibold text-white">{fmtPct(stats.quality)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 grid grid-cols-1 gap-4 xl:grid-cols-3">
|
||||||
|
<div className="rounded-2xl border border-white/10 bg-white/5 p-5 xl:col-span-1">
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<div className="text-sm font-semibold text-white">{t("overview.attentionList")}</div>
|
||||||
|
<div className="text-xs text-zinc-400">
|
||||||
|
{attention.length} {t("overview.shown")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{attention.length === 0 ? (
|
||||||
|
<div className="text-sm text-zinc-400">{t("overview.noUrgent")}</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{attention.map(({ machine, offline, oee }) => (
|
||||||
|
<div key={machine.id} className="rounded-xl border border-white/10 bg-black/20 p-3">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate text-sm font-semibold text-white">{machine.name}</div>
|
||||||
|
<div className="mt-1 text-xs text-zinc-400">
|
||||||
|
{machine.code ?? ""} {machine.location ? `- ${machine.location}` : ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-zinc-400">
|
||||||
|
{secondsAgo(heartbeatTime(machine.latestHeartbeat), locale, t("common.never"))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex items-center gap-2 text-xs">
|
||||||
|
<span
|
||||||
|
className={`rounded-full px-2 py-0.5 ${
|
||||||
|
offline ? "bg-white/10 text-zinc-300" : "bg-emerald-500/15 text-emerald-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{offline ? t("overview.status.offline") : t("overview.status.online")}
|
||||||
|
</span>
|
||||||
|
{oee != null && (
|
||||||
|
<span className="rounded-full bg-yellow-500/15 px-2 py-0.5 text-yellow-300">
|
||||||
|
OEE {fmtPct(oee)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Suspense fallback={<OverviewTimelineSkeleton />}>
|
||||||
|
<OverviewTimeline events={events} eventsLoading={eventsLoading} locale={locale} t={t} />
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
154
app/(app)/recap/RecapGridClient.tsx
Normal file
154
app/(app)/recap/RecapGridClient.tsx
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
"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");
|
||||||
|
if (status === "idle") return t("recap.status.idle");
|
||||||
|
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", "idle", "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>
|
||||||
|
);
|
||||||
|
}
|
||||||
153
app/(app)/recap/RecapGridClient.tsx.bak.step5
Normal file
153
app/(app)/recap/RecapGridClient.tsx.bak.step5
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
30
app/(app)/recap/RecapPageSkeletons.tsx
Normal file
30
app/(app)/recap/RecapPageSkeletons.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
/**
|
||||||
|
* Shared markup for loading states (used by `loading.tsx` and explicit `<Suspense>` in pages)
|
||||||
|
* so the recap UI always shows the same skeleton while server data is pending.
|
||||||
|
*/
|
||||||
|
export function RecapGridPageSkeleton() {
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RecapDetailPageSkeleton() {
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
240
app/(app)/recap/[machineId]/RecapDetailClient.tsx
Normal file
240
app/(app)/recap/[machineId]/RecapDetailClient.tsx
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
"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 [timelineLoading, setTimelineLoading] = useState(true);
|
||||||
|
const [nowMs, setNowMs] = useState(() => Date.now());
|
||||||
|
|
||||||
|
const [customStart, setCustomStart] = useState(toInputDate(initialData.range.start));
|
||||||
|
const [customEnd, setCustomEnd] = useState(toInputDate(initialData.range.end));
|
||||||
|
|
||||||
|
const requestedRange =
|
||||||
|
(searchParams.get("range") as RecapRangeMode | null) ?? initialData.range.requestedMode ?? initialData.range.mode;
|
||||||
|
const selectedRange = requestedRange;
|
||||||
|
const shiftAvailable = initialData.range.shiftAvailable ?? true;
|
||||||
|
const shiftFallbackReason = initialData.range.fallbackReason;
|
||||||
|
const shiftFallbackActive = selectedRange === "shift" && initialData.range.mode !== "shift";
|
||||||
|
|
||||||
|
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 ?? [];
|
||||||
|
const timelineHasData = timeline?.hasData ?? false;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
setTimeline(null);
|
||||||
|
setTimelineLoading(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 {
|
||||||
|
} finally {
|
||||||
|
if (alive) setTimelineLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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"
|
||||||
|
disabled={range === "shift" && !shiftAvailable}
|
||||||
|
onClick={() => {
|
||||||
|
if (range === "shift" && !shiftAvailable) return;
|
||||||
|
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 === "shift" && !shiftAvailable ? "cursor-not-allowed opacity-60" : ""}`}
|
||||||
|
>
|
||||||
|
{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>
|
||||||
|
|
||||||
|
{!shiftAvailable ? (
|
||||||
|
<div className="mb-4 rounded-xl border border-amber-400/40 bg-amber-400/10 px-3 py-2 text-xs text-amber-100">
|
||||||
|
{t("recap.range.shiftUnavailable")}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{shiftFallbackActive ? (
|
||||||
|
<div className="mb-4 rounded-xl border border-amber-400/40 bg-amber-400/10 px-3 py-2 text-xs text-amber-100">
|
||||||
|
{shiftFallbackReason === "shift-inactive" ? t("recap.range.shiftFallbackInactive") : t("recap.range.shiftFallbackUnavailable")}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{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}
|
||||||
|
rangeMode={initialData.range.mode}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="mt-4">
|
||||||
|
<RecapFullTimeline
|
||||||
|
rangeStart={timelineStart}
|
||||||
|
rangeEnd={timelineEnd}
|
||||||
|
segments={timelineSegments}
|
||||||
|
hasData={timelineHasData}
|
||||||
|
loading={timelineLoading}
|
||||||
|
locale={locale}
|
||||||
|
rangeMode={initialData.range.mode}
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
240
app/(app)/recap/[machineId]/RecapDetailClient.tsx.bak
Normal file
240
app/(app)/recap/[machineId]/RecapDetailClient.tsx.bak
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
"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 [timelineLoading, setTimelineLoading] = useState(true);
|
||||||
|
const [nowMs, setNowMs] = useState(() => Date.now());
|
||||||
|
|
||||||
|
const [customStart, setCustomStart] = useState(toInputDate(initialData.range.start));
|
||||||
|
const [customEnd, setCustomEnd] = useState(toInputDate(initialData.range.end));
|
||||||
|
|
||||||
|
const requestedRange =
|
||||||
|
(searchParams.get("range") as RecapRangeMode | null) ?? initialData.range.requestedMode ?? initialData.range.mode;
|
||||||
|
const selectedRange = requestedRange;
|
||||||
|
const shiftAvailable = initialData.range.shiftAvailable ?? true;
|
||||||
|
const shiftFallbackReason = initialData.range.fallbackReason;
|
||||||
|
const shiftFallbackActive = selectedRange === "shift" && initialData.range.mode !== "shift";
|
||||||
|
|
||||||
|
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 ?? [];
|
||||||
|
const timelineHasData = timeline?.hasData ?? false;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
setTimeline(null);
|
||||||
|
setTimelineLoading(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 {
|
||||||
|
} finally {
|
||||||
|
if (alive) setTimelineLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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"
|
||||||
|
disabled={range === "shift" && !shiftAvailable}
|
||||||
|
onClick={() => {
|
||||||
|
if (range === "shift" && !shiftAvailable) return;
|
||||||
|
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 === "shift" && !shiftAvailable ? "cursor-not-allowed opacity-60" : ""}`}
|
||||||
|
>
|
||||||
|
{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>
|
||||||
|
|
||||||
|
{!shiftAvailable ? (
|
||||||
|
<div className="mb-4 rounded-xl border border-amber-400/40 bg-amber-400/10 px-3 py-2 text-xs text-amber-100">
|
||||||
|
{t("recap.range.shiftUnavailable")}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{shiftFallbackActive ? (
|
||||||
|
<div className="mb-4 rounded-xl border border-amber-400/40 bg-amber-400/10 px-3 py-2 text-xs text-amber-100">
|
||||||
|
{shiftFallbackReason === "shift-inactive" ? t("recap.range.shiftFallbackInactive") : t("recap.range.shiftFallbackUnavailable")}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{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}
|
||||||
|
rangeMode={initialData.range.mode}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="mt-4">
|
||||||
|
<RecapFullTimeline
|
||||||
|
rangeStart={timelineStart}
|
||||||
|
rangeEnd={timelineEnd}
|
||||||
|
segments={timelineSegments}
|
||||||
|
hasData={timelineHasData}
|
||||||
|
loading={timelineLoading}
|
||||||
|
locale={locale}
|
||||||
|
rangeMode={initialData.range.mode}
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
app/(app)/recap/[machineId]/loading.tsx
Normal file
5
app/(app)/recap/[machineId]/loading.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { RecapDetailPageSkeleton } from "../RecapPageSkeletons";
|
||||||
|
|
||||||
|
export default function LoadingRecapDetail() {
|
||||||
|
return <RecapDetailPageSkeleton />;
|
||||||
|
}
|
||||||
51
app/(app)/recap/[machineId]/page.tsx
Normal file
51
app/(app)/recap/[machineId]/page.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { Suspense } from "react";
|
||||||
|
import { notFound, redirect } from "next/navigation";
|
||||||
|
import { requireSession } from "@/lib/auth/requireSession";
|
||||||
|
import { getRecapMachineDetailCached, parseRecapDetailRangeInput } from "@/lib/recap/redesign";
|
||||||
|
import { RecapDetailPageSkeleton } from "../RecapPageSkeletons";
|
||||||
|
import RecapDetailClient from "./RecapDetailClient";
|
||||||
|
|
||||||
|
async function RecapDetailData({
|
||||||
|
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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RecapMachineDetailPage({
|
||||||
|
params,
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ machineId: string }>;
|
||||||
|
searchParams?: Promise<Record<string, string | string[] | undefined>>;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<RecapDetailPageSkeleton />}>
|
||||||
|
<RecapDetailData params={params} searchParams={searchParams} />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
app/(app)/recap/loading.tsx
Normal file
5
app/(app)/recap/loading.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { RecapGridPageSkeleton } from "./RecapPageSkeletons";
|
||||||
|
|
||||||
|
export default function LoadingRecapGrid() {
|
||||||
|
return <RecapGridPageSkeleton />;
|
||||||
|
}
|
||||||
26
app/(app)/recap/page.tsx
Normal file
26
app/(app)/recap/page.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { Suspense } from "react";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { requireSession } from "@/lib/auth/requireSession";
|
||||||
|
import { getRecapSummaryCached } from "@/lib/recap/redesign";
|
||||||
|
import RecapGridClient from "./RecapGridClient";
|
||||||
|
import { RecapGridPageSkeleton } from "./RecapPageSkeletons";
|
||||||
|
|
||||||
|
async function RecapGridData() {
|
||||||
|
const session = await requireSession();
|
||||||
|
if (!session) redirect("/login?next=/recap");
|
||||||
|
|
||||||
|
const initialData = await getRecapSummaryCached({
|
||||||
|
orgId: session.orgId,
|
||||||
|
hours: 24,
|
||||||
|
});
|
||||||
|
|
||||||
|
return <RecapGridClient initialData={initialData} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RecapPage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<RecapGridPageSkeleton />}>
|
||||||
|
<RecapGridData />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -21,7 +21,7 @@ type SimpleTooltipProps<T> = {
|
|||||||
label?: string | number;
|
label?: string | number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ChartPoint = { ts: string; label: string; value: number };
|
type ChartPoint = { ts: string; label: string; value: number | null };
|
||||||
type CycleHistogramRow = {
|
type CycleHistogramRow = {
|
||||||
label: string;
|
label: string;
|
||||||
count: number;
|
count: number;
|
||||||
@@ -135,7 +135,14 @@ export default function ReportsCharts({
|
|||||||
"OEE",
|
"OEE",
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<Line type="monotone" dataKey="value" stroke="#34d399" dot={false} strokeWidth={2} />
|
<Line
|
||||||
|
type="linear"
|
||||||
|
dataKey="value"
|
||||||
|
stroke="#34d399"
|
||||||
|
dot={false}
|
||||||
|
strokeWidth={2}
|
||||||
|
connectNulls={false}
|
||||||
|
/>
|
||||||
</LineChart>
|
</LineChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
706
app/(app)/reports/ReportsPageClient.tsx
Normal file
706
app/(app)/reports/ReportsPageClient.tsx
Normal file
@@ -0,0 +1,706 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Suspense, lazy, useEffect, useMemo, useState } from "react";
|
||||||
|
import { useI18n } from "@/lib/i18n/useI18n";
|
||||||
|
|
||||||
|
const ReportsCharts = lazy(() => import("./ReportsCharts"));
|
||||||
|
|
||||||
|
type RangeKey = "24h" | "7d" | "30d" | "custom";
|
||||||
|
|
||||||
|
type ReportSummary = {
|
||||||
|
oeeAvg: number | null;
|
||||||
|
availabilityAvg: number | null;
|
||||||
|
performanceAvg: number | null;
|
||||||
|
qualityAvg: number | null;
|
||||||
|
goodTotal: number | null;
|
||||||
|
scrapTotal: number | null;
|
||||||
|
targetTotal: number | null;
|
||||||
|
scrapRate: number | null;
|
||||||
|
topScrapSku?: string | null;
|
||||||
|
topScrapWorkOrder?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ReportDowntime = {
|
||||||
|
macrostopSec: number;
|
||||||
|
microstopSec: number;
|
||||||
|
slowCycleCount: number;
|
||||||
|
qualitySpikeCount: number;
|
||||||
|
performanceDegradationCount: number;
|
||||||
|
oeeDropCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ReportTrendPoint = { t: string; v: number | null };
|
||||||
|
|
||||||
|
type ReportPayload = {
|
||||||
|
summary: ReportSummary;
|
||||||
|
downtime: ReportDowntime;
|
||||||
|
trend: {
|
||||||
|
oee: ReportTrendPoint[];
|
||||||
|
availability: ReportTrendPoint[];
|
||||||
|
performance: ReportTrendPoint[];
|
||||||
|
quality: ReportTrendPoint[];
|
||||||
|
scrapRate: ReportTrendPoint[];
|
||||||
|
};
|
||||||
|
distribution: {
|
||||||
|
cycleTime: {
|
||||||
|
label: string;
|
||||||
|
count: number;
|
||||||
|
rangeStart?: number;
|
||||||
|
rangeEnd?: number;
|
||||||
|
overflow?: "low" | "high";
|
||||||
|
minValue?: number;
|
||||||
|
maxValue?: number;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
insights?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type MachineOption = { id: string; name: string };
|
||||||
|
type FilterOptions = { workOrders: string[]; skus: string[] };
|
||||||
|
type Translator = (key: string, vars?: Record<string, string | number>) => string;
|
||||||
|
|
||||||
|
function fmtPct(v?: number | null) {
|
||||||
|
if (v === null || v === undefined || Number.isNaN(v)) return "--";
|
||||||
|
return `${v.toFixed(1)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDuration(sec?: number | null) {
|
||||||
|
if (!sec) return "--";
|
||||||
|
const h = Math.floor(sec / 3600);
|
||||||
|
const m = Math.floor((sec % 3600) / 60);
|
||||||
|
if (h > 0) return `${h}h ${m}m`;
|
||||||
|
return `${m}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function downsample<T>(rows: T[], max: number) {
|
||||||
|
if (rows.length <= max) return rows;
|
||||||
|
const step = Math.ceil(rows.length / max);
|
||||||
|
return rows.filter((_, idx) => idx % step === 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function downsampleTrendPreserveGaps(rows: ReportTrendPoint[], max: number) {
|
||||||
|
if (rows.length <= max) return rows;
|
||||||
|
const step = Math.ceil(rows.length / max);
|
||||||
|
const picked = new Set<number>();
|
||||||
|
|
||||||
|
picked.add(0);
|
||||||
|
picked.add(rows.length - 1);
|
||||||
|
for (let idx = 0; idx < rows.length; idx += step) picked.add(idx);
|
||||||
|
|
||||||
|
// Keep both sides of null/non-null transitions so chart gaps remain visible.
|
||||||
|
for (let idx = 1; idx < rows.length; idx += 1) {
|
||||||
|
const prevIsNull = rows[idx - 1]?.v == null;
|
||||||
|
const currIsNull = rows[idx]?.v == null;
|
||||||
|
if (prevIsNull !== currIsNull) {
|
||||||
|
picked.add(idx - 1);
|
||||||
|
picked.add(idx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...picked]
|
||||||
|
.sort((a, b) => a - b)
|
||||||
|
.map((idx) => rows[idx])
|
||||||
|
.filter((row): row is ReportTrendPoint => row != null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTickLabel(ts: string, range: RangeKey) {
|
||||||
|
const d = new Date(ts);
|
||||||
|
if (Number.isNaN(d.getTime())) return ts;
|
||||||
|
const hh = String(d.getHours()).padStart(2, "0");
|
||||||
|
const mm = String(d.getMinutes()).padStart(2, "0");
|
||||||
|
const month = String(d.getMonth() + 1).padStart(2, "0");
|
||||||
|
const day = String(d.getDate()).padStart(2, "0");
|
||||||
|
if (range === "24h") return `${hh}:${mm}`;
|
||||||
|
return `${month}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReportsChartsSkeleton() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="mt-6 grid grid-cols-1 gap-4 xl:grid-cols-2">
|
||||||
|
{Array.from({ length: 2 }).map((_, idx) => (
|
||||||
|
<div key={idx} className="h-[320px] rounded-2xl border border-white/10 bg-white/5" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-6 grid grid-cols-1 gap-4 xl:grid-cols-3">
|
||||||
|
{Array.from({ length: 3 }).map((_, idx) => (
|
||||||
|
<div key={idx} className="h-[280px] rounded-2xl border border-white/10 bg-white/5" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCsv(report: ReportPayload, t: Translator) {
|
||||||
|
const rows = new Map<string, Record<string, string | number | null>>();
|
||||||
|
const addSeries = (series: ReportTrendPoint[], key: string) => {
|
||||||
|
for (const p of series) {
|
||||||
|
const row = rows.get(p.t) ?? { timestamp: p.t };
|
||||||
|
row[key] = p.v;
|
||||||
|
rows.set(p.t, row);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
addSeries(report.trend.oee, "oee");
|
||||||
|
addSeries(report.trend.availability, "availability");
|
||||||
|
addSeries(report.trend.performance, "performance");
|
||||||
|
addSeries(report.trend.quality, "quality");
|
||||||
|
addSeries(report.trend.scrapRate, "scrapRate");
|
||||||
|
|
||||||
|
const ordered = [...rows.values()].sort((a, b) => {
|
||||||
|
const at = new Date(String(a.timestamp)).getTime();
|
||||||
|
const bt = new Date(String(b.timestamp)).getTime();
|
||||||
|
return at - bt;
|
||||||
|
});
|
||||||
|
|
||||||
|
const header = ["timestamp", "oee", "availability", "performance", "quality", "scrapRate"].join(",");
|
||||||
|
const lines = ordered.map((row) =>
|
||||||
|
[
|
||||||
|
row.timestamp,
|
||||||
|
row.oee ?? "",
|
||||||
|
row.availability ?? "",
|
||||||
|
row.performance ?? "",
|
||||||
|
row.quality ?? "",
|
||||||
|
row.scrapRate ?? "",
|
||||||
|
]
|
||||||
|
.map((v) => (v == null ? "" : String(v)))
|
||||||
|
.join(",")
|
||||||
|
);
|
||||||
|
|
||||||
|
const summary = report.summary;
|
||||||
|
const downtime = report.downtime;
|
||||||
|
|
||||||
|
const sectionLines: string[] = [];
|
||||||
|
sectionLines.push(
|
||||||
|
[t("reports.csv.section"), t("reports.csv.key"), t("reports.csv.value")].join(",")
|
||||||
|
);
|
||||||
|
const addRow = (section: string, key: string, value: string | number | null | undefined) => {
|
||||||
|
sectionLines.push(
|
||||||
|
[section, key, value == null ? "" : String(value)]
|
||||||
|
.map((v) => (v.includes(",") ? `"${v.replace(/\"/g, '""')}"` : v))
|
||||||
|
.join(",")
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
addRow("summary", "oeeAvg", summary.oeeAvg);
|
||||||
|
addRow("summary", "availabilityAvg", summary.availabilityAvg);
|
||||||
|
addRow("summary", "performanceAvg", summary.performanceAvg);
|
||||||
|
addRow("summary", "qualityAvg", summary.qualityAvg);
|
||||||
|
addRow("summary", "goodTotal", summary.goodTotal);
|
||||||
|
addRow("summary", "scrapTotal", summary.scrapTotal);
|
||||||
|
addRow("summary", "targetTotal", summary.targetTotal);
|
||||||
|
addRow("summary", "scrapRate", summary.scrapRate);
|
||||||
|
addRow("summary", "topScrapSku", summary.topScrapSku ?? "");
|
||||||
|
addRow("summary", "topScrapWorkOrder", summary.topScrapWorkOrder ?? "");
|
||||||
|
|
||||||
|
addRow("loss_drivers", "macrostopSec", downtime.macrostopSec);
|
||||||
|
addRow("loss_drivers", "microstopSec", downtime.microstopSec);
|
||||||
|
addRow("loss_drivers", "slowCycleCount", downtime.slowCycleCount);
|
||||||
|
addRow("loss_drivers", "qualitySpikeCount", downtime.qualitySpikeCount);
|
||||||
|
addRow("loss_drivers", "performanceDegradationCount", downtime.performanceDegradationCount);
|
||||||
|
addRow("loss_drivers", "oeeDropCount", downtime.oeeDropCount);
|
||||||
|
|
||||||
|
for (const bin of report.distribution.cycleTime) {
|
||||||
|
addRow("cycle_distribution", bin.label, bin.count);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (report.insights?.length) {
|
||||||
|
report.insights.forEach((note, idx) => addRow("insights", String(idx + 1), note));
|
||||||
|
}
|
||||||
|
|
||||||
|
return [header, ...lines, "", ...sectionLines].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadText(filename: string, content: string) {
|
||||||
|
const blob = new Blob([content], { type: "text/csv;charset=utf-8;" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = url;
|
||||||
|
link.setAttribute("download", filename);
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPdfHtml(
|
||||||
|
report: ReportPayload,
|
||||||
|
rangeLabel: string,
|
||||||
|
filters: { machine: string; workOrder: string; sku: string },
|
||||||
|
t: Translator
|
||||||
|
) {
|
||||||
|
const summary = report.summary;
|
||||||
|
const downtime = report.downtime;
|
||||||
|
const cycleBins = report.distribution.cycleTime;
|
||||||
|
const insights = report.insights ?? [];
|
||||||
|
|
||||||
|
return `
|
||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>${t("reports.pdf.title")}</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: Arial, sans-serif; color: #111; margin: 24px; }
|
||||||
|
h1 { margin: 0 0 6px; }
|
||||||
|
.meta { margin-bottom: 16px; color: #555; }
|
||||||
|
.grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
|
||||||
|
.card { border: 1px solid #ddd; border-radius: 8px; padding: 12px; }
|
||||||
|
.label { color: #666; font-size: 12px; text-transform: uppercase; letter-spacing: .03em; }
|
||||||
|
.value { font-size: 18px; font-weight: 600; margin-top: 6px; }
|
||||||
|
table { width: 100%; border-collapse: collapse; margin-top: 12px; }
|
||||||
|
th, td { border: 1px solid #ddd; padding: 6px 8px; font-size: 12px; }
|
||||||
|
th { background: #f5f5f5; text-align: left; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>${t("reports.title")}</h1>
|
||||||
|
<div class="meta">${t("reports.pdf.range")}: ${rangeLabel} | ${t("reports.pdf.machine")}: ${filters.machine} | ${t("reports.pdf.workOrder")}: ${filters.workOrder} | ${t("reports.pdf.sku")}: ${filters.sku}</div>
|
||||||
|
|
||||||
|
<div class="grid">
|
||||||
|
<div class="card">
|
||||||
|
<div class="label">OEE (avg)</div>
|
||||||
|
<div class="value">${summary.oeeAvg != null ? summary.oeeAvg.toFixed(1) + "%" : "--"}</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="label">Availability (avg)</div>
|
||||||
|
<div class="value">${summary.availabilityAvg != null ? summary.availabilityAvg.toFixed(1) + "%" : "--"}</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="label">Performance (avg)</div>
|
||||||
|
<div class="value">${summary.performanceAvg != null ? summary.performanceAvg.toFixed(1) + "%" : "--"}</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="label">Quality (avg)</div>
|
||||||
|
<div class="value">${summary.qualityAvg != null ? summary.qualityAvg.toFixed(1) + "%" : "--"}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card" style="margin-top: 16px;">
|
||||||
|
<div class="label">${t("reports.pdf.topLoss")}</div>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>${t("reports.pdf.metric")}</th><th>${t("reports.pdf.value")}</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>${t("reports.loss.macrostop")} (sec)</td><td>${downtime.macrostopSec}</td></tr>
|
||||||
|
<tr><td>${t("reports.loss.microstop")} (sec)</td><td>${downtime.microstopSec}</td></tr>
|
||||||
|
<tr><td>${t("reports.loss.slowCycle")}</td><td>${downtime.slowCycleCount}</td></tr>
|
||||||
|
<tr><td>${t("reports.loss.qualitySpike")}</td><td>${downtime.qualitySpikeCount}</td></tr>
|
||||||
|
<tr><td>${t("reports.loss.perfDegradation")}</td><td>${downtime.performanceDegradationCount}</td></tr>
|
||||||
|
<tr><td>${t("reports.loss.oeeDrop")}</td><td>${downtime.oeeDropCount}</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card" style="margin-top: 16px;">
|
||||||
|
<div class="label">${t("reports.pdf.qualitySummary")}</div>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>${t("reports.pdf.metric")}</th><th>${t("reports.pdf.value")}</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>${t("reports.scrapRate")}</td><td>${summary.scrapRate != null ? summary.scrapRate.toFixed(1) + "%" : "--"}</td></tr>
|
||||||
|
<tr><td>${t("overview.good")}</td><td>${summary.goodTotal ?? "--"}</td></tr>
|
||||||
|
<tr><td>${t("overview.scrap")}</td><td>${summary.scrapTotal ?? "--"}</td></tr>
|
||||||
|
<tr><td>${t("overview.target")}</td><td>${summary.targetTotal ?? "--"}</td></tr>
|
||||||
|
<tr><td>${t("reports.topScrapSku")}</td><td>${summary.topScrapSku ?? "--"}</td></tr>
|
||||||
|
<tr><td>${t("reports.topScrapWorkOrder")}</td><td>${summary.topScrapWorkOrder ?? "--"}</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card" style="margin-top: 16px;">
|
||||||
|
<div class="label">${t("reports.pdf.cycleDistribution")}</div>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>${t("reports.tooltip.range")}</th><th>${t("reports.tooltip.cycles")}</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
${cycleBins
|
||||||
|
.map((bin) => `<tr><td>${bin.label}</td><td>${bin.count}</td></tr>`)
|
||||||
|
.join("")}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card" style="margin-top: 16px;">
|
||||||
|
<div class="label">${t("reports.pdf.notes")}</div>
|
||||||
|
${insights.length ? `<ul>${insights.map((n) => `<li>${n}</li>`).join("")}</ul>` : `<div>${t("reports.pdf.none")}</div>`}
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ReportsPageClient({
|
||||||
|
initialMachines = [],
|
||||||
|
}: {
|
||||||
|
initialMachines?: MachineOption[];
|
||||||
|
}) {
|
||||||
|
const { t, locale } = useI18n();
|
||||||
|
const [range, setRange] = useState<RangeKey>("24h");
|
||||||
|
const [report, setReport] = useState<ReportPayload | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [machines] = useState<MachineOption[]>(() => initialMachines);
|
||||||
|
const [filterOptions, setFilterOptions] = useState<FilterOptions>({ workOrders: [], skus: [] });
|
||||||
|
const [machineId, setMachineId] = useState("");
|
||||||
|
const [workOrderId, setWorkOrderId] = useState("");
|
||||||
|
const [sku, setSku] = useState("");
|
||||||
|
|
||||||
|
const rangeLabel = useMemo(() => {
|
||||||
|
if (range === "24h") return t("reports.rangeLabel.last24");
|
||||||
|
if (range === "7d") return t("reports.rangeLabel.last7");
|
||||||
|
if (range === "30d") return t("reports.rangeLabel.last30");
|
||||||
|
return t("reports.rangeLabel.custom");
|
||||||
|
}, [range, t]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({ range });
|
||||||
|
if (machineId) params.set("machineId", machineId);
|
||||||
|
if (workOrderId) params.set("workOrderId", workOrderId);
|
||||||
|
if (sku) params.set("sku", sku);
|
||||||
|
|
||||||
|
const res = await fetch(`/api/reports?${params.toString()}`, {
|
||||||
|
cache: "no-cache",
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
const json = await res.json();
|
||||||
|
if (!alive) return;
|
||||||
|
if (!res.ok || json?.ok === false) {
|
||||||
|
setError(json?.error ?? t("reports.error.failed"));
|
||||||
|
setReport(null);
|
||||||
|
} else {
|
||||||
|
setReport(json);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (!alive) return;
|
||||||
|
setError(t("reports.error.network"));
|
||||||
|
setReport(null);
|
||||||
|
} finally {
|
||||||
|
if (alive) setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
load();
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
controller.abort();
|
||||||
|
};
|
||||||
|
}, [range, machineId, workOrderId, sku, t]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
async function loadFilters() {
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({ range });
|
||||||
|
if (machineId) params.set("machineId", machineId);
|
||||||
|
const res = await fetch(`/api/reports/filters?${params.toString()}`, {
|
||||||
|
cache: "no-cache",
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
const json = await res.json();
|
||||||
|
if (!alive) return;
|
||||||
|
if (!res.ok || json?.ok === false) {
|
||||||
|
setFilterOptions({ workOrders: [], skus: [] });
|
||||||
|
} else {
|
||||||
|
setFilterOptions({
|
||||||
|
workOrders: json.workOrders ?? [],
|
||||||
|
skus: json.skus ?? [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (!alive) return;
|
||||||
|
setFilterOptions({ workOrders: [], skus: [] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadFilters();
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
controller.abort();
|
||||||
|
};
|
||||||
|
}, [range, machineId]);
|
||||||
|
|
||||||
|
const summary = report?.summary;
|
||||||
|
const downtime = report?.downtime;
|
||||||
|
const trend = report?.trend;
|
||||||
|
const distribution = report?.distribution;
|
||||||
|
|
||||||
|
const oeeSeries = useMemo(() => {
|
||||||
|
const rows = trend?.oee ?? [];
|
||||||
|
const trimmed = downsampleTrendPreserveGaps(rows, 600);
|
||||||
|
return trimmed.map((p) => ({
|
||||||
|
ts: p.t,
|
||||||
|
label: formatTickLabel(p.t, range),
|
||||||
|
value: p.v,
|
||||||
|
}));
|
||||||
|
}, [trend?.oee, range]);
|
||||||
|
|
||||||
|
const scrapSeries = useMemo(() => {
|
||||||
|
const rows = trend?.scrapRate ?? [];
|
||||||
|
const trimmed = downsample(rows, 600);
|
||||||
|
return trimmed.map((p) => ({
|
||||||
|
ts: p.t,
|
||||||
|
label: formatTickLabel(p.t, range),
|
||||||
|
value: p.v,
|
||||||
|
}));
|
||||||
|
}, [trend?.scrapRate, range]);
|
||||||
|
|
||||||
|
const cycleHistogram = useMemo(() => {
|
||||||
|
return distribution?.cycleTime ?? [];
|
||||||
|
}, [distribution?.cycleTime]);
|
||||||
|
|
||||||
|
const downtimeSeries = useMemo(() => {
|
||||||
|
if (!downtime) return [];
|
||||||
|
return [
|
||||||
|
{ name: "Macrostop", value: Math.round(downtime.macrostopSec / 60) },
|
||||||
|
{ name: "Microstop", value: Math.round(downtime.microstopSec / 60) },
|
||||||
|
];
|
||||||
|
}, [downtime]);
|
||||||
|
|
||||||
|
const downtimeColors: Record<string, string> = {
|
||||||
|
Macrostop: "#FF3B5C",
|
||||||
|
Microstop: "#FF7A00",
|
||||||
|
};
|
||||||
|
|
||||||
|
const lossRows = useMemo(
|
||||||
|
() => [
|
||||||
|
{ label: t("reports.loss.macrostop"), value: fmtDuration(downtime?.macrostopSec) },
|
||||||
|
{ label: t("reports.loss.microstop"), value: fmtDuration(downtime?.microstopSec) },
|
||||||
|
{ label: t("reports.loss.slowCycle"), value: downtime ? `${downtime.slowCycleCount}` : "--" },
|
||||||
|
{ label: t("reports.loss.qualitySpike"), value: downtime ? `${downtime.qualitySpikeCount}` : "--" },
|
||||||
|
{ label: t("reports.loss.oeeDrop"), value: downtime ? `${downtime.oeeDropCount}` : "--" },
|
||||||
|
{
|
||||||
|
label: t("reports.loss.perfDegradation"),
|
||||||
|
value: downtime ? `${downtime.performanceDegradationCount}` : "--",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[downtime, t]
|
||||||
|
);
|
||||||
|
|
||||||
|
const machineLabel = useMemo(() => {
|
||||||
|
if (!machineId) return t("reports.filter.allMachines");
|
||||||
|
return machines.find((m) => m.id === machineId)?.name ?? machineId;
|
||||||
|
}, [machineId, machines, t]);
|
||||||
|
|
||||||
|
const workOrderLabel = workOrderId || t("reports.filter.allWorkOrders");
|
||||||
|
const skuLabel = sku || t("reports.filter.allSkus");
|
||||||
|
|
||||||
|
const handleExportCsv = () => {
|
||||||
|
if (!report) return;
|
||||||
|
const csv = buildCsv(report, t);
|
||||||
|
downloadText("reports.csv", csv);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExportPdf = () => {
|
||||||
|
if (!report) return;
|
||||||
|
const html = buildPdfHtml(
|
||||||
|
report,
|
||||||
|
rangeLabel,
|
||||||
|
{
|
||||||
|
machine: machineLabel,
|
||||||
|
workOrder: workOrderLabel,
|
||||||
|
sku: skuLabel,
|
||||||
|
},
|
||||||
|
t
|
||||||
|
);
|
||||||
|
|
||||||
|
const win = window.open("", "_blank", "width=900,height=650");
|
||||||
|
if (!win) return;
|
||||||
|
win.document.open();
|
||||||
|
win.document.write(html);
|
||||||
|
win.document.close();
|
||||||
|
win.focus();
|
||||||
|
setTimeout(() => win.print(), 300);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4 sm:p-6">
|
||||||
|
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold text-white">{t("reports.title")}</h1>
|
||||||
|
<p className="text-sm text-zinc-400">{t("reports.subtitle")}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex w-full flex-wrap items-center gap-2 sm:w-auto">
|
||||||
|
<button
|
||||||
|
onClick={handleExportCsv}
|
||||||
|
className="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-white hover:bg-white/10 sm:w-auto"
|
||||||
|
>
|
||||||
|
{t("reports.exportCsv")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleExportPdf}
|
||||||
|
className="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-white hover:bg-white/10 sm:w-auto"
|
||||||
|
>
|
||||||
|
{t("reports.exportPdf")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||||
|
<div className="text-sm font-semibold text-white">{t("reports.filters")}</div>
|
||||||
|
<div className="text-xs text-zinc-400">{rangeLabel}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||||
|
<div className="rounded-xl border border-white/10 bg-black/20 p-3">
|
||||||
|
<div className="text-[11px] text-zinc-400">{t("reports.filter.range")}</div>
|
||||||
|
<div className="mt-2 flex flex-wrap gap-2">
|
||||||
|
{(["24h", "7d", "30d", "custom"] as RangeKey[]).map((k) => (
|
||||||
|
<button
|
||||||
|
key={k}
|
||||||
|
onClick={() => setRange(k)}
|
||||||
|
className={`rounded-full border px-3 py-1 text-xs ${
|
||||||
|
range === k
|
||||||
|
? "border-emerald-500/30 bg-emerald-500/15 text-emerald-200"
|
||||||
|
: "border-white/10 bg-white/5 text-zinc-300 hover:bg-white/10"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{k.toUpperCase()}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-xl border border-white/10 bg-black/20 p-3">
|
||||||
|
<div className="text-[11px] text-zinc-400">{t("reports.filter.machine")}</div>
|
||||||
|
<select
|
||||||
|
value={machineId}
|
||||||
|
onChange={(e) => setMachineId(e.target.value)}
|
||||||
|
className="mt-2 w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm text-zinc-300"
|
||||||
|
>
|
||||||
|
<option value="">{t("reports.filter.allMachines")}</option>
|
||||||
|
{machines.map((m) => (
|
||||||
|
<option key={m.id} value={m.id}>
|
||||||
|
{m.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-xl border border-white/10 bg-black/20 p-3">
|
||||||
|
<div className="text-[11px] text-zinc-400">{t("reports.filter.workOrder")}</div>
|
||||||
|
<input
|
||||||
|
list="work-order-list"
|
||||||
|
value={workOrderId}
|
||||||
|
onChange={(e) => setWorkOrderId(e.target.value)}
|
||||||
|
placeholder={t("reports.filter.allWorkOrders")}
|
||||||
|
className="mt-2 w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm text-zinc-300 placeholder:text-zinc-500"
|
||||||
|
/>
|
||||||
|
<datalist id="work-order-list">
|
||||||
|
{filterOptions.workOrders.map((wo) => (
|
||||||
|
<option key={wo} value={wo} />
|
||||||
|
))}
|
||||||
|
</datalist>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-xl border border-white/10 bg-black/20 p-3">
|
||||||
|
<div className="text-[11px] text-zinc-400">{t("reports.filter.sku")}</div>
|
||||||
|
<input
|
||||||
|
list="sku-list"
|
||||||
|
value={sku}
|
||||||
|
onChange={(e) => setSku(e.target.value)}
|
||||||
|
placeholder={t("reports.filter.allSkus")}
|
||||||
|
className="mt-2 w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm text-zinc-300 placeholder:text-zinc-500"
|
||||||
|
/>
|
||||||
|
<datalist id="sku-list">
|
||||||
|
{filterOptions.skus.map((s) => (
|
||||||
|
<option key={s} value={s} />
|
||||||
|
))}
|
||||||
|
</datalist>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4">
|
||||||
|
{loading && <div className="text-sm text-zinc-400">{t("reports.loading")}</div>}
|
||||||
|
{error && !loading && (
|
||||||
|
<div className="rounded-2xl border border-red-500/20 bg-red-500/10 p-4 text-sm text-red-200">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||||
|
{[
|
||||||
|
{ label: "OEE", value: fmtPct(summary?.oeeAvg), tone: "text-emerald-300" },
|
||||||
|
{ label: "Availability", value: fmtPct(summary?.availabilityAvg), tone: "text-white" },
|
||||||
|
{ label: "Performance", value: fmtPct(summary?.performanceAvg), tone: "text-white" },
|
||||||
|
{ label: "Quality", value: fmtPct(summary?.qualityAvg), tone: "text-white" },
|
||||||
|
].map((kpi) => (
|
||||||
|
<div key={kpi.label} className="rounded-2xl border border-white/10 bg-white/5 p-5">
|
||||||
|
<div className="text-xs text-zinc-400">{kpi.label} (avg)</div>
|
||||||
|
<div className={`mt-2 text-3xl font-semibold ${kpi.tone}`}>{kpi.value}</div>
|
||||||
|
<div className="mt-2 text-xs text-zinc-500">
|
||||||
|
{summary ? t("reports.kpi.note.withData") : t("reports.kpi.note.noData")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Suspense fallback={<ReportsChartsSkeleton />}>
|
||||||
|
<ReportsCharts
|
||||||
|
oeeSeries={oeeSeries}
|
||||||
|
downtimeSeries={downtimeSeries}
|
||||||
|
downtimeColors={downtimeColors}
|
||||||
|
cycleHistogram={cycleHistogram}
|
||||||
|
scrapSeries={scrapSeries}
|
||||||
|
lossRows={lossRows}
|
||||||
|
locale={locale}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
|
||||||
|
<div className="mt-6 grid grid-cols-1 gap-4 xl:grid-cols-2">
|
||||||
|
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
|
||||||
|
<div className="mb-3 text-sm font-semibold text-white">{t("reports.qualitySummary")}</div>
|
||||||
|
<div className="space-y-3 text-sm text-zinc-300">
|
||||||
|
<div className="rounded-xl border border-white/10 bg-black/20 p-3">
|
||||||
|
<div className="text-xs text-zinc-400">{t("reports.scrapRate")}</div>
|
||||||
|
<div className="mt-1 text-lg font-semibold text-white">
|
||||||
|
{summary?.scrapRate != null ? fmtPct(summary.scrapRate) : "--"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-white/10 bg-black/20 p-3">
|
||||||
|
<div className="text-xs text-zinc-400">{t("reports.topScrapSku")}</div>
|
||||||
|
<div className="mt-1 text-sm text-zinc-300">{summary?.topScrapSku ?? "--"}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-white/10 bg-black/20 p-3">
|
||||||
|
<div className="text-xs text-zinc-400">{t("reports.topScrapWorkOrder")}</div>
|
||||||
|
<div className="mt-1 text-sm text-zinc-300">{summary?.topScrapWorkOrder ?? "--"}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
|
||||||
|
<div className="mb-3 text-sm font-semibold text-white">{t("reports.notes")}</div>
|
||||||
|
<div className="rounded-xl border border-white/10 bg-black/20 p-4 text-sm text-zinc-300">
|
||||||
|
<div className="mb-2 text-xs text-zinc-400">{t("reports.notes.suggested")}</div>
|
||||||
|
{report?.insights && report.insights.length > 0 ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{report.insights.map((note, idx) => (
|
||||||
|
<div key={idx}>{note}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>{t("reports.notes.none")}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,713 +1,17 @@
|
|||||||
"use client";
|
import { redirect } from "next/navigation";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { requireSession } from "@/lib/auth/requireSession";
|
||||||
|
import ReportsPageClient from "./ReportsPageClient";
|
||||||
|
|
||||||
import { Suspense, lazy, useEffect, useMemo, useState } from "react";
|
export default async function ReportsPage() {
|
||||||
import { useI18n } from "@/lib/i18n/useI18n";
|
const session = await requireSession();
|
||||||
|
if (!session) redirect("/login?next=/reports");
|
||||||
|
|
||||||
const ReportsCharts = lazy(() => import("./ReportsCharts"));
|
const machines = await prisma.machine.findMany({
|
||||||
|
where: { orgId: session.orgId },
|
||||||
type RangeKey = "24h" | "7d" | "30d" | "custom";
|
orderBy: { createdAt: "desc" },
|
||||||
|
select: { id: true, name: true },
|
||||||
type ReportSummary = {
|
|
||||||
oeeAvg: number | null;
|
|
||||||
availabilityAvg: number | null;
|
|
||||||
performanceAvg: number | null;
|
|
||||||
qualityAvg: number | null;
|
|
||||||
goodTotal: number | null;
|
|
||||||
scrapTotal: number | null;
|
|
||||||
targetTotal: number | null;
|
|
||||||
scrapRate: number | null;
|
|
||||||
topScrapSku?: string | null;
|
|
||||||
topScrapWorkOrder?: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ReportDowntime = {
|
|
||||||
macrostopSec: number;
|
|
||||||
microstopSec: number;
|
|
||||||
slowCycleCount: number;
|
|
||||||
qualitySpikeCount: number;
|
|
||||||
performanceDegradationCount: number;
|
|
||||||
oeeDropCount: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ReportTrendPoint = { t: string; v: number };
|
|
||||||
|
|
||||||
type ReportPayload = {
|
|
||||||
summary: ReportSummary;
|
|
||||||
downtime: ReportDowntime;
|
|
||||||
trend: {
|
|
||||||
oee: ReportTrendPoint[];
|
|
||||||
availability: ReportTrendPoint[];
|
|
||||||
performance: ReportTrendPoint[];
|
|
||||||
quality: ReportTrendPoint[];
|
|
||||||
scrapRate: ReportTrendPoint[];
|
|
||||||
};
|
|
||||||
distribution: {
|
|
||||||
cycleTime: {
|
|
||||||
label: string;
|
|
||||||
count: number;
|
|
||||||
rangeStart?: number;
|
|
||||||
rangeEnd?: number;
|
|
||||||
overflow?: "low" | "high";
|
|
||||||
minValue?: number;
|
|
||||||
maxValue?: number;
|
|
||||||
}[];
|
|
||||||
};
|
|
||||||
insights?: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
type MachineOption = { id: string; name: string };
|
|
||||||
type FilterOptions = { workOrders: string[]; skus: string[] };
|
|
||||||
type Translator = (key: string, vars?: Record<string, string | number>) => string;
|
|
||||||
|
|
||||||
function fmtPct(v?: number | null) {
|
|
||||||
if (v === null || v === undefined || Number.isNaN(v)) return "--";
|
|
||||||
return `${v.toFixed(1)}%`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function fmtDuration(sec?: number | null) {
|
|
||||||
if (!sec) return "--";
|
|
||||||
const h = Math.floor(sec / 3600);
|
|
||||||
const m = Math.floor((sec % 3600) / 60);
|
|
||||||
if (h > 0) return `${h}h ${m}m`;
|
|
||||||
return `${m}m`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function downsample<T>(rows: T[], max: number) {
|
|
||||||
if (rows.length <= max) return rows;
|
|
||||||
const step = Math.ceil(rows.length / max);
|
|
||||||
return rows.filter((_, idx) => idx % step === 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatTickLabel(ts: string, range: RangeKey) {
|
|
||||||
const d = new Date(ts);
|
|
||||||
if (Number.isNaN(d.getTime())) return ts;
|
|
||||||
const hh = String(d.getHours()).padStart(2, "0");
|
|
||||||
const mm = String(d.getMinutes()).padStart(2, "0");
|
|
||||||
const month = String(d.getMonth() + 1).padStart(2, "0");
|
|
||||||
const day = String(d.getDate()).padStart(2, "0");
|
|
||||||
if (range === "24h") return `${hh}:${mm}`;
|
|
||||||
return `${month}-${day}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ReportsChartsSkeleton() {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="mt-6 grid grid-cols-1 gap-4 xl:grid-cols-2">
|
|
||||||
{Array.from({ length: 2 }).map((_, idx) => (
|
|
||||||
<div key={idx} className="h-[320px] rounded-2xl border border-white/10 bg-white/5" />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="mt-6 grid grid-cols-1 gap-4 xl:grid-cols-3">
|
|
||||||
{Array.from({ length: 3 }).map((_, idx) => (
|
|
||||||
<div key={idx} className="h-[280px] rounded-2xl border border-white/10 bg-white/5" />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function toMachineOption(value: unknown): MachineOption | null {
|
|
||||||
if (!value || typeof value !== "object") return null;
|
|
||||||
const record = value as Record<string, unknown>;
|
|
||||||
const id = typeof record.id === "string" ? record.id : "";
|
|
||||||
const name = typeof record.name === "string" ? record.name : "";
|
|
||||||
if (!id || !name) return null;
|
|
||||||
return { id, name };
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildCsv(report: ReportPayload, t: Translator) {
|
|
||||||
const rows = new Map<string, Record<string, string | number>>();
|
|
||||||
const addSeries = (series: ReportTrendPoint[], key: string) => {
|
|
||||||
for (const p of series) {
|
|
||||||
const row = rows.get(p.t) ?? { timestamp: p.t };
|
|
||||||
row[key] = p.v;
|
|
||||||
rows.set(p.t, row);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
addSeries(report.trend.oee, "oee");
|
|
||||||
addSeries(report.trend.availability, "availability");
|
|
||||||
addSeries(report.trend.performance, "performance");
|
|
||||||
addSeries(report.trend.quality, "quality");
|
|
||||||
addSeries(report.trend.scrapRate, "scrapRate");
|
|
||||||
|
|
||||||
const ordered = [...rows.values()].sort((a, b) => {
|
|
||||||
const at = new Date(String(a.timestamp)).getTime();
|
|
||||||
const bt = new Date(String(b.timestamp)).getTime();
|
|
||||||
return at - bt;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const header = ["timestamp", "oee", "availability", "performance", "quality", "scrapRate"].join(",");
|
return <ReportsPageClient initialMachines={machines} />;
|
||||||
const lines = ordered.map((row) =>
|
|
||||||
[
|
|
||||||
row.timestamp,
|
|
||||||
row.oee ?? "",
|
|
||||||
row.availability ?? "",
|
|
||||||
row.performance ?? "",
|
|
||||||
row.quality ?? "",
|
|
||||||
row.scrapRate ?? "",
|
|
||||||
]
|
|
||||||
.map((v) => (v == null ? "" : String(v)))
|
|
||||||
.join(",")
|
|
||||||
);
|
|
||||||
|
|
||||||
const summary = report.summary;
|
|
||||||
const downtime = report.downtime;
|
|
||||||
|
|
||||||
const sectionLines: string[] = [];
|
|
||||||
sectionLines.push(
|
|
||||||
[t("reports.csv.section"), t("reports.csv.key"), t("reports.csv.value")].join(",")
|
|
||||||
);
|
|
||||||
const addRow = (section: string, key: string, value: string | number | null | undefined) => {
|
|
||||||
sectionLines.push(
|
|
||||||
[section, key, value == null ? "" : String(value)]
|
|
||||||
.map((v) => (v.includes(",") ? `"${v.replace(/\"/g, '""')}"` : v))
|
|
||||||
.join(",")
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
addRow("summary", "oeeAvg", summary.oeeAvg);
|
|
||||||
addRow("summary", "availabilityAvg", summary.availabilityAvg);
|
|
||||||
addRow("summary", "performanceAvg", summary.performanceAvg);
|
|
||||||
addRow("summary", "qualityAvg", summary.qualityAvg);
|
|
||||||
addRow("summary", "goodTotal", summary.goodTotal);
|
|
||||||
addRow("summary", "scrapTotal", summary.scrapTotal);
|
|
||||||
addRow("summary", "targetTotal", summary.targetTotal);
|
|
||||||
addRow("summary", "scrapRate", summary.scrapRate);
|
|
||||||
addRow("summary", "topScrapSku", summary.topScrapSku ?? "");
|
|
||||||
addRow("summary", "topScrapWorkOrder", summary.topScrapWorkOrder ?? "");
|
|
||||||
|
|
||||||
addRow("loss_drivers", "macrostopSec", downtime.macrostopSec);
|
|
||||||
addRow("loss_drivers", "microstopSec", downtime.microstopSec);
|
|
||||||
addRow("loss_drivers", "slowCycleCount", downtime.slowCycleCount);
|
|
||||||
addRow("loss_drivers", "qualitySpikeCount", downtime.qualitySpikeCount);
|
|
||||||
addRow("loss_drivers", "performanceDegradationCount", downtime.performanceDegradationCount);
|
|
||||||
addRow("loss_drivers", "oeeDropCount", downtime.oeeDropCount);
|
|
||||||
|
|
||||||
for (const bin of report.distribution.cycleTime) {
|
|
||||||
addRow("cycle_distribution", bin.label, bin.count);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (report.insights?.length) {
|
|
||||||
report.insights.forEach((note, idx) => addRow("insights", String(idx + 1), note));
|
|
||||||
}
|
|
||||||
|
|
||||||
return [header, ...lines, "", ...sectionLines].join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
function downloadText(filename: string, content: string) {
|
|
||||||
const blob = new Blob([content], { type: "text/csv;charset=utf-8;" });
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const link = document.createElement("a");
|
|
||||||
link.href = url;
|
|
||||||
link.setAttribute("download", filename);
|
|
||||||
document.body.appendChild(link);
|
|
||||||
link.click();
|
|
||||||
document.body.removeChild(link);
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildPdfHtml(
|
|
||||||
report: ReportPayload,
|
|
||||||
rangeLabel: string,
|
|
||||||
filters: { machine: string; workOrder: string; sku: string },
|
|
||||||
t: Translator
|
|
||||||
) {
|
|
||||||
const summary = report.summary;
|
|
||||||
const downtime = report.downtime;
|
|
||||||
const cycleBins = report.distribution.cycleTime;
|
|
||||||
const insights = report.insights ?? [];
|
|
||||||
|
|
||||||
return `
|
|
||||||
<!doctype html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<title>${t("reports.pdf.title")}</title>
|
|
||||||
<style>
|
|
||||||
body { font-family: Arial, sans-serif; color: #111; margin: 24px; }
|
|
||||||
h1 { margin: 0 0 6px; }
|
|
||||||
.meta { margin-bottom: 16px; color: #555; }
|
|
||||||
.grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
|
|
||||||
.card { border: 1px solid #ddd; border-radius: 8px; padding: 12px; }
|
|
||||||
.label { color: #666; font-size: 12px; text-transform: uppercase; letter-spacing: .03em; }
|
|
||||||
.value { font-size: 18px; font-weight: 600; margin-top: 6px; }
|
|
||||||
table { width: 100%; border-collapse: collapse; margin-top: 12px; }
|
|
||||||
th, td { border: 1px solid #ddd; padding: 6px 8px; font-size: 12px; }
|
|
||||||
th { background: #f5f5f5; text-align: left; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<h1>${t("reports.title")}</h1>
|
|
||||||
<div class="meta">${t("reports.pdf.range")}: ${rangeLabel} | ${t("reports.pdf.machine")}: ${filters.machine} | ${t("reports.pdf.workOrder")}: ${filters.workOrder} | ${t("reports.pdf.sku")}: ${filters.sku}</div>
|
|
||||||
|
|
||||||
<div class="grid">
|
|
||||||
<div class="card">
|
|
||||||
<div class="label">OEE (avg)</div>
|
|
||||||
<div class="value">${summary.oeeAvg != null ? summary.oeeAvg.toFixed(1) + "%" : "--"}</div>
|
|
||||||
</div>
|
|
||||||
<div class="card">
|
|
||||||
<div class="label">Availability (avg)</div>
|
|
||||||
<div class="value">${summary.availabilityAvg != null ? summary.availabilityAvg.toFixed(1) + "%" : "--"}</div>
|
|
||||||
</div>
|
|
||||||
<div class="card">
|
|
||||||
<div class="label">Performance (avg)</div>
|
|
||||||
<div class="value">${summary.performanceAvg != null ? summary.performanceAvg.toFixed(1) + "%" : "--"}</div>
|
|
||||||
</div>
|
|
||||||
<div class="card">
|
|
||||||
<div class="label">Quality (avg)</div>
|
|
||||||
<div class="value">${summary.qualityAvg != null ? summary.qualityAvg.toFixed(1) + "%" : "--"}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card" style="margin-top: 16px;">
|
|
||||||
<div class="label">${t("reports.pdf.topLoss")}</div>
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr><th>${t("reports.pdf.metric")}</th><th>${t("reports.pdf.value")}</th></tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr><td>${t("reports.loss.macrostop")} (sec)</td><td>${downtime.macrostopSec}</td></tr>
|
|
||||||
<tr><td>${t("reports.loss.microstop")} (sec)</td><td>${downtime.microstopSec}</td></tr>
|
|
||||||
<tr><td>${t("reports.loss.slowCycle")}</td><td>${downtime.slowCycleCount}</td></tr>
|
|
||||||
<tr><td>${t("reports.loss.qualitySpike")}</td><td>${downtime.qualitySpikeCount}</td></tr>
|
|
||||||
<tr><td>${t("reports.loss.perfDegradation")}</td><td>${downtime.performanceDegradationCount}</td></tr>
|
|
||||||
<tr><td>${t("reports.loss.oeeDrop")}</td><td>${downtime.oeeDropCount}</td></tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card" style="margin-top: 16px;">
|
|
||||||
<div class="label">${t("reports.pdf.qualitySummary")}</div>
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr><th>${t("reports.pdf.metric")}</th><th>${t("reports.pdf.value")}</th></tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr><td>${t("reports.scrapRate")}</td><td>${summary.scrapRate != null ? summary.scrapRate.toFixed(1) + "%" : "--"}</td></tr>
|
|
||||||
<tr><td>${t("overview.good")}</td><td>${summary.goodTotal ?? "--"}</td></tr>
|
|
||||||
<tr><td>${t("overview.scrap")}</td><td>${summary.scrapTotal ?? "--"}</td></tr>
|
|
||||||
<tr><td>${t("overview.target")}</td><td>${summary.targetTotal ?? "--"}</td></tr>
|
|
||||||
<tr><td>${t("reports.topScrapSku")}</td><td>${summary.topScrapSku ?? "--"}</td></tr>
|
|
||||||
<tr><td>${t("reports.topScrapWorkOrder")}</td><td>${summary.topScrapWorkOrder ?? "--"}</td></tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card" style="margin-top: 16px;">
|
|
||||||
<div class="label">${t("reports.pdf.cycleDistribution")}</div>
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr><th>${t("reports.tooltip.range")}</th><th>${t("reports.tooltip.cycles")}</th></tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
${cycleBins
|
|
||||||
.map((bin) => `<tr><td>${bin.label}</td><td>${bin.count}</td></tr>`)
|
|
||||||
.join("")}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card" style="margin-top: 16px;">
|
|
||||||
<div class="label">${t("reports.pdf.notes")}</div>
|
|
||||||
${insights.length ? `<ul>${insights.map((n) => `<li>${n}</li>`).join("")}</ul>` : `<div>${t("reports.pdf.none")}</div>`}
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
`.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ReportsPage() {
|
|
||||||
const { t, locale } = useI18n();
|
|
||||||
const [range, setRange] = useState<RangeKey>("24h");
|
|
||||||
const [report, setReport] = useState<ReportPayload | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [machines, setMachines] = useState<MachineOption[]>([]);
|
|
||||||
const [filterOptions, setFilterOptions] = useState<FilterOptions>({ workOrders: [], skus: [] });
|
|
||||||
const [machineId, setMachineId] = useState("");
|
|
||||||
const [workOrderId, setWorkOrderId] = useState("");
|
|
||||||
const [sku, setSku] = useState("");
|
|
||||||
|
|
||||||
const rangeLabel = useMemo(() => {
|
|
||||||
if (range === "24h") return t("reports.rangeLabel.last24");
|
|
||||||
if (range === "7d") return t("reports.rangeLabel.last7");
|
|
||||||
if (range === "30d") return t("reports.rangeLabel.last30");
|
|
||||||
return t("reports.rangeLabel.custom");
|
|
||||||
}, [range, t]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let alive = true;
|
|
||||||
|
|
||||||
async function loadMachines() {
|
|
||||||
try {
|
|
||||||
const res = await fetch("/api/machines", { cache: "no-store" });
|
|
||||||
const json = await res.json();
|
|
||||||
if (!alive) return;
|
|
||||||
const rows: unknown[] = Array.isArray(json?.machines) ? json.machines : [];
|
|
||||||
const options: MachineOption[] = [];
|
|
||||||
rows.forEach((row) => {
|
|
||||||
const option = toMachineOption(row);
|
|
||||||
if (option) options.push(option);
|
|
||||||
});
|
|
||||||
setMachines(options);
|
|
||||||
} catch {
|
|
||||||
if (!alive) return;
|
|
||||||
setMachines([]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
loadMachines();
|
|
||||||
return () => {
|
|
||||||
alive = false;
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let alive = true;
|
|
||||||
const controller = new AbortController();
|
|
||||||
|
|
||||||
async function load() {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const params = new URLSearchParams({ range });
|
|
||||||
if (machineId) params.set("machineId", machineId);
|
|
||||||
if (workOrderId) params.set("workOrderId", workOrderId);
|
|
||||||
if (sku) params.set("sku", sku);
|
|
||||||
|
|
||||||
const res = await fetch(`/api/reports?${params.toString()}`, {
|
|
||||||
cache: "no-store",
|
|
||||||
signal: controller.signal,
|
|
||||||
});
|
|
||||||
const json = await res.json();
|
|
||||||
if (!alive) return;
|
|
||||||
if (!res.ok || json?.ok === false) {
|
|
||||||
setError(json?.error ?? t("reports.error.failed"));
|
|
||||||
setReport(null);
|
|
||||||
} else {
|
|
||||||
setReport(json);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
if (!alive) return;
|
|
||||||
setError(t("reports.error.network"));
|
|
||||||
setReport(null);
|
|
||||||
} finally {
|
|
||||||
if (alive) setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
load();
|
|
||||||
return () => {
|
|
||||||
alive = false;
|
|
||||||
controller.abort();
|
|
||||||
};
|
|
||||||
}, [range, machineId, workOrderId, sku, t]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let alive = true;
|
|
||||||
const controller = new AbortController();
|
|
||||||
|
|
||||||
async function loadFilters() {
|
|
||||||
try {
|
|
||||||
const params = new URLSearchParams({ range });
|
|
||||||
if (machineId) params.set("machineId", machineId);
|
|
||||||
const res = await fetch(`/api/reports/filters?${params.toString()}`, {
|
|
||||||
cache: "no-store",
|
|
||||||
signal: controller.signal,
|
|
||||||
});
|
|
||||||
const json = await res.json();
|
|
||||||
if (!alive) return;
|
|
||||||
if (!res.ok || json?.ok === false) {
|
|
||||||
setFilterOptions({ workOrders: [], skus: [] });
|
|
||||||
} else {
|
|
||||||
setFilterOptions({
|
|
||||||
workOrders: json.workOrders ?? [],
|
|
||||||
skus: json.skus ?? [],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
if (!alive) return;
|
|
||||||
setFilterOptions({ workOrders: [], skus: [] });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
loadFilters();
|
|
||||||
return () => {
|
|
||||||
alive = false;
|
|
||||||
controller.abort();
|
|
||||||
};
|
|
||||||
}, [range, machineId]);
|
|
||||||
|
|
||||||
const summary = report?.summary;
|
|
||||||
const downtime = report?.downtime;
|
|
||||||
const trend = report?.trend;
|
|
||||||
const distribution = report?.distribution;
|
|
||||||
|
|
||||||
const oeeSeries = useMemo(() => {
|
|
||||||
const rows = trend?.oee ?? [];
|
|
||||||
const trimmed = downsample(rows, 600);
|
|
||||||
return trimmed.map((p) => ({
|
|
||||||
ts: p.t,
|
|
||||||
label: formatTickLabel(p.t, range),
|
|
||||||
value: p.v,
|
|
||||||
}));
|
|
||||||
}, [trend?.oee, range]);
|
|
||||||
|
|
||||||
const scrapSeries = useMemo(() => {
|
|
||||||
const rows = trend?.scrapRate ?? [];
|
|
||||||
const trimmed = downsample(rows, 600);
|
|
||||||
return trimmed.map((p) => ({
|
|
||||||
ts: p.t,
|
|
||||||
label: formatTickLabel(p.t, range),
|
|
||||||
value: p.v,
|
|
||||||
}));
|
|
||||||
}, [trend?.scrapRate, range]);
|
|
||||||
|
|
||||||
const cycleHistogram = useMemo(() => {
|
|
||||||
return distribution?.cycleTime ?? [];
|
|
||||||
}, [distribution?.cycleTime]);
|
|
||||||
|
|
||||||
const downtimeSeries = useMemo(() => {
|
|
||||||
if (!downtime) return [];
|
|
||||||
return [
|
|
||||||
{ name: "Macrostop", value: Math.round(downtime.macrostopSec / 60) },
|
|
||||||
{ name: "Microstop", value: Math.round(downtime.microstopSec / 60) },
|
|
||||||
];
|
|
||||||
}, [downtime]);
|
|
||||||
|
|
||||||
const downtimeColors: Record<string, string> = {
|
|
||||||
Macrostop: "#FF3B5C",
|
|
||||||
Microstop: "#FF7A00",
|
|
||||||
};
|
|
||||||
|
|
||||||
const lossRows = useMemo(
|
|
||||||
() => [
|
|
||||||
{ label: t("reports.loss.macrostop"), value: fmtDuration(downtime?.macrostopSec) },
|
|
||||||
{ label: t("reports.loss.microstop"), value: fmtDuration(downtime?.microstopSec) },
|
|
||||||
{ label: t("reports.loss.slowCycle"), value: downtime ? `${downtime.slowCycleCount}` : "--" },
|
|
||||||
{ label: t("reports.loss.qualitySpike"), value: downtime ? `${downtime.qualitySpikeCount}` : "--" },
|
|
||||||
{ label: t("reports.loss.oeeDrop"), value: downtime ? `${downtime.oeeDropCount}` : "--" },
|
|
||||||
{
|
|
||||||
label: t("reports.loss.perfDegradation"),
|
|
||||||
value: downtime ? `${downtime.performanceDegradationCount}` : "--",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[downtime, t]
|
|
||||||
);
|
|
||||||
|
|
||||||
const machineLabel = useMemo(() => {
|
|
||||||
if (!machineId) return t("reports.filter.allMachines");
|
|
||||||
return machines.find((m) => m.id === machineId)?.name ?? machineId;
|
|
||||||
}, [machineId, machines, t]);
|
|
||||||
|
|
||||||
const workOrderLabel = workOrderId || t("reports.filter.allWorkOrders");
|
|
||||||
const skuLabel = sku || t("reports.filter.allSkus");
|
|
||||||
|
|
||||||
const handleExportCsv = () => {
|
|
||||||
if (!report) return;
|
|
||||||
const csv = buildCsv(report, t);
|
|
||||||
downloadText("reports.csv", csv);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleExportPdf = () => {
|
|
||||||
if (!report) return;
|
|
||||||
const html = buildPdfHtml(
|
|
||||||
report,
|
|
||||||
rangeLabel,
|
|
||||||
{
|
|
||||||
machine: machineLabel,
|
|
||||||
workOrder: workOrderLabel,
|
|
||||||
sku: skuLabel,
|
|
||||||
},
|
|
||||||
t
|
|
||||||
);
|
|
||||||
|
|
||||||
const win = window.open("", "_blank", "width=900,height=650");
|
|
||||||
if (!win) return;
|
|
||||||
win.document.open();
|
|
||||||
win.document.write(html);
|
|
||||||
win.document.close();
|
|
||||||
win.focus();
|
|
||||||
setTimeout(() => win.print(), 300);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="p-4 sm:p-6">
|
|
||||||
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-semibold text-white">{t("reports.title")}</h1>
|
|
||||||
<p className="text-sm text-zinc-400">{t("reports.subtitle")}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex w-full flex-wrap items-center gap-2 sm:w-auto">
|
|
||||||
<button
|
|
||||||
onClick={handleExportCsv}
|
|
||||||
className="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-white hover:bg-white/10 sm:w-auto"
|
|
||||||
>
|
|
||||||
{t("reports.exportCsv")}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleExportPdf}
|
|
||||||
className="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-white hover:bg-white/10 sm:w-auto"
|
|
||||||
>
|
|
||||||
{t("reports.exportPdf")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
|
||||||
<div className="text-sm font-semibold text-white">{t("reports.filters")}</div>
|
|
||||||
<div className="text-xs text-zinc-400">{rangeLabel}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4 grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-4">
|
|
||||||
<div className="rounded-xl border border-white/10 bg-black/20 p-3">
|
|
||||||
<div className="text-[11px] text-zinc-400">{t("reports.filter.range")}</div>
|
|
||||||
<div className="mt-2 flex flex-wrap gap-2">
|
|
||||||
{(["24h", "7d", "30d", "custom"] as RangeKey[]).map((k) => (
|
|
||||||
<button
|
|
||||||
key={k}
|
|
||||||
onClick={() => setRange(k)}
|
|
||||||
className={`rounded-full border px-3 py-1 text-xs ${
|
|
||||||
range === k
|
|
||||||
? "border-emerald-500/30 bg-emerald-500/15 text-emerald-200"
|
|
||||||
: "border-white/10 bg-white/5 text-zinc-300 hover:bg-white/10"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{k.toUpperCase()}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-xl border border-white/10 bg-black/20 p-3">
|
|
||||||
<div className="text-[11px] text-zinc-400">{t("reports.filter.machine")}</div>
|
|
||||||
<select
|
|
||||||
value={machineId}
|
|
||||||
onChange={(e) => setMachineId(e.target.value)}
|
|
||||||
className="mt-2 w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm text-zinc-300"
|
|
||||||
>
|
|
||||||
<option value="">{t("reports.filter.allMachines")}</option>
|
|
||||||
{machines.map((m) => (
|
|
||||||
<option key={m.id} value={m.id}>
|
|
||||||
{m.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-xl border border-white/10 bg-black/20 p-3">
|
|
||||||
<div className="text-[11px] text-zinc-400">{t("reports.filter.workOrder")}</div>
|
|
||||||
<input
|
|
||||||
list="work-order-list"
|
|
||||||
value={workOrderId}
|
|
||||||
onChange={(e) => setWorkOrderId(e.target.value)}
|
|
||||||
placeholder={t("reports.filter.allWorkOrders")}
|
|
||||||
className="mt-2 w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm text-zinc-300 placeholder:text-zinc-500"
|
|
||||||
/>
|
|
||||||
<datalist id="work-order-list">
|
|
||||||
{filterOptions.workOrders.map((wo) => (
|
|
||||||
<option key={wo} value={wo} />
|
|
||||||
))}
|
|
||||||
</datalist>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-xl border border-white/10 bg-black/20 p-3">
|
|
||||||
<div className="text-[11px] text-zinc-400">{t("reports.filter.sku")}</div>
|
|
||||||
<input
|
|
||||||
list="sku-list"
|
|
||||||
value={sku}
|
|
||||||
onChange={(e) => setSku(e.target.value)}
|
|
||||||
placeholder={t("reports.filter.allSkus")}
|
|
||||||
className="mt-2 w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm text-zinc-300 placeholder:text-zinc-500"
|
|
||||||
/>
|
|
||||||
<datalist id="sku-list">
|
|
||||||
{filterOptions.skus.map((s) => (
|
|
||||||
<option key={s} value={s} />
|
|
||||||
))}
|
|
||||||
</datalist>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4">
|
|
||||||
{loading && <div className="text-sm text-zinc-400">{t("reports.loading")}</div>}
|
|
||||||
{error && !loading && (
|
|
||||||
<div className="rounded-2xl border border-red-500/20 bg-red-500/10 p-4 text-sm text-red-200">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4 grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
|
|
||||||
{[
|
|
||||||
{ label: "OEE", value: fmtPct(summary?.oeeAvg), tone: "text-emerald-300" },
|
|
||||||
{ label: "Availability", value: fmtPct(summary?.availabilityAvg), tone: "text-white" },
|
|
||||||
{ label: "Performance", value: fmtPct(summary?.performanceAvg), tone: "text-white" },
|
|
||||||
{ label: "Quality", value: fmtPct(summary?.qualityAvg), tone: "text-white" },
|
|
||||||
].map((kpi) => (
|
|
||||||
<div key={kpi.label} className="rounded-2xl border border-white/10 bg-white/5 p-5">
|
|
||||||
<div className="text-xs text-zinc-400">{kpi.label} (avg)</div>
|
|
||||||
<div className={`mt-2 text-3xl font-semibold ${kpi.tone}`}>{kpi.value}</div>
|
|
||||||
<div className="mt-2 text-xs text-zinc-500">
|
|
||||||
{summary ? t("reports.kpi.note.withData") : t("reports.kpi.note.noData")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Suspense fallback={<ReportsChartsSkeleton />}>
|
|
||||||
<ReportsCharts
|
|
||||||
oeeSeries={oeeSeries}
|
|
||||||
downtimeSeries={downtimeSeries}
|
|
||||||
downtimeColors={downtimeColors}
|
|
||||||
cycleHistogram={cycleHistogram}
|
|
||||||
scrapSeries={scrapSeries}
|
|
||||||
lossRows={lossRows}
|
|
||||||
locale={locale}
|
|
||||||
t={t}
|
|
||||||
/>
|
|
||||||
</Suspense>
|
|
||||||
|
|
||||||
<div className="mt-6 grid grid-cols-1 gap-4 xl:grid-cols-2">
|
|
||||||
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
|
|
||||||
<div className="mb-3 text-sm font-semibold text-white">{t("reports.qualitySummary")}</div>
|
|
||||||
<div className="space-y-3 text-sm text-zinc-300">
|
|
||||||
<div className="rounded-xl border border-white/10 bg-black/20 p-3">
|
|
||||||
<div className="text-xs text-zinc-400">{t("reports.scrapRate")}</div>
|
|
||||||
<div className="mt-1 text-lg font-semibold text-white">
|
|
||||||
{summary?.scrapRate != null ? fmtPct(summary.scrapRate) : "--"}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-xl border border-white/10 bg-black/20 p-3">
|
|
||||||
<div className="text-xs text-zinc-400">{t("reports.topScrapSku")}</div>
|
|
||||||
<div className="mt-1 text-sm text-zinc-300">{summary?.topScrapSku ?? "--"}</div>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-xl border border-white/10 bg-black/20 p-3">
|
|
||||||
<div className="text-xs text-zinc-400">{t("reports.topScrapWorkOrder")}</div>
|
|
||||||
<div className="mt-1 text-sm text-zinc-300">{summary?.topScrapWorkOrder ?? "--"}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
|
|
||||||
<div className="mb-3 text-sm font-semibold text-white">{t("reports.notes")}</div>
|
|
||||||
<div className="rounded-xl border border-white/10 bg-black/20 p-4 text-sm text-zinc-300">
|
|
||||||
<div className="mb-2 text-xs text-zinc-400">{t("reports.notes.suggested")}</div>
|
|
||||||
{report?.insights && report.insights.length > 0 ? (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{report.insights.map((note, idx) => (
|
|
||||||
<div key={idx}>{note}</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div>{t("reports.notes.none")}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { AlertsConfig } from "@/components/settings/AlertsConfig";
|
import { AlertsConfig } from "@/components/settings/AlertsConfig";
|
||||||
import { FinancialCostConfig } from "@/components/settings/FinancialCostConfig";
|
import { FinancialCostConfig } from "@/components/settings/FinancialCostConfig";
|
||||||
|
import { ReasonCatalogConfig } from "@/components/settings/ReasonCatalogConfig";
|
||||||
import { useI18n } from "@/lib/i18n/useI18n";
|
import { useI18n } from "@/lib/i18n/useI18n";
|
||||||
import { SHIFT_OVERRIDE_DAYS, type ShiftOverrideDay } from "@/lib/settings";
|
import { SHIFT_OVERRIDE_DAYS, type ShiftOverrideDay } from "@/lib/settings";
|
||||||
import { useScreenlessMode } from "@/lib/ui/screenlessMode";
|
import { useScreenlessMode } from "@/lib/ui/screenlessMode";
|
||||||
@@ -122,6 +123,7 @@ const SETTINGS_TABS = [
|
|||||||
{ id: "thresholds", labelKey: "settings.tabs.thresholds" },
|
{ id: "thresholds", labelKey: "settings.tabs.thresholds" },
|
||||||
{ id: "alerts", labelKey: "settings.tabs.alerts" },
|
{ id: "alerts", labelKey: "settings.tabs.alerts" },
|
||||||
{ id: "financial", labelKey: "settings.tabs.financial" },
|
{ id: "financial", labelKey: "settings.tabs.financial" },
|
||||||
|
{ id: "reasonCatalog", labelKey: "settings.tabs.reasonCatalog" },
|
||||||
{ id: "team", labelKey: "settings.tabs.team" },
|
{ id: "team", labelKey: "settings.tabs.team" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
@@ -239,7 +241,6 @@ function normalizeSettings(raw: unknown, fallbackName: (index: number) => string
|
|||||||
const thresholds = asRecord(record.thresholds) ?? {};
|
const thresholds = asRecord(record.thresholds) ?? {};
|
||||||
const alerts = asRecord(record.alerts) ?? {};
|
const alerts = asRecord(record.alerts) ?? {};
|
||||||
const defaults = asRecord(record.defaults) ?? {};
|
const defaults = asRecord(record.defaults) ?? {};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
orgId: String(record.orgId ?? ""),
|
orgId: String(record.orgId ?? ""),
|
||||||
version: Number(record.version ?? 0),
|
version: Number(record.version ?? 0),
|
||||||
@@ -1276,6 +1277,18 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{activeTab === "reasonCatalog" && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
|
||||||
|
<div className="text-sm font-semibold text-white">{t("settings.reasonCatalog.title")}</div>
|
||||||
|
<p className="mt-1 text-xs text-zinc-400">{t("settings.reasonCatalog.subtitle")}</p>
|
||||||
|
<div className="mt-4">
|
||||||
|
<ReasonCatalogConfig disabled={saving} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{activeTab === "team" && (
|
{activeTab === "team" && (
|
||||||
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
|
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
|
||||||
<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">
|
||||||
|
|||||||
@@ -2,6 +2,14 @@ import { NextResponse } from "next/server";
|
|||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { requireSession } from "@/lib/auth/requireSession";
|
import { requireSession } from "@/lib/auth/requireSession";
|
||||||
import { coerceDowntimeRange, rangeToStart } from "@/lib/analytics/downtimeRange";
|
import { coerceDowntimeRange, rangeToStart } from "@/lib/analytics/downtimeRange";
|
||||||
|
import type { Prisma } from "@prisma/client";
|
||||||
|
import {
|
||||||
|
applyDowntimeFilters,
|
||||||
|
loadDowntimeShiftContext,
|
||||||
|
normalizeMicrostopLtMin,
|
||||||
|
normalizeShiftFilter,
|
||||||
|
resolvePlannedFilter,
|
||||||
|
} from "@/lib/analytics/downtimeFilters";
|
||||||
|
|
||||||
const bad = (status: number, error: string) =>
|
const bad = (status: number, error: string) =>
|
||||||
NextResponse.json({ ok: false, error }, { status });
|
NextResponse.json({ ok: false, error }, { status });
|
||||||
@@ -24,6 +32,10 @@ export async function GET(req: Request) {
|
|||||||
|
|
||||||
const machineId = url.searchParams.get("machineId"); // optional
|
const machineId = url.searchParams.get("machineId"); // optional
|
||||||
const reasonCode = url.searchParams.get("reasonCode"); // optional
|
const reasonCode = url.searchParams.get("reasonCode"); // optional
|
||||||
|
const includeMoldChange = url.searchParams.get("includeMoldChange") === "true";
|
||||||
|
const planned = resolvePlannedFilter(url.searchParams.get("planned"), includeMoldChange);
|
||||||
|
const shift = normalizeShiftFilter(url.searchParams.get("shift"));
|
||||||
|
const microstopLtMin = normalizeMicrostopLtMin(url.searchParams.get("microstopLtMin"));
|
||||||
|
|
||||||
const limitRaw = url.searchParams.get("limit");
|
const limitRaw = url.searchParams.get("limit");
|
||||||
const limit = Math.min(Math.max(Number(limitRaw || 200), 1), 500);
|
const limit = Math.min(Math.max(Number(limitRaw || 200), 1), 500);
|
||||||
@@ -44,7 +56,7 @@ export async function GET(req: Request) {
|
|||||||
|
|
||||||
// ✅ Query ReasonEntry as the "episode" table for downtime
|
// ✅ Query ReasonEntry as the "episode" table for downtime
|
||||||
// We only return rows that have an episodeId (true downtime episodes)
|
// We only return rows that have an episodeId (true downtime episodes)
|
||||||
const where: any = {
|
const where: Prisma.ReasonEntryWhereInput = {
|
||||||
orgId,
|
orgId,
|
||||||
kind: "downtime",
|
kind: "downtime",
|
||||||
episodeId: { not: null },
|
episodeId: { not: null },
|
||||||
@@ -56,10 +68,11 @@ export async function GET(req: Request) {
|
|||||||
...(reasonCode ? { reasonCode } : {}),
|
...(reasonCode ? { reasonCode } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
const rows = await prisma.reasonEntry.findMany({
|
const scanTake = Math.min(Math.max(limit * 8, 1000), 5000);
|
||||||
|
const rowsRaw = await prisma.reasonEntry.findMany({
|
||||||
where,
|
where,
|
||||||
orderBy: { capturedAt: "desc" },
|
orderBy: { capturedAt: "desc" },
|
||||||
take: limit,
|
take: scanTake,
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
episodeId: true,
|
episodeId: true,
|
||||||
@@ -77,6 +90,14 @@ export async function GET(req: Request) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const shiftContext = shift === "all" ? null : await loadDowntimeShiftContext(orgId);
|
||||||
|
const rows = applyDowntimeFilters(rowsRaw, {
|
||||||
|
planned,
|
||||||
|
shift,
|
||||||
|
microstopLtMin,
|
||||||
|
shiftContext,
|
||||||
|
}).slice(0, limit);
|
||||||
|
|
||||||
const events = rows.map((r) => {
|
const events = rows.map((r) => {
|
||||||
const startAt = r.capturedAt;
|
const startAt = r.capturedAt;
|
||||||
const endAt =
|
const endAt =
|
||||||
@@ -113,7 +134,11 @@ export async function GET(req: Request) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const nextBefore =
|
const nextBefore =
|
||||||
events.length > 0 ? events[events.length - 1]?.capturedAt ?? null : null;
|
events.length > 0
|
||||||
|
? events[events.length - 1]?.capturedAt ?? null
|
||||||
|
: rowsRaw.length > 0
|
||||||
|
? toISO(rowsRaw[rowsRaw.length - 1]?.capturedAt)
|
||||||
|
: null;
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -122,6 +147,10 @@ export async function GET(req: Request) {
|
|||||||
start,
|
start,
|
||||||
machineId: machineId ?? null,
|
machineId: machineId ?? null,
|
||||||
reasonCode: reasonCode ?? null,
|
reasonCode: reasonCode ?? null,
|
||||||
|
planned,
|
||||||
|
shift,
|
||||||
|
microstopLtMin,
|
||||||
|
includeMoldChange,
|
||||||
limit,
|
limit,
|
||||||
before: before ?? null,
|
before: before ?? null,
|
||||||
nextBefore, // pass this back for pagination
|
nextBefore, // pass this back for pagination
|
||||||
|
|||||||
@@ -2,6 +2,13 @@ import { NextResponse } from "next/server";
|
|||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { requireSession } from "@/lib/auth/requireSession";
|
import { requireSession } from "@/lib/auth/requireSession";
|
||||||
import { coerceDowntimeRange, rangeToStart } from "@/lib/analytics/downtimeRange";
|
import { coerceDowntimeRange, rangeToStart } from "@/lib/analytics/downtimeRange";
|
||||||
|
import {
|
||||||
|
applyDowntimeFilters,
|
||||||
|
loadDowntimeShiftContext,
|
||||||
|
normalizeMicrostopLtMin,
|
||||||
|
normalizeShiftFilter,
|
||||||
|
resolvePlannedFilter,
|
||||||
|
} from "@/lib/analytics/downtimeFilters";
|
||||||
|
|
||||||
const bad = (status: number, error: string) =>
|
const bad = (status: number, error: string) =>
|
||||||
NextResponse.json({ ok: false, error }, { status });
|
NextResponse.json({ ok: false, error }, { status });
|
||||||
@@ -20,9 +27,13 @@ export async function GET(req: Request) {
|
|||||||
|
|
||||||
const machineId = url.searchParams.get("machineId"); // optional
|
const machineId = url.searchParams.get("machineId"); // optional
|
||||||
const kind = (url.searchParams.get("kind") || "downtime").toLowerCase();
|
const kind = (url.searchParams.get("kind") || "downtime").toLowerCase();
|
||||||
|
const includeMoldChange = url.searchParams.get("includeMoldChange") === "true";
|
||||||
|
const planned = resolvePlannedFilter(url.searchParams.get("planned"), includeMoldChange);
|
||||||
|
const shift = normalizeShiftFilter(url.searchParams.get("shift"));
|
||||||
|
const microstopLtMin = normalizeMicrostopLtMin(url.searchParams.get("microstopLtMin"));
|
||||||
|
|
||||||
if (kind !== "downtime" && kind !== "scrap") {
|
if (kind !== "downtime" && kind !== "scrap" && kind !== "planned-downtime") {
|
||||||
return bad(400, "Invalid kind (downtime|scrap)");
|
return bad(400, "Invalid kind (downtime|scrap|planned-downtime)");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ✅ If machineId provided, verify it belongs to this org
|
// ✅ If machineId provided, verify it belongs to this org
|
||||||
@@ -34,7 +45,61 @@ export async function GET(req: Request) {
|
|||||||
if (!m) return bad(404, "Machine not found");
|
if (!m) return bad(404, "Machine not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ✅ Scope by orgId (+ machineId if provided)
|
let itemsRaw: { reasonCode: string; reasonLabel: string; value: number; count: number }[] = [];
|
||||||
|
|
||||||
|
if (kind === "downtime" || kind === "planned-downtime") {
|
||||||
|
const baseRows = await prisma.reasonEntry.findMany({
|
||||||
|
where: {
|
||||||
|
orgId,
|
||||||
|
...(machineId ? { machineId } : {}),
|
||||||
|
kind: "downtime",
|
||||||
|
capturedAt: { gte: start },
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
reasonCode: true,
|
||||||
|
reasonLabel: true,
|
||||||
|
durationSeconds: true,
|
||||||
|
capturedAt: true,
|
||||||
|
meta: true,
|
||||||
|
episodeId: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const effectivePlanned = kind === "planned-downtime" ? "planned" : planned;
|
||||||
|
const shiftContext = shift === "all" ? null : await loadDowntimeShiftContext(orgId);
|
||||||
|
const filteredRows = applyDowntimeFilters(baseRows, {
|
||||||
|
planned: effectivePlanned,
|
||||||
|
shift,
|
||||||
|
microstopLtMin,
|
||||||
|
shiftContext,
|
||||||
|
});
|
||||||
|
|
||||||
|
const grouped = new Map<string, { reasonCode: string; reasonLabel: string; durationSeconds: number; count: number }>();
|
||||||
|
for (const row of filteredRows) {
|
||||||
|
const key = `${row.reasonCode}:::${row.reasonLabel ?? row.reasonCode}`;
|
||||||
|
const slot =
|
||||||
|
grouped.get(key) ??
|
||||||
|
{
|
||||||
|
reasonCode: row.reasonCode,
|
||||||
|
reasonLabel: row.reasonLabel ?? row.reasonCode,
|
||||||
|
durationSeconds: 0,
|
||||||
|
count: 0,
|
||||||
|
};
|
||||||
|
slot.durationSeconds += Math.max(0, row.durationSeconds ?? 0);
|
||||||
|
slot.count += 1;
|
||||||
|
grouped.set(key, slot);
|
||||||
|
}
|
||||||
|
|
||||||
|
itemsRaw = [...grouped.values()]
|
||||||
|
.map((g) => ({
|
||||||
|
reasonCode: g.reasonCode,
|
||||||
|
reasonLabel: g.reasonLabel,
|
||||||
|
value: Math.round((g.durationSeconds / 60) * 10) / 10,
|
||||||
|
count: g.count,
|
||||||
|
}))
|
||||||
|
.filter((x) => x.value > 0 || x.count > 0);
|
||||||
|
} else {
|
||||||
|
// Scrap path unchanged.
|
||||||
const grouped = await prisma.reasonEntry.groupBy({
|
const grouped = await prisma.reasonEntry.groupBy({
|
||||||
by: ["reasonCode", "reasonLabel"],
|
by: ["reasonCode", "reasonLabel"],
|
||||||
where: {
|
where: {
|
||||||
@@ -43,28 +108,19 @@ export async function GET(req: Request) {
|
|||||||
kind,
|
kind,
|
||||||
capturedAt: { gte: start },
|
capturedAt: { gte: start },
|
||||||
},
|
},
|
||||||
_sum: {
|
_sum: { scrapQty: true },
|
||||||
durationSeconds: true,
|
|
||||||
scrapQty: true,
|
|
||||||
},
|
|
||||||
_count: { _all: true },
|
_count: { _all: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
const itemsRaw = grouped
|
itemsRaw = grouped
|
||||||
.map((g) => {
|
.map((g) => ({
|
||||||
const value =
|
|
||||||
kind === "downtime"
|
|
||||||
? Math.round(((g._sum.durationSeconds ?? 0) / 60) * 10) / 10 // minutes, 1 decimal
|
|
||||||
: g._sum.scrapQty ?? 0;
|
|
||||||
|
|
||||||
return {
|
|
||||||
reasonCode: g.reasonCode,
|
reasonCode: g.reasonCode,
|
||||||
reasonLabel: g.reasonLabel ?? g.reasonCode,
|
reasonLabel: g.reasonLabel ?? g.reasonCode,
|
||||||
value,
|
value: g._sum.scrapQty ?? 0,
|
||||||
count: g._count._all,
|
count: g._count._all,
|
||||||
};
|
}))
|
||||||
})
|
.filter((x) => x.value > 0);
|
||||||
.filter((x) => (kind === "downtime" ? x.value > 0 || x.count > 0 : x.value > 0));
|
}
|
||||||
|
|
||||||
itemsRaw.sort((a, b) => b.value - a.value);
|
itemsRaw.sort((a, b) => b.value - a.value);
|
||||||
|
|
||||||
@@ -83,7 +139,7 @@ export async function GET(req: Request) {
|
|||||||
return {
|
return {
|
||||||
reasonCode: x.reasonCode,
|
reasonCode: x.reasonCode,
|
||||||
reasonLabel: x.reasonLabel,
|
reasonLabel: x.reasonLabel,
|
||||||
minutesLost: kind === "downtime" ? x.value : undefined,
|
minutesLost: kind === "downtime" || kind === "planned-downtime" ? x.value : undefined,
|
||||||
scrapQty: kind === "scrap" ? x.value : undefined,
|
scrapQty: kind === "scrap" ? x.value : undefined,
|
||||||
pctOfTotal,
|
pctOfTotal,
|
||||||
cumulativePct,
|
cumulativePct,
|
||||||
@@ -106,9 +162,13 @@ export async function GET(req: Request) {
|
|||||||
orgId,
|
orgId,
|
||||||
machineId: machineId ?? null,
|
machineId: machineId ?? null,
|
||||||
kind,
|
kind,
|
||||||
|
planned: kind === "downtime" ? planned : kind === "planned-downtime" ? "planned" : "all",
|
||||||
|
shift,
|
||||||
|
microstopLtMin,
|
||||||
|
includeMoldChange,
|
||||||
range, // ✅ now defined correctly
|
range, // ✅ now defined correctly
|
||||||
start, // ✅ now defined correctly
|
start, // ✅ now defined correctly
|
||||||
totalMinutesLost: kind === "downtime" ? total : undefined,
|
totalMinutesLost: kind === "downtime" || kind === "planned-downtime" ? total : undefined,
|
||||||
totalScrap: kind === "scrap" ? total : undefined,
|
totalScrap: kind === "scrap" ? total : undefined,
|
||||||
rows,
|
rows,
|
||||||
top3,
|
top3,
|
||||||
|
|||||||
@@ -68,7 +68,8 @@ function normalizeCycleInput(raw: unknown): Record<string, unknown> | null {
|
|||||||
cycle_count: fromRowOrData(["cycle_count", "cycleCount"]),
|
cycle_count: fromRowOrData(["cycle_count", "cycleCount"]),
|
||||||
work_order_id: fromRowOrData(["work_order_id", "workOrderId"]),
|
work_order_id: fromRowOrData(["work_order_id", "workOrderId"]),
|
||||||
good_delta: fromRowOrData(["good_delta", "goodDelta"]),
|
good_delta: fromRowOrData(["good_delta", "goodDelta"]),
|
||||||
scrap_delta: fromRowOrData(["scrap_delta", "scrapDelta", "scrap_total"]),
|
// `scrap_total` is cumulative and should not be persisted as per-cycle delta.
|
||||||
|
scrap_delta: fromRowOrData(["scrap_delta", "scrapDelta"]),
|
||||||
timestamp: fromRowOrData(["timestamp", "tsMs"]),
|
timestamp: fromRowOrData(["timestamp", "tsMs"]),
|
||||||
ts: fromRowOrData(["ts", "tsMs"]),
|
ts: fromRowOrData(["ts", "tsMs"]),
|
||||||
event_timestamp: fromRowOrData(["event_timestamp", "eventTimestamp"]),
|
event_timestamp: fromRowOrData(["event_timestamp", "eventTimestamp"]),
|
||||||
@@ -171,11 +172,35 @@ export async function POST(req: Request) {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const result = await prisma.machineCycle.createMany({
|
||||||
|
data: rows,
|
||||||
|
skipDuplicates: true,
|
||||||
|
});
|
||||||
|
|
||||||
if (rows.length === 1) {
|
if (rows.length === 1) {
|
||||||
const row = await prisma.machineCycle.create({ data: rows[0] });
|
const row = await prisma.machineCycle.findFirst({
|
||||||
return NextResponse.json({ ok: true, id: row.id, ts: row.ts });
|
where: {
|
||||||
|
orgId: machine.orgId,
|
||||||
|
machineId: machine.id,
|
||||||
|
ts: rows[0].ts,
|
||||||
|
cycleCount: rows[0].cycleCount ?? null,
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: "asc" },
|
||||||
|
select: { id: true, ts: true },
|
||||||
|
});
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
id: row?.id,
|
||||||
|
ts: row?.ts,
|
||||||
|
inserted: result.count,
|
||||||
|
duplicate: result.count === 0,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await prisma.machineCycle.createMany({ data: rows });
|
return NextResponse.json({
|
||||||
return NextResponse.json({ ok: true, count: result.count });
|
ok: true,
|
||||||
|
inserted: result.count,
|
||||||
|
requested: rows.length,
|
||||||
|
count: result.count,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,13 +5,14 @@ import { z } from "zod";
|
|||||||
import { evaluateAlertsForEvent } from "@/lib/alerts/engine";
|
import { evaluateAlertsForEvent } from "@/lib/alerts/engine";
|
||||||
import { toJsonValue } from "@/lib/prismaJson";
|
import { toJsonValue } from "@/lib/prismaJson";
|
||||||
import {
|
import {
|
||||||
|
detailEffectiveReasonCode,
|
||||||
findCatalogReason,
|
findCatalogReason,
|
||||||
loadFallbackReasonCatalog,
|
findCatalogReasonByReasonCode,
|
||||||
normalizeReasonCatalog,
|
|
||||||
toReasonCode,
|
toReasonCode,
|
||||||
type ReasonCatalog,
|
type ReasonCatalog,
|
||||||
type ReasonCatalogKind,
|
type ReasonCatalogKind,
|
||||||
} from "@/lib/reasonCatalog";
|
} from "@/lib/reasonCatalog";
|
||||||
|
import { effectiveReasonCatalogForOrg } from "@/lib/reasonCatalogDb";
|
||||||
|
|
||||||
const normalizeType = (t: unknown) =>
|
const normalizeType = (t: unknown) =>
|
||||||
String(t ?? "")
|
String(t ?? "")
|
||||||
@@ -40,6 +41,7 @@ const CANON_TYPE: Record<string, string> = {
|
|||||||
"down": "stop",
|
"down": "stop",
|
||||||
"downtime-acknowledged": "downtime-acknowledged",
|
"downtime-acknowledged": "downtime-acknowledged",
|
||||||
"scrap-manual-entry": "scrap-manual-entry",
|
"scrap-manual-entry": "scrap-manual-entry",
|
||||||
|
"mold-change": "mold-change",
|
||||||
};
|
};
|
||||||
|
|
||||||
const ALLOWED_TYPES = new Set([
|
const ALLOWED_TYPES = new Set([
|
||||||
@@ -54,6 +56,7 @@ const ALLOWED_TYPES = new Set([
|
|||||||
"predictive-oee-decline",
|
"predictive-oee-decline",
|
||||||
"downtime-acknowledged",
|
"downtime-acknowledged",
|
||||||
"scrap-manual-entry",
|
"scrap-manual-entry",
|
||||||
|
"mold-change",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const machineIdSchema = z.string().uuid();
|
const machineIdSchema = z.string().uuid();
|
||||||
@@ -61,7 +64,12 @@ const MAX_EVENTS = 100;
|
|||||||
|
|
||||||
//when no cycle time is configed
|
//when no cycle time is configed
|
||||||
const DEFAULT_MACROSTOP_SEC = 300;
|
const DEFAULT_MACROSTOP_SEC = 300;
|
||||||
|
const NON_AUTHORITATIVE_REASON_CODES = new Set(["PENDIENTE", "UNCLASSIFIED"]);
|
||||||
|
|
||||||
|
function isNonAuthoritativeReasonCode(code: unknown) {
|
||||||
|
const normalized = clampText(code, 64)?.toUpperCase();
|
||||||
|
return !!normalized && NON_AUTHORITATIVE_REASON_CODES.has(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
function clampText(value: unknown, maxLen: number) {
|
function clampText(value: unknown, maxLen: number) {
|
||||||
if (value === null || value === undefined) return null;
|
if (value === null || value === undefined) return null;
|
||||||
@@ -78,6 +86,15 @@ function numberFrom(value: unknown) {
|
|||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
function parseSeqToBigInt(value: unknown): bigint | null {
|
||||||
|
if (value === null || value === undefined) return null;
|
||||||
|
if (typeof value === "number") {
|
||||||
|
if (!Number.isInteger(value) || value < 0) return null;
|
||||||
|
return BigInt(value);
|
||||||
|
}
|
||||||
|
if (typeof value === "string" && /^\d+$/.test(value)) return BigInt(value);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function canonicalText(value: unknown) {
|
function canonicalText(value: unknown) {
|
||||||
return String(value ?? "")
|
return String(value ?? "")
|
||||||
@@ -153,7 +170,7 @@ function findCatalogReasonFlexible(
|
|||||||
categoryLabel: category.label,
|
categoryLabel: category.label,
|
||||||
detailId: detail.id,
|
detailId: detail.id,
|
||||||
detailLabel: detail.label,
|
detailLabel: detail.label,
|
||||||
reasonCode: toReasonCode(category.id, detail.id),
|
reasonCode: detailEffectiveReasonCode(category, detail),
|
||||||
reasonLabel: `${category.label} > ${detail.label}`,
|
reasonLabel: `${category.label} > ${detail.label}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -161,12 +178,6 @@ function findCatalogReasonFlexible(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCatalogFromDefaults(defaultsJson: unknown) {
|
|
||||||
const defaults = asRecord(defaultsJson);
|
|
||||||
if (!defaults) return null;
|
|
||||||
return normalizeReasonCatalog(defaults.reasonCatalog ?? defaults.reasonCatalogData);
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveReason(
|
function resolveReason(
|
||||||
raw: Record<string, unknown>,
|
raw: Record<string, unknown>,
|
||||||
kind: ReasonCatalogKind,
|
kind: ReasonCatalogKind,
|
||||||
@@ -177,7 +188,13 @@ function resolveReason(
|
|||||||
const reasonTextPath = parseReasonTextPath(raw.reasonText);
|
const reasonTextPath = parseReasonTextPath(raw.reasonText);
|
||||||
const categoryIdRaw = clampText(raw.categoryId ?? reasonPath.category ?? reasonTextPath.category, 64);
|
const categoryIdRaw = clampText(raw.categoryId ?? reasonPath.category ?? reasonTextPath.category, 64);
|
||||||
const detailIdRaw = clampText(raw.detailId ?? reasonPath.detail ?? reasonTextPath.detail, 64);
|
const detailIdRaw = clampText(raw.detailId ?? reasonPath.detail ?? reasonTextPath.detail, 64);
|
||||||
const fromCatalog = findCatalogReasonFlexible(catalog, kind, categoryIdRaw, detailIdRaw);
|
const fromCatalogFlexible = findCatalogReasonFlexible(catalog, kind, categoryIdRaw, detailIdRaw);
|
||||||
|
const rawReasonCodeEarly = clampText(raw.reasonCode, 64);
|
||||||
|
const fromCatalogByCode =
|
||||||
|
!fromCatalogFlexible && rawReasonCodeEarly
|
||||||
|
? findCatalogReasonByReasonCode(catalog, kind, rawReasonCodeEarly)
|
||||||
|
: null;
|
||||||
|
const fromCatalog = fromCatalogFlexible ?? fromCatalogByCode;
|
||||||
|
|
||||||
const categoryLabelRaw = clampText(raw.categoryLabel ?? reasonPath.category ?? reasonTextPath.category, 120);
|
const categoryLabelRaw = clampText(raw.categoryLabel ?? reasonPath.category ?? reasonTextPath.category, 120);
|
||||||
const detailLabelRaw = clampText(raw.detailLabel ?? reasonPath.detail ?? reasonTextPath.detail, 120);
|
const detailLabelRaw = clampText(raw.detailLabel ?? reasonPath.detail ?? reasonTextPath.detail, 120);
|
||||||
@@ -260,13 +277,19 @@ export async function POST(req: Request) {
|
|||||||
|
|
||||||
const machine = await getMachineAuth(String(machineId), apiKey);
|
const machine = await getMachineAuth(String(machineId), apiKey);
|
||||||
if (!machine) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
|
if (!machine) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
|
const bodySeq = parseSeqToBigInt(bodyRecord.seq);
|
||||||
|
const bodySchemaVersion = clampText(bodyRecord.schemaVersion, 16);
|
||||||
|
|
||||||
const orgSettings = await prisma.orgSettings.findUnique({
|
const orgSettings = await prisma.orgSettings.findUnique({
|
||||||
where: { orgId: machine.orgId },
|
where: { orgId: machine.orgId },
|
||||||
select: { stoppageMultiplier: true, macroStoppageMultiplier: true, defaultsJson: true },
|
select: { stoppageMultiplier: true, macroStoppageMultiplier: true, defaultsJson: true, version: true },
|
||||||
});
|
});
|
||||||
const fallbackCatalog = await loadFallbackReasonCatalog();
|
const reasonCatalog = await effectiveReasonCatalogForOrg(
|
||||||
const settingsCatalog = getCatalogFromDefaults(orgSettings?.defaultsJson);
|
machine.orgId,
|
||||||
const reasonCatalog = settingsCatalog ?? fallbackCatalog;
|
orgSettings?.defaultsJson ?? null,
|
||||||
|
orgSettings?.version ?? 1
|
||||||
|
);
|
||||||
|
|
||||||
const defaultMicroMultiplier = Number(orgSettings?.stoppageMultiplier ?? 1.5);
|
const defaultMicroMultiplier = Number(orgSettings?.stoppageMultiplier ?? 1.5);
|
||||||
const defaultMacroMultiplier = Math.max(
|
const defaultMacroMultiplier = Math.max(
|
||||||
@@ -291,8 +314,11 @@ export async function POST(req: Request) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const evData = asRecord(evRecord.data) ?? {};
|
const evData = asRecord(evRecord.data) ?? {};
|
||||||
const evReason = asRecord(evRecord.reason) ?? asRecord(evData.reason);
|
// We'll re-check reason again after parsing `data` (it may be a JSON string)
|
||||||
|
let evReason = asRecord(evRecord.reason) ?? asRecord(evData.reason);
|
||||||
const evDowntime = asRecord(evRecord.downtime) ?? asRecord(evData.downtime);
|
const evDowntime = asRecord(evRecord.downtime) ?? asRecord(evData.downtime);
|
||||||
|
// Some producers nest the reason under `downtime.reason`
|
||||||
|
if (!evReason) evReason = asRecord(evDowntime?.reason);
|
||||||
|
|
||||||
const rawType = evRecord.eventType ?? evRecord.anomaly_type ?? evRecord.topic ?? bodyRecord.topic ?? "";
|
const rawType = evRecord.eventType ?? evRecord.anomaly_type ?? evRecord.topic ?? bodyRecord.topic ?? "";
|
||||||
const typ0 = normalizeType(rawType);
|
const typ0 = normalizeType(rawType);
|
||||||
@@ -313,11 +339,13 @@ export async function POST(req: Request) {
|
|||||||
|
|
||||||
// Stop classification -> microstop/macrostop
|
// Stop classification -> microstop/macrostop
|
||||||
let finalType = typ;
|
let finalType = typ;
|
||||||
|
let stopSecForReason: number | null = null;
|
||||||
if (typ === "stop") {
|
if (typ === "stop") {
|
||||||
const stopSec =
|
const stopSec =
|
||||||
(typeof evData.stoppage_duration_seconds === "number" && evData.stoppage_duration_seconds) ||
|
(typeof evData.stoppage_duration_seconds === "number" && evData.stoppage_duration_seconds) ||
|
||||||
(typeof evData.stop_duration_seconds === "number" && evData.stop_duration_seconds) ||
|
(typeof evData.stop_duration_seconds === "number" && evData.stop_duration_seconds) ||
|
||||||
null;
|
null;
|
||||||
|
stopSecForReason = stopSec != null ? Number(stopSec) : null;
|
||||||
|
|
||||||
if (stopSec != null) {
|
if (stopSec != null) {
|
||||||
const theoretical = Number(evData.theoretical_cycle_time ?? evData.theoreticalCycleTime ?? 0) || 0;
|
const theoretical = Number(evData.theoretical_cycle_time ?? evData.theoreticalCycleTime ?? 0) || 0;
|
||||||
@@ -378,13 +406,46 @@ export async function POST(req: Request) {
|
|||||||
if (evReason && dataObj.reason == null) dataObj.reason = evReason;
|
if (evReason && dataObj.reason == null) dataObj.reason = evReason;
|
||||||
if (evDowntime && dataObj.downtime == null) dataObj.downtime = evDowntime;
|
if (evDowntime && dataObj.downtime == null) dataObj.downtime = evDowntime;
|
||||||
|
|
||||||
|
// If `data` was a JSON string, the earlier evReason lookup would miss it.
|
||||||
|
// Re-check here using the normalized object we will persist.
|
||||||
|
if (!evReason) evReason = asRecord(dataObj.reason);
|
||||||
|
if (!evReason) evReason = asRecord(asRecord(dataObj.downtime)?.reason);
|
||||||
|
|
||||||
|
// If we have a reasonText but missing ids, derive ids from the path-like string.
|
||||||
|
if (evReason) {
|
||||||
|
const hasCat = clampText((evReason as any).categoryId, 64) ?? clampText((evReason as any).categoryLabel, 120);
|
||||||
|
const hasDet = clampText((evReason as any).detailId, 64) ?? clampText((evReason as any).detailLabel, 120);
|
||||||
|
const rt = clampText((evReason as any).reasonText, 240);
|
||||||
|
if ((!hasCat || !hasDet) && rt) {
|
||||||
|
const parsed = parseReasonTextPath(rt);
|
||||||
|
const next = { ...evReason } as Record<string, unknown>;
|
||||||
|
// Preserve any explicit ids; only fill gaps.
|
||||||
|
if ((next as any).categoryId == null && parsed.category) next.categoryId = canonicalText(parsed.category);
|
||||||
|
if ((next as any).categoryLabel == null && parsed.category) next.categoryLabel = parsed.category;
|
||||||
|
if ((next as any).detailId == null && parsed.detail) next.detailId = canonicalText(parsed.detail);
|
||||||
|
if ((next as any).detailLabel == null && parsed.detail) next.detailLabel = parsed.detail;
|
||||||
|
evReason = next;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const activeWorkOrder = asRecord(evRecord.activeWorkOrder);
|
const activeWorkOrder = asRecord(evRecord.activeWorkOrder);
|
||||||
const dataActiveWorkOrder = asRecord(evData.activeWorkOrder);
|
const dataActiveWorkOrder = asRecord(evData.activeWorkOrder);
|
||||||
|
|
||||||
const row = await prisma.machineEvent.create({
|
// ✨ Cada evento puede traer su propio seq, o usar el del payload raíz
|
||||||
data: {
|
const evSeq =
|
||||||
|
parseSeqToBigInt(evRecord.seq) ??
|
||||||
|
parseSeqToBigInt(evData.seq) ??
|
||||||
|
bodySeq;
|
||||||
|
|
||||||
|
const evSchemaVersion =
|
||||||
|
clampText(evRecord.schemaVersion, 16) ??
|
||||||
|
bodySchemaVersion;
|
||||||
|
|
||||||
|
const eventData = {
|
||||||
orgId: machine.orgId,
|
orgId: machine.orgId,
|
||||||
machineId: machine.id,
|
machineId: machine.id,
|
||||||
|
schemaVersion: evSchemaVersion,
|
||||||
|
seq: evSeq,
|
||||||
ts,
|
ts,
|
||||||
topic: clampText(evRecord.topic ?? finalType, 64) ?? finalType,
|
topic: clampText(evRecord.topic ?? finalType, 64) ?? finalType,
|
||||||
eventType: finalType,
|
eventType: finalType,
|
||||||
@@ -405,24 +466,118 @@ export async function POST(req: Request) {
|
|||||||
clampText(activeWorkOrder?.sku, 64) ??
|
clampText(activeWorkOrder?.sku, 64) ??
|
||||||
clampText(dataActiveWorkOrder?.sku, 64) ??
|
clampText(dataActiveWorkOrder?.sku, 64) ??
|
||||||
null,
|
null,
|
||||||
},
|
};
|
||||||
|
|
||||||
|
// ✨ Idempotente: si ya existe (mismo orgId+machineId+seq), no inserta
|
||||||
|
const insertResult = await prisma.machineEvent.createMany({
|
||||||
|
data: [eventData],
|
||||||
|
skipDuplicates: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ✨ Buscar la fila (la recién creada o la duplicada existente)
|
||||||
|
let row;
|
||||||
|
if (evSeq != null) {
|
||||||
|
row = await prisma.machineEvent.findFirst({
|
||||||
|
where: {
|
||||||
|
orgId: machine.orgId,
|
||||||
|
machineId: machine.id,
|
||||||
|
seq: evSeq,
|
||||||
|
},
|
||||||
|
orderBy: { ts: "asc" },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Sin seq, buscar por ts (fallback compatibilidad con eventos viejos)
|
||||||
|
row = await prisma.machineEvent.findFirst({
|
||||||
|
where: {
|
||||||
|
orgId: machine.orgId,
|
||||||
|
machineId: machine.id,
|
||||||
|
ts,
|
||||||
|
eventType: finalType,
|
||||||
|
},
|
||||||
|
orderBy: { ts: "desc" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
|
skipped.push({ reason: "row_not_found_after_insert", seq: evSeq?.toString() });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const wasDuplicate = insertResult.count === 0;
|
||||||
|
|
||||||
|
// Si fue duplicado, no procesar reasonEntry ni alertas (ya se hicieron antes)
|
||||||
|
if (wasDuplicate) {
|
||||||
|
created.push({ id: row.id, ts: row.ts, eventType: row.eventType });
|
||||||
|
continue; // ✨ saltar el resto del procesamiento
|
||||||
|
}
|
||||||
|
|
||||||
created.push({ id: row.id, ts: row.ts, eventType: row.eventType });
|
created.push({ id: row.id, ts: row.ts, eventType: row.eventType });
|
||||||
|
|
||||||
if (evReason) {
|
|
||||||
|
|
||||||
|
// If the payload carries a `reason`, create the corresponding ReasonEntry.
|
||||||
|
// If it doesn't, still create an "UNCLASSIFIED" downtime ReasonEntry for stop events so the dashboard can show coverage.
|
||||||
|
if (evRecord.is_update || evRecord.is_auto_ack || dataObj.is_update || dataObj.is_auto_ack){
|
||||||
|
// skip duplicate reasonEntry for refresh/ack
|
||||||
|
} else if (evReason || finalType === "microstop" || finalType === "macrostop" || finalType === "downtime-acknowledged" || finalType === "mold-change"){
|
||||||
|
const fallbackIncidentKey =
|
||||||
|
clampText(
|
||||||
|
evData.incidentKey ??
|
||||||
|
dataObj.incidentKey ??
|
||||||
|
evDowntime?.incidentKey ??
|
||||||
|
evReason?.incidentKey,
|
||||||
|
128
|
||||||
|
) ?? null;
|
||||||
|
const moldIncidentKey =
|
||||||
|
clampText(evData.incidentKey ?? dataObj.incidentKey, 128) ??
|
||||||
|
(numberFrom(evData.start_ms ?? dataObj.start_ms) != null
|
||||||
|
? `mold-change:${Math.trunc(numberFrom(evData.start_ms ?? dataObj.start_ms) as number)}`
|
||||||
|
: null);
|
||||||
|
const reasonRaw: Record<string, unknown> =
|
||||||
|
evReason ??
|
||||||
|
(finalType === "mold-change"
|
||||||
|
? ({
|
||||||
|
type: "downtime",
|
||||||
|
categoryId: "cambio-molde",
|
||||||
|
detailId: "cambio-molde",
|
||||||
|
categoryLabel: "Cambio molde",
|
||||||
|
detailLabel: "Cambio molde",
|
||||||
|
reasonCode: "MOLD_CHANGE",
|
||||||
|
reasonText: "Cambio molde",
|
||||||
|
incidentKey: moldIncidentKey ?? fallbackIncidentKey ?? row.id,
|
||||||
|
} as Record<string, unknown>)
|
||||||
|
:
|
||||||
|
({
|
||||||
|
type: "downtime",
|
||||||
|
categoryId: "unclassified",
|
||||||
|
detailId: "unclassified",
|
||||||
|
categoryLabel: "Unclassified",
|
||||||
|
detailLabel: "Unclassified",
|
||||||
|
reasonCode: "UNCLASSIFIED",
|
||||||
|
reasonText: "Unclassified",
|
||||||
|
incidentKey: fallbackIncidentKey ?? row.id,
|
||||||
|
} as Record<string, unknown>));
|
||||||
|
|
||||||
const inferredKind: ReasonCatalogKind =
|
const inferredKind: ReasonCatalogKind =
|
||||||
String(evReason.type ?? "").toLowerCase() === "scrap" || finalType === "scrap-manual-entry"
|
String(reasonRaw.type ?? "").toLowerCase() === "scrap" || finalType === "scrap-manual-entry"
|
||||||
? "scrap"
|
? "scrap"
|
||||||
: "downtime";
|
: "downtime";
|
||||||
const resolved = resolveReason(evReason, inferredKind, reasonCatalog, reasonCatalog.version);
|
const resolved = resolveReason(reasonRaw, inferredKind, reasonCatalog, reasonCatalog.version);
|
||||||
|
|
||||||
if (resolved.reasonCode) {
|
if (resolved.reasonCode) {
|
||||||
|
const continuityIncidentKey =
|
||||||
|
inferredKind === "downtime"
|
||||||
|
? clampText((reasonRaw as any).incidentKey ?? evDowntime?.incidentKey ?? fallbackIncidentKey, 128) ?? row.id
|
||||||
|
: null;
|
||||||
|
const reasonMetaIncidentKey =
|
||||||
|
inferredKind === "downtime"
|
||||||
|
? continuityIncidentKey
|
||||||
|
: clampText((reasonRaw as any).incidentKey ?? evDowntime?.incidentKey, 128);
|
||||||
const reasonId =
|
const reasonId =
|
||||||
clampText(evReason.reasonId, 128) ??
|
clampText(reasonRaw.reasonId, 128) ??
|
||||||
(inferredKind === "downtime"
|
(inferredKind === "downtime"
|
||||||
? `evt:${machine.id}:downtime:${clampText(evReason.incidentKey ?? evDowntime?.incidentKey, 128) ?? row.id}`
|
? `evt:${machine.id}:downtime:${continuityIncidentKey ?? row.id}`
|
||||||
: `evt:${machine.id}:scrap:${clampText(evReason.scrapEntryId, 128) ?? row.id}`);
|
: `evt:${machine.id}:scrap:${clampText(reasonRaw.scrapEntryId, 128) ?? row.id}`);
|
||||||
|
|
||||||
const workOrderId =
|
const workOrderId =
|
||||||
clampText(evRecord.work_order_id, 64) ??
|
clampText(evRecord.work_order_id, 64) ??
|
||||||
@@ -441,7 +596,7 @@ export async function POST(req: Request) {
|
|||||||
source: "ingest:event",
|
source: "ingest:event",
|
||||||
eventId: row.id,
|
eventId: row.id,
|
||||||
eventType: row.eventType,
|
eventType: row.eventType,
|
||||||
incidentKey: clampText(evReason.incidentKey ?? evDowntime?.incidentKey, 128),
|
incidentKey: reasonMetaIncidentKey,
|
||||||
anomalyType:
|
anomalyType:
|
||||||
clampText(evRecord.anomalyType, 64) ??
|
clampText(evRecord.anomalyType, 64) ??
|
||||||
clampText(evDowntime?.anomalyType, 64) ??
|
clampText(evDowntime?.anomalyType, 64) ??
|
||||||
@@ -459,17 +614,76 @@ export async function POST(req: Request) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (inferredKind === "downtime") {
|
if (inferredKind === "downtime") {
|
||||||
const incidentKey = clampText(evReason.incidentKey ?? evDowntime?.incidentKey, 128) ?? row.id;
|
const incidentKey = continuityIncidentKey ?? row.id;
|
||||||
const durationSeconds =
|
const durationSeconds =
|
||||||
numberFrom(evDowntime?.durationSeconds) ??
|
numberFrom(evDowntime?.durationSeconds) ??
|
||||||
|
numberFrom(evData.duration_sec) ??
|
||||||
numberFrom(evData.stoppage_duration_seconds) ??
|
numberFrom(evData.stoppage_duration_seconds) ??
|
||||||
numberFrom(evData.stop_duration_seconds) ??
|
numberFrom(evData.stop_duration_seconds) ??
|
||||||
|
(stopSecForReason != null ? stopSecForReason : null) ??
|
||||||
null;
|
null;
|
||||||
const episodeEndTsMs =
|
const episodeEndTsMs =
|
||||||
|
numberFrom(evData.end_ms) ??
|
||||||
numberFrom(evDowntime?.episodeEndTsMs) ??
|
numberFrom(evDowntime?.episodeEndTsMs) ??
|
||||||
numberFrom(evDowntime?.acknowledgedAtMs) ??
|
numberFrom(evDowntime?.acknowledgedAtMs) ??
|
||||||
null;
|
null;
|
||||||
|
|
||||||
|
let guardedWrite = commonWrite;
|
||||||
|
const incomingIsNonAuthoritative = isNonAuthoritativeReasonCode(resolved.reasonCode);
|
||||||
|
const isManualAckEvent = finalType === "downtime-acknowledged";
|
||||||
|
if (!isManualAckEvent && incomingIsNonAuthoritative) {
|
||||||
|
const existingEpisode = await prisma.reasonEntry.findFirst({
|
||||||
|
where: {
|
||||||
|
orgId: machine.orgId,
|
||||||
|
kind: "downtime",
|
||||||
|
episodeId: incidentKey,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
reasonCode: true,
|
||||||
|
reasonLabel: true,
|
||||||
|
reasonText: true,
|
||||||
|
meta: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (existingEpisode && !isNonAuthoritativeReasonCode(existingEpisode.reasonCode)) {
|
||||||
|
const existingMeta = asRecord(existingEpisode.meta);
|
||||||
|
const existingMetaReason = asRecord(existingMeta?.reason);
|
||||||
|
guardedWrite = {
|
||||||
|
...commonWrite,
|
||||||
|
reasonCode: existingEpisode.reasonCode,
|
||||||
|
reasonLabel: existingEpisode.reasonLabel ?? existingEpisode.reasonCode,
|
||||||
|
reasonText:
|
||||||
|
existingEpisode.reasonText ??
|
||||||
|
existingEpisode.reasonLabel ??
|
||||||
|
existingEpisode.reasonCode,
|
||||||
|
meta: toJsonValue({
|
||||||
|
source: "ingest:event",
|
||||||
|
eventId: row.id,
|
||||||
|
eventType: row.eventType,
|
||||||
|
incidentKey: reasonMetaIncidentKey,
|
||||||
|
anomalyType:
|
||||||
|
clampText(evRecord.anomalyType, 64) ??
|
||||||
|
clampText(evDowntime?.anomalyType, 64) ??
|
||||||
|
clampText(evRecord.anomaly_type, 64),
|
||||||
|
reason: existingMetaReason ?? {
|
||||||
|
type: resolved.type,
|
||||||
|
categoryId: resolved.categoryId,
|
||||||
|
categoryLabel: resolved.categoryLabel,
|
||||||
|
detailId: resolved.detailId,
|
||||||
|
detailLabel: resolved.detailLabel,
|
||||||
|
reasonText:
|
||||||
|
existingEpisode.reasonText ??
|
||||||
|
existingEpisode.reasonLabel ??
|
||||||
|
existingEpisode.reasonCode,
|
||||||
|
catalogVersion: resolved.catalogVersion,
|
||||||
|
},
|
||||||
|
reasonPreservedFromManual: true,
|
||||||
|
incomingReasonCode: resolved.reasonCode,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await prisma.reasonEntry.upsert({
|
await prisma.reasonEntry.upsert({
|
||||||
where: { reasonId },
|
where: { reasonId },
|
||||||
create: {
|
create: {
|
||||||
@@ -480,19 +694,19 @@ export async function POST(req: Request) {
|
|||||||
episodeId: incidentKey,
|
episodeId: incidentKey,
|
||||||
durationSeconds: durationSeconds != null ? Math.max(0, Math.trunc(durationSeconds)) : null,
|
durationSeconds: durationSeconds != null ? Math.max(0, Math.trunc(durationSeconds)) : null,
|
||||||
episodeEndTs: episodeEndTsMs != null ? new Date(episodeEndTsMs) : null,
|
episodeEndTs: episodeEndTsMs != null ? new Date(episodeEndTsMs) : null,
|
||||||
...commonWrite,
|
...guardedWrite,
|
||||||
},
|
},
|
||||||
update: {
|
update: {
|
||||||
kind: "downtime",
|
kind: "downtime",
|
||||||
episodeId: incidentKey,
|
episodeId: incidentKey,
|
||||||
durationSeconds: durationSeconds != null ? Math.max(0, Math.trunc(durationSeconds)) : null,
|
durationSeconds: durationSeconds != null ? Math.max(0, Math.trunc(durationSeconds)) : null,
|
||||||
episodeEndTs: episodeEndTsMs != null ? new Date(episodeEndTsMs) : null,
|
episodeEndTs: episodeEndTsMs != null ? new Date(episodeEndTsMs) : null,
|
||||||
...commonWrite,
|
...guardedWrite,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
const scrapEntryId =
|
const scrapEntryId =
|
||||||
clampText(evReason.scrapEntryId, 128) ??
|
clampText((reasonRaw as any).scrapEntryId, 128) ??
|
||||||
clampText(evRecord.id, 128) ??
|
clampText(evRecord.id, 128) ??
|
||||||
clampText(evRecord.eventId, 128) ??
|
clampText(evRecord.eventId, 128) ??
|
||||||
row.id;
|
row.id;
|
||||||
@@ -512,14 +726,14 @@ export async function POST(req: Request) {
|
|||||||
kind: "scrap",
|
kind: "scrap",
|
||||||
scrapEntryId,
|
scrapEntryId,
|
||||||
scrapQty,
|
scrapQty,
|
||||||
scrapUnit: clampText(evReason.scrapUnit, 16) ?? null,
|
scrapUnit: clampText((reasonRaw as any).scrapUnit, 16) ?? null,
|
||||||
...commonWrite,
|
...commonWrite,
|
||||||
},
|
},
|
||||||
update: {
|
update: {
|
||||||
kind: "scrap",
|
kind: "scrap",
|
||||||
scrapEntryId,
|
scrapEntryId,
|
||||||
scrapQty,
|
scrapQty,
|
||||||
scrapUnit: clampText(evReason.scrapUnit, 16) ?? null,
|
scrapUnit: clampText((reasonRaw as any).scrapUnit, 16) ?? null,
|
||||||
...commonWrite,
|
...commonWrite,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
670
app/api/ingest/event/route.ts.bak
Normal file
670
app/api/ingest/event/route.ts.bak
Normal file
@@ -0,0 +1,670 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { getMachineAuth } from "@/lib/machineAuthCache";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { evaluateAlertsForEvent } from "@/lib/alerts/engine";
|
||||||
|
import { toJsonValue } from "@/lib/prismaJson";
|
||||||
|
import {
|
||||||
|
findCatalogReason,
|
||||||
|
loadFallbackReasonCatalog,
|
||||||
|
normalizeReasonCatalog,
|
||||||
|
toReasonCode,
|
||||||
|
type ReasonCatalog,
|
||||||
|
type ReasonCatalogKind,
|
||||||
|
} from "@/lib/reasonCatalog";
|
||||||
|
|
||||||
|
const normalizeType = (t: unknown) =>
|
||||||
|
String(t ?? "")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/_/g, "-");
|
||||||
|
|
||||||
|
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
||||||
|
return value as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CANON_TYPE: Record<string, string> = {
|
||||||
|
// Node-RED
|
||||||
|
"production-stopped": "stop",
|
||||||
|
"oee-drop": "oee-drop",
|
||||||
|
"quality-spike": "quality-spike",
|
||||||
|
"predictive-oee-decline": "predictive-oee-decline",
|
||||||
|
"performance-degradation": "performance-degradation",
|
||||||
|
|
||||||
|
// legacy / synonyms
|
||||||
|
"macroparo": "macrostop",
|
||||||
|
"macro-stop": "macrostop",
|
||||||
|
"microparo": "microstop",
|
||||||
|
"micro-paro": "microstop",
|
||||||
|
"down": "stop",
|
||||||
|
"downtime-acknowledged": "downtime-acknowledged",
|
||||||
|
"scrap-manual-entry": "scrap-manual-entry",
|
||||||
|
"mold-change": "mold-change",
|
||||||
|
};
|
||||||
|
|
||||||
|
const ALLOWED_TYPES = new Set([
|
||||||
|
"slow-cycle",
|
||||||
|
"microstop",
|
||||||
|
"macrostop",
|
||||||
|
"offline",
|
||||||
|
"error",
|
||||||
|
"oee-drop",
|
||||||
|
"quality-spike",
|
||||||
|
"performance-degradation",
|
||||||
|
"predictive-oee-decline",
|
||||||
|
"downtime-acknowledged",
|
||||||
|
"scrap-manual-entry",
|
||||||
|
"mold-change",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const machineIdSchema = z.string().uuid();
|
||||||
|
const MAX_EVENTS = 100;
|
||||||
|
|
||||||
|
//when no cycle time is configed
|
||||||
|
const DEFAULT_MACROSTOP_SEC = 300;
|
||||||
|
|
||||||
|
|
||||||
|
function clampText(value: unknown, maxLen: number) {
|
||||||
|
if (value === null || value === undefined) return null;
|
||||||
|
const text = String(value).trim().replace(/[\u0000-\u001f\u007f]/g, "");
|
||||||
|
if (!text) return null;
|
||||||
|
return text.length > maxLen ? text.slice(0, maxLen) : text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function numberFrom(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 parseSeqToBigInt(value: unknown): bigint | null {
|
||||||
|
if (value === null || value === undefined) return null;
|
||||||
|
if (typeof value === "number") {
|
||||||
|
if (!Number.isInteger(value) || value < 0) return null;
|
||||||
|
return BigInt(value);
|
||||||
|
}
|
||||||
|
if (typeof value === "string" && /^\d+$/.test(value)) return BigInt(value);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function canonicalText(value: unknown) {
|
||||||
|
return String(value ?? "")
|
||||||
|
.normalize("NFD")
|
||||||
|
.replace(/[\u0300-\u036f]/g, "")
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/^-+|-+$/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseReasonPath(rawPath: unknown) {
|
||||||
|
let category: string | null = null;
|
||||||
|
let detail: string | null = null;
|
||||||
|
|
||||||
|
if (Array.isArray(rawPath)) {
|
||||||
|
const first = rawPath[0];
|
||||||
|
const second = rawPath[1];
|
||||||
|
if (typeof first === "string") category = first;
|
||||||
|
if (typeof second === "string") detail = second;
|
||||||
|
if (asRecord(first)) category = clampText(first.id ?? first.label ?? first.value, 120);
|
||||||
|
if (asRecord(second)) detail = clampText(second.id ?? second.label ?? second.value, 120);
|
||||||
|
} else if (typeof rawPath === "string") {
|
||||||
|
const pieces = rawPath
|
||||||
|
.split(/>|\/|\\|\|/g)
|
||||||
|
.map((p) => p.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
category = pieces[0] ?? null;
|
||||||
|
detail = pieces[1] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
category: clampText(category, 120),
|
||||||
|
detail: clampText(detail, 120),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseReasonTextPath(reasonText: unknown) {
|
||||||
|
const text = clampText(reasonText, 240);
|
||||||
|
if (!text) return { category: null as string | null, detail: null as string | null };
|
||||||
|
const pieces = text
|
||||||
|
.split(/>|\/|\\|\|/g)
|
||||||
|
.map((p) => p.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
return {
|
||||||
|
category: clampText(pieces[0] ?? null, 120),
|
||||||
|
detail: clampText(pieces[1] ?? null, 120),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function findCatalogReasonFlexible(
|
||||||
|
catalog: ReasonCatalog | null,
|
||||||
|
kind: ReasonCatalogKind,
|
||||||
|
categoryIdOrLabel: unknown,
|
||||||
|
detailIdOrLabel: unknown
|
||||||
|
) {
|
||||||
|
const direct = findCatalogReason(catalog, kind, categoryIdOrLabel, detailIdOrLabel);
|
||||||
|
if (direct) return direct;
|
||||||
|
if (!catalog) return null;
|
||||||
|
|
||||||
|
const catNeedle = canonicalText(categoryIdOrLabel);
|
||||||
|
const detNeedle = canonicalText(detailIdOrLabel);
|
||||||
|
if (!catNeedle || !detNeedle) return null;
|
||||||
|
|
||||||
|
for (const category of catalog[kind] ?? []) {
|
||||||
|
const catMatch =
|
||||||
|
canonicalText(category.id) === catNeedle || canonicalText(category.label) === catNeedle;
|
||||||
|
if (!catMatch) continue;
|
||||||
|
for (const detail of category.details) {
|
||||||
|
const detMatch = canonicalText(detail.id) === detNeedle || canonicalText(detail.label) === detNeedle;
|
||||||
|
if (!detMatch) continue;
|
||||||
|
return {
|
||||||
|
categoryId: category.id,
|
||||||
|
categoryLabel: category.label,
|
||||||
|
detailId: detail.id,
|
||||||
|
detailLabel: detail.label,
|
||||||
|
reasonCode: toReasonCode(category.id, detail.id),
|
||||||
|
reasonLabel: `${category.label} > ${detail.label}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCatalogFromDefaults(defaultsJson: unknown) {
|
||||||
|
const defaults = asRecord(defaultsJson);
|
||||||
|
if (!defaults) return null;
|
||||||
|
return normalizeReasonCatalog(defaults.reasonCatalog ?? defaults.reasonCatalogData);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveReason(
|
||||||
|
raw: Record<string, unknown>,
|
||||||
|
kind: ReasonCatalogKind,
|
||||||
|
catalog: ReasonCatalog | null,
|
||||||
|
fallbackVersion: number
|
||||||
|
) {
|
||||||
|
const reasonPath = parseReasonPath(raw.reasonPath);
|
||||||
|
const reasonTextPath = parseReasonTextPath(raw.reasonText);
|
||||||
|
const categoryIdRaw = clampText(raw.categoryId ?? reasonPath.category ?? reasonTextPath.category, 64);
|
||||||
|
const detailIdRaw = clampText(raw.detailId ?? reasonPath.detail ?? reasonTextPath.detail, 64);
|
||||||
|
const fromCatalog = findCatalogReasonFlexible(catalog, kind, categoryIdRaw, detailIdRaw);
|
||||||
|
|
||||||
|
const categoryLabelRaw = clampText(raw.categoryLabel ?? reasonPath.category ?? reasonTextPath.category, 120);
|
||||||
|
const detailLabelRaw = clampText(raw.detailLabel ?? reasonPath.detail ?? reasonTextPath.detail, 120);
|
||||||
|
|
||||||
|
const reasonCode =
|
||||||
|
clampText(raw.reasonCode, 64)?.toUpperCase() ??
|
||||||
|
fromCatalog?.reasonCode ??
|
||||||
|
toReasonCode(categoryIdRaw ?? categoryLabelRaw, detailIdRaw ?? detailLabelRaw) ??
|
||||||
|
null;
|
||||||
|
|
||||||
|
const categoryId = fromCatalog?.categoryId ?? categoryIdRaw;
|
||||||
|
const detailId = fromCatalog?.detailId ?? detailIdRaw;
|
||||||
|
const categoryLabel = fromCatalog?.categoryLabel ?? categoryLabelRaw;
|
||||||
|
const detailLabel = fromCatalog?.detailLabel ?? detailLabelRaw;
|
||||||
|
|
||||||
|
const pathLabel =
|
||||||
|
clampText(raw.reasonText, 240) ??
|
||||||
|
fromCatalog?.reasonLabel ??
|
||||||
|
(categoryLabel && detailLabel ? `${categoryLabel} > ${detailLabel}` : null) ??
|
||||||
|
detailLabel ??
|
||||||
|
categoryLabel ??
|
||||||
|
reasonCode;
|
||||||
|
|
||||||
|
const catalogVersionRaw = numberFrom(raw.catalogVersion);
|
||||||
|
const catalogVersion = catalogVersionRaw != null ? Math.trunc(catalogVersionRaw) : fallbackVersion;
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: kind,
|
||||||
|
categoryId,
|
||||||
|
categoryLabel,
|
||||||
|
detailId,
|
||||||
|
detailLabel,
|
||||||
|
reasonCode,
|
||||||
|
reasonLabel: pathLabel,
|
||||||
|
reasonText: pathLabel,
|
||||||
|
catalogVersion,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const apiKey = req.headers.get("x-api-key");
|
||||||
|
if (!apiKey) return NextResponse.json({ ok: false, error: "Missing api key" }, { status: 401 });
|
||||||
|
|
||||||
|
let body: unknown = await req.json().catch(() => null);
|
||||||
|
|
||||||
|
// ✅ if Node-RED sent an array as the whole body, unwrap it
|
||||||
|
if (Array.isArray(body)) body = body[0];
|
||||||
|
const bodyRecord = asRecord(body) ?? {};
|
||||||
|
const payloadRecord = asRecord(bodyRecord.payload) ?? {};
|
||||||
|
|
||||||
|
// ✅ accept multiple common keys
|
||||||
|
const machineId =
|
||||||
|
bodyRecord.machineId ??
|
||||||
|
bodyRecord.machine_id ??
|
||||||
|
(asRecord(bodyRecord.machine)?.id ?? null);
|
||||||
|
let rawEvent =
|
||||||
|
bodyRecord.event ??
|
||||||
|
bodyRecord.events ??
|
||||||
|
bodyRecord.anomalies ??
|
||||||
|
payloadRecord.event ??
|
||||||
|
payloadRecord.events ??
|
||||||
|
payloadRecord.anomalies ??
|
||||||
|
payloadRecord ??
|
||||||
|
bodyRecord.data; // sometimes "data"
|
||||||
|
|
||||||
|
const rawEventRecord = asRecord(rawEvent);
|
||||||
|
if (rawEventRecord?.event && typeof rawEventRecord.event === "object") rawEvent = rawEventRecord.event;
|
||||||
|
if (Array.isArray(rawEventRecord?.events)) rawEvent = rawEventRecord.events;
|
||||||
|
|
||||||
|
if (!machineId || !rawEvent) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ ok: false, error: "Invalid payload", got: { hasMachineId: !!machineId, keys: Object.keys(bodyRecord) } },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!machineIdSchema.safeParse(String(machineId)).success) {
|
||||||
|
return NextResponse.json({ ok: false, error: "Invalid machine id" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const machine = await getMachineAuth(String(machineId), apiKey);
|
||||||
|
if (!machine) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
|
||||||
|
const orgSettings = await prisma.orgSettings.findUnique({
|
||||||
|
where: { orgId: machine.orgId },
|
||||||
|
select: { stoppageMultiplier: true, macroStoppageMultiplier: true, defaultsJson: true },
|
||||||
|
});
|
||||||
|
const fallbackCatalog = await loadFallbackReasonCatalog();
|
||||||
|
const settingsCatalog = getCatalogFromDefaults(orgSettings?.defaultsJson);
|
||||||
|
const reasonCatalog = settingsCatalog ?? fallbackCatalog;
|
||||||
|
|
||||||
|
const defaultMicroMultiplier = Number(orgSettings?.stoppageMultiplier ?? 1.5);
|
||||||
|
const defaultMacroMultiplier = Math.max(
|
||||||
|
defaultMicroMultiplier,
|
||||||
|
Number(orgSettings?.macroStoppageMultiplier ?? 5)
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
// ✅ normalize to array no matter what
|
||||||
|
const events = Array.isArray(rawEvent) ? rawEvent : [rawEvent];
|
||||||
|
if (events.length > MAX_EVENTS) {
|
||||||
|
return NextResponse.json({ ok: false, error: "Too many events" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const created: { id: string; ts: Date; eventType: string }[] = [];
|
||||||
|
const skipped: Array<Record<string, unknown>> = [];
|
||||||
|
|
||||||
|
for (const ev of events) {
|
||||||
|
const evRecord = asRecord(ev);
|
||||||
|
if (!evRecord) {
|
||||||
|
skipped.push({ reason: "invalid_event_object" });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const evData = asRecord(evRecord.data) ?? {};
|
||||||
|
// We'll re-check reason again after parsing `data` (it may be a JSON string)
|
||||||
|
let evReason = asRecord(evRecord.reason) ?? asRecord(evData.reason);
|
||||||
|
const evDowntime = asRecord(evRecord.downtime) ?? asRecord(evData.downtime);
|
||||||
|
// Some producers nest the reason under `downtime.reason`
|
||||||
|
if (!evReason) evReason = asRecord(evDowntime?.reason);
|
||||||
|
|
||||||
|
const rawType = evRecord.eventType ?? evRecord.anomaly_type ?? evRecord.topic ?? bodyRecord.topic ?? "";
|
||||||
|
const typ0 = normalizeType(rawType);
|
||||||
|
const typ = CANON_TYPE[typ0] ?? typ0;
|
||||||
|
|
||||||
|
// Determine timestamp
|
||||||
|
const tsMs =
|
||||||
|
(typeof evRecord.timestamp === "number" && evRecord.timestamp) ||
|
||||||
|
(typeof evData.timestamp === "number" && evData.timestamp) ||
|
||||||
|
(typeof evData.event_timestamp === "number" && evData.event_timestamp) ||
|
||||||
|
null;
|
||||||
|
|
||||||
|
const ts = tsMs ? new Date(tsMs) : new Date();
|
||||||
|
|
||||||
|
// Severity defaulting (do not skip on severity — store for audit)
|
||||||
|
let sev = String(evRecord.severity ?? "").trim().toLowerCase();
|
||||||
|
if (!sev) sev = "warning";
|
||||||
|
|
||||||
|
// Stop classification -> microstop/macrostop
|
||||||
|
let finalType = typ;
|
||||||
|
let stopSecForReason: number | null = null;
|
||||||
|
if (typ === "stop") {
|
||||||
|
const stopSec =
|
||||||
|
(typeof evData.stoppage_duration_seconds === "number" && evData.stoppage_duration_seconds) ||
|
||||||
|
(typeof evData.stop_duration_seconds === "number" && evData.stop_duration_seconds) ||
|
||||||
|
null;
|
||||||
|
stopSecForReason = stopSec != null ? Number(stopSec) : null;
|
||||||
|
|
||||||
|
if (stopSec != null) {
|
||||||
|
const theoretical = Number(evData.theoretical_cycle_time ?? evData.theoreticalCycleTime ?? 0) || 0;
|
||||||
|
|
||||||
|
const microMultiplier = Number(
|
||||||
|
evData.micro_threshold_multiplier ?? evData.threshold_multiplier ?? defaultMicroMultiplier
|
||||||
|
);
|
||||||
|
const macroMultiplier = Math.max(
|
||||||
|
microMultiplier,
|
||||||
|
Number(evData.macro_threshold_multiplier ?? defaultMacroMultiplier)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (theoretical > 0) {
|
||||||
|
const macroThresholdSec = theoretical * macroMultiplier;
|
||||||
|
finalType = stopSec >= macroThresholdSec ? "macrostop" : "microstop";
|
||||||
|
} else {
|
||||||
|
finalType = stopSec >= DEFAULT_MACROSTOP_SEC ? "macrostop" : "microstop";
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// missing duration -> conservative
|
||||||
|
finalType = "microstop";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ALLOWED_TYPES.has(finalType)) {
|
||||||
|
skipped.push({ reason: "type_not_allowed", typ: finalType, sev });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const title =
|
||||||
|
clampText(evRecord.title, 160) ||
|
||||||
|
(finalType === "slow-cycle" ? "Slow Cycle Detected" :
|
||||||
|
finalType === "macrostop" ? "Macrostop Detected" :
|
||||||
|
finalType === "microstop" ? "Microstop Detected" :
|
||||||
|
"Event");
|
||||||
|
|
||||||
|
const description = clampText(evRecord.description, 1000);
|
||||||
|
|
||||||
|
// store full blob, ensure object
|
||||||
|
const rawData = evRecord.data ?? evRecord;
|
||||||
|
const parsedData = typeof rawData === "string"
|
||||||
|
? (() => {
|
||||||
|
try {
|
||||||
|
return JSON.parse(rawData);
|
||||||
|
} catch {
|
||||||
|
return { raw: rawData };
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
: rawData;
|
||||||
|
const dataObj: Record<string, unknown> =
|
||||||
|
parsedData && typeof parsedData === "object" && !Array.isArray(parsedData)
|
||||||
|
? { ...(parsedData as Record<string, unknown>) }
|
||||||
|
: { raw: parsedData };
|
||||||
|
if (evRecord.status != null && dataObj.status == null) dataObj.status = evRecord.status;
|
||||||
|
if (evRecord.alert_id != null && dataObj.alert_id == null) dataObj.alert_id = evRecord.alert_id;
|
||||||
|
if (evRecord.is_update != null && dataObj.is_update == null) dataObj.is_update = evRecord.is_update;
|
||||||
|
if (evRecord.is_auto_ack != null && dataObj.is_auto_ack == null) dataObj.is_auto_ack = evRecord.is_auto_ack;
|
||||||
|
if (evReason && dataObj.reason == null) dataObj.reason = evReason;
|
||||||
|
if (evDowntime && dataObj.downtime == null) dataObj.downtime = evDowntime;
|
||||||
|
|
||||||
|
// If `data` was a JSON string, the earlier evReason lookup would miss it.
|
||||||
|
// Re-check here using the normalized object we will persist.
|
||||||
|
if (!evReason) evReason = asRecord(dataObj.reason);
|
||||||
|
if (!evReason) evReason = asRecord(asRecord(dataObj.downtime)?.reason);
|
||||||
|
|
||||||
|
// If we have a reasonText but missing ids, derive ids from the path-like string.
|
||||||
|
if (evReason) {
|
||||||
|
const hasCat = clampText((evReason as any).categoryId, 64) ?? clampText((evReason as any).categoryLabel, 120);
|
||||||
|
const hasDet = clampText((evReason as any).detailId, 64) ?? clampText((evReason as any).detailLabel, 120);
|
||||||
|
const rt = clampText((evReason as any).reasonText, 240);
|
||||||
|
if ((!hasCat || !hasDet) && rt) {
|
||||||
|
const parsed = parseReasonTextPath(rt);
|
||||||
|
const next = { ...evReason } as Record<string, unknown>;
|
||||||
|
// Preserve any explicit ids; only fill gaps.
|
||||||
|
if ((next as any).categoryId == null && parsed.category) next.categoryId = canonicalText(parsed.category);
|
||||||
|
if ((next as any).categoryLabel == null && parsed.category) next.categoryLabel = parsed.category;
|
||||||
|
if ((next as any).detailId == null && parsed.detail) next.detailId = canonicalText(parsed.detail);
|
||||||
|
if ((next as any).detailLabel == null && parsed.detail) next.detailLabel = parsed.detail;
|
||||||
|
evReason = next;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeWorkOrder = asRecord(evRecord.activeWorkOrder);
|
||||||
|
const dataActiveWorkOrder = asRecord(evData.activeWorkOrder);
|
||||||
|
|
||||||
|
// ✨ Cada evento puede traer su propio seq, o usar el del payload raíz
|
||||||
|
const evSeq =
|
||||||
|
parseSeqToBigInt(evRecord.seq) ??
|
||||||
|
parseSeqToBigInt(evData.seq) ??
|
||||||
|
bodySeq;
|
||||||
|
|
||||||
|
const evSchemaVersion =
|
||||||
|
clampText(evRecord.schemaVersion, 16) ??
|
||||||
|
bodySchemaVersion;
|
||||||
|
|
||||||
|
const eventData = {
|
||||||
|
orgId: machine.orgId,
|
||||||
|
machineId: machine.id,
|
||||||
|
schemaVersion: evSchemaVersion,
|
||||||
|
seq: evSeq,
|
||||||
|
ts,
|
||||||
|
topic: clampText(evRecord.topic ?? finalType, 64) ?? finalType,
|
||||||
|
eventType: finalType,
|
||||||
|
severity: sev,
|
||||||
|
requiresAck: !!evRecord.requires_ack,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
data: toJsonValue(dataObj),
|
||||||
|
workOrderId:
|
||||||
|
clampText(evRecord.work_order_id, 64) ??
|
||||||
|
clampText(evData.work_order_id, 64) ??
|
||||||
|
clampText(activeWorkOrder?.id, 64) ??
|
||||||
|
clampText(dataActiveWorkOrder?.id, 64) ??
|
||||||
|
null,
|
||||||
|
sku:
|
||||||
|
clampText(evRecord.sku, 64) ??
|
||||||
|
clampText(evData.sku, 64) ??
|
||||||
|
clampText(activeWorkOrder?.sku, 64) ??
|
||||||
|
clampText(dataActiveWorkOrder?.sku, 64) ??
|
||||||
|
null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ✨ Idempotente: si ya existe (mismo orgId+machineId+seq), no inserta
|
||||||
|
const insertResult = await prisma.machineEvent.createMany({
|
||||||
|
data: [eventData],
|
||||||
|
skipDuplicates: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// ✨ Buscar la fila (la recién creada o la duplicada existente)
|
||||||
|
let row;
|
||||||
|
if (evSeq != null) {
|
||||||
|
row = await prisma.machineEvent.findFirst({
|
||||||
|
where: {
|
||||||
|
orgId: machine.orgId,
|
||||||
|
machineId: machine.id,
|
||||||
|
seq: evSeq,
|
||||||
|
},
|
||||||
|
orderBy: { ts_server: "asc" },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Sin seq, buscar por ts (fallback compatibilidad con eventos viejos)
|
||||||
|
row = await prisma.machineEvent.findFirst({
|
||||||
|
where: {
|
||||||
|
orgId: machine.orgId,
|
||||||
|
machineId: machine.id,
|
||||||
|
ts,
|
||||||
|
eventType: finalType,
|
||||||
|
},
|
||||||
|
orderBy: { ts_server: "desc" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
|
skipped.push({ reason: "row_not_found_after_insert", seq: evSeq?.toString() });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const wasDuplicate = insertResult.count === 0;
|
||||||
|
|
||||||
|
// Si fue duplicado, no procesar reasonEntry ni alertas (ya se hicieron antes)
|
||||||
|
if (wasDuplicate) {
|
||||||
|
created.push({ id: row.id, ts: row.ts, eventType: row.eventType });
|
||||||
|
continue; // ✨ saltar el resto del procesamiento
|
||||||
|
}
|
||||||
|
|
||||||
|
created.push({ id: row.id, ts: row.ts, eventType: row.eventType });
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// If the payload carries a `reason`, create the corresponding ReasonEntry.
|
||||||
|
// If it doesn't, still create an "UNCLASSIFIED" downtime ReasonEntry for stop events so the dashboard can show coverage.
|
||||||
|
if (evRecord.is_update || evRecord.is_auto_ack || dataObj.is_update || dataObj.is_auto_ack){
|
||||||
|
// skip duplicate reasonEntry for refresh/ack
|
||||||
|
} else if (evReason || finalType === "microstop" || finalType === "macrostop" || finalType === "downtime-acknowledged" || finalType === "mold-change"){
|
||||||
|
const moldIncidentKey =
|
||||||
|
clampText(evData.incidentKey ?? dataObj.incidentKey, 128) ??
|
||||||
|
(numberFrom(evData.start_ms ?? dataObj.start_ms) != null
|
||||||
|
? `mold-change:${Math.trunc(numberFrom(evData.start_ms ?? dataObj.start_ms) as number)}`
|
||||||
|
: null);
|
||||||
|
const reasonRaw: Record<string, unknown> =
|
||||||
|
evReason ??
|
||||||
|
(finalType === "mold-change"
|
||||||
|
? ({
|
||||||
|
type: "downtime",
|
||||||
|
categoryId: "cambio-molde",
|
||||||
|
detailId: "cambio-molde",
|
||||||
|
categoryLabel: "Cambio molde",
|
||||||
|
detailLabel: "Cambio molde",
|
||||||
|
reasonCode: "MOLD_CHANGE",
|
||||||
|
reasonText: "Cambio molde",
|
||||||
|
incidentKey: moldIncidentKey ?? row.id,
|
||||||
|
} as Record<string, unknown>)
|
||||||
|
:
|
||||||
|
({
|
||||||
|
type: "downtime",
|
||||||
|
categoryId: "unclassified",
|
||||||
|
detailId: "unclassified",
|
||||||
|
categoryLabel: "Unclassified",
|
||||||
|
detailLabel: "Unclassified",
|
||||||
|
reasonCode: "UNCLASSIFIED",
|
||||||
|
reasonText: "Unclassified",
|
||||||
|
incidentKey: row.id,
|
||||||
|
} as Record<string, unknown>));
|
||||||
|
|
||||||
|
const inferredKind: ReasonCatalogKind =
|
||||||
|
String(reasonRaw.type ?? "").toLowerCase() === "scrap" || finalType === "scrap-manual-entry"
|
||||||
|
? "scrap"
|
||||||
|
: "downtime";
|
||||||
|
const resolved = resolveReason(reasonRaw, inferredKind, reasonCatalog, reasonCatalog.version);
|
||||||
|
|
||||||
|
if (resolved.reasonCode) {
|
||||||
|
const reasonId =
|
||||||
|
clampText(reasonRaw.reasonId, 128) ??
|
||||||
|
(inferredKind === "downtime"
|
||||||
|
? `evt:${machine.id}:downtime:${clampText((reasonRaw as any).incidentKey ?? evDowntime?.incidentKey, 128) ?? row.id}`
|
||||||
|
: `evt:${machine.id}:scrap:${clampText(reasonRaw.scrapEntryId, 128) ?? row.id}`);
|
||||||
|
|
||||||
|
const workOrderId =
|
||||||
|
clampText(evRecord.work_order_id, 64) ??
|
||||||
|
clampText(evData.work_order_id, 64) ??
|
||||||
|
clampText(evRecord.workOrderId, 64) ??
|
||||||
|
null;
|
||||||
|
|
||||||
|
const commonWrite = {
|
||||||
|
reasonCode: resolved.reasonCode,
|
||||||
|
reasonLabel: resolved.reasonLabel ?? resolved.reasonCode,
|
||||||
|
reasonText: resolved.reasonText ?? null,
|
||||||
|
capturedAt: row.ts,
|
||||||
|
workOrderId,
|
||||||
|
schemaVersion: Math.max(1, Math.trunc(resolved.catalogVersion)),
|
||||||
|
meta: toJsonValue({
|
||||||
|
source: "ingest:event",
|
||||||
|
eventId: row.id,
|
||||||
|
eventType: row.eventType,
|
||||||
|
incidentKey: clampText((reasonRaw as any).incidentKey ?? evDowntime?.incidentKey, 128),
|
||||||
|
anomalyType:
|
||||||
|
clampText(evRecord.anomalyType, 64) ??
|
||||||
|
clampText(evDowntime?.anomalyType, 64) ??
|
||||||
|
clampText(evRecord.anomaly_type, 64),
|
||||||
|
reason: {
|
||||||
|
type: resolved.type,
|
||||||
|
categoryId: resolved.categoryId,
|
||||||
|
categoryLabel: resolved.categoryLabel,
|
||||||
|
detailId: resolved.detailId,
|
||||||
|
detailLabel: resolved.detailLabel,
|
||||||
|
reasonText: resolved.reasonText,
|
||||||
|
catalogVersion: resolved.catalogVersion,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (inferredKind === "downtime") {
|
||||||
|
const incidentKey = clampText((reasonRaw as any).incidentKey ?? evDowntime?.incidentKey, 128) ?? row.id;
|
||||||
|
const durationSeconds =
|
||||||
|
numberFrom(evDowntime?.durationSeconds) ??
|
||||||
|
numberFrom(evData.duration_sec) ??
|
||||||
|
numberFrom(evData.stoppage_duration_seconds) ??
|
||||||
|
numberFrom(evData.stop_duration_seconds) ??
|
||||||
|
(stopSecForReason != null ? stopSecForReason : null) ??
|
||||||
|
null;
|
||||||
|
const episodeEndTsMs =
|
||||||
|
numberFrom(evData.end_ms) ??
|
||||||
|
numberFrom(evDowntime?.episodeEndTsMs) ??
|
||||||
|
numberFrom(evDowntime?.acknowledgedAtMs) ??
|
||||||
|
null;
|
||||||
|
|
||||||
|
await prisma.reasonEntry.upsert({
|
||||||
|
where: { reasonId },
|
||||||
|
create: {
|
||||||
|
orgId: machine.orgId,
|
||||||
|
machineId: machine.id,
|
||||||
|
reasonId,
|
||||||
|
kind: "downtime",
|
||||||
|
episodeId: incidentKey,
|
||||||
|
durationSeconds: durationSeconds != null ? Math.max(0, Math.trunc(durationSeconds)) : null,
|
||||||
|
episodeEndTs: episodeEndTsMs != null ? new Date(episodeEndTsMs) : null,
|
||||||
|
...commonWrite,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
kind: "downtime",
|
||||||
|
episodeId: incidentKey,
|
||||||
|
durationSeconds: durationSeconds != null ? Math.max(0, Math.trunc(durationSeconds)) : null,
|
||||||
|
episodeEndTs: episodeEndTsMs != null ? new Date(episodeEndTsMs) : null,
|
||||||
|
...commonWrite,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const scrapEntryId =
|
||||||
|
clampText((reasonRaw as any).scrapEntryId, 128) ??
|
||||||
|
clampText(evRecord.id, 128) ??
|
||||||
|
clampText(evRecord.eventId, 128) ??
|
||||||
|
row.id;
|
||||||
|
const scrapQtyRaw =
|
||||||
|
numberFrom(evRecord.scrapDelta) ??
|
||||||
|
numberFrom(evData.scrapDelta) ??
|
||||||
|
numberFrom(evData.scrap_delta) ??
|
||||||
|
0;
|
||||||
|
const scrapQty = Math.max(0, Math.trunc(scrapQtyRaw));
|
||||||
|
|
||||||
|
await prisma.reasonEntry.upsert({
|
||||||
|
where: { reasonId },
|
||||||
|
create: {
|
||||||
|
orgId: machine.orgId,
|
||||||
|
machineId: machine.id,
|
||||||
|
reasonId,
|
||||||
|
kind: "scrap",
|
||||||
|
scrapEntryId,
|
||||||
|
scrapQty,
|
||||||
|
scrapUnit: clampText((reasonRaw as any).scrapUnit, 16) ?? null,
|
||||||
|
...commonWrite,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
kind: "scrap",
|
||||||
|
scrapEntryId,
|
||||||
|
scrapQty,
|
||||||
|
scrapUnit: clampText((reasonRaw as any).scrapUnit, 16) ?? null,
|
||||||
|
...commonWrite,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (row.eventType !== "downtime-acknowledged" && row.eventType !== "scrap-manual-entry") {
|
||||||
|
await evaluateAlertsForEvent(row.id);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[alerts] evaluation failed", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true, createdCount: created.length, created, skippedCount: skipped.length, skipped });
|
||||||
|
}
|
||||||
@@ -97,26 +97,38 @@ export async function POST(req: Request) {
|
|||||||
// 5) Store heartbeat
|
// 5) Store heartbeat
|
||||||
// Keep your legacy fields, but store meta fields too.
|
// Keep your legacy fields, but store meta fields too.
|
||||||
const tsServerNow = new Date();
|
const tsServerNow = new Date();
|
||||||
const hb = await prisma.machineHeartbeat.create({
|
const hbRow = {
|
||||||
data: {
|
|
||||||
orgId,
|
orgId,
|
||||||
machineId: machine.id,
|
machineId: machine.id,
|
||||||
|
|
||||||
// Phase 0 meta
|
|
||||||
schemaVersion,
|
schemaVersion,
|
||||||
seq,
|
seq,
|
||||||
ts: tsDeviceDate,
|
ts: tsDeviceDate,
|
||||||
tsServer: tsServerNow,
|
tsServer: tsServerNow,
|
||||||
|
|
||||||
// Legacy payload compatibility
|
|
||||||
status: body.status ? String(body.status) : (body.online ? "RUN" : "STOP"),
|
status: body.status ? String(body.status) : (body.online ? "RUN" : "STOP"),
|
||||||
message: body.message ? String(body.message) : null,
|
message: body.message ? String(body.message) : null,
|
||||||
ip: body.ip ? String(body.ip) : null,
|
ip: body.ip ? String(body.ip) : null,
|
||||||
fwVersion: body.fwVersion ? String(body.fwVersion) : null,
|
fwVersion: body.fwVersion ? String(body.fwVersion) : null,
|
||||||
},
|
};
|
||||||
|
|
||||||
|
const insertHb = await prisma.machineHeartbeat.createMany({
|
||||||
|
data: [hbRow],
|
||||||
|
skipDuplicates: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Optional: update machine last seen (same as KPI)
|
const hb = await prisma.machineHeartbeat.findFirst({
|
||||||
|
where: {
|
||||||
|
orgId,
|
||||||
|
machineId: machine.id,
|
||||||
|
ts: tsDeviceDate,
|
||||||
|
},
|
||||||
|
orderBy: { tsServer: "asc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!hb) {
|
||||||
|
return NextResponse.json({ ok: false, error: "Server error", detail: "Heartbeat row missing" }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional: update machine last seen (same as KPI) — also on duplicate HB so lastSeen is fresh
|
||||||
await prisma.machine.update({
|
await prisma.machine.update({
|
||||||
where: { id: machine.id },
|
where: { id: machine.id },
|
||||||
data: {
|
data: {
|
||||||
@@ -132,6 +144,7 @@ export async function POST(req: Request) {
|
|||||||
id: hb.id,
|
id: hb.id,
|
||||||
tsDevice: hb.ts,
|
tsDevice: hb.ts,
|
||||||
tsServer: hb.tsServer,
|
tsServer: hb.tsServer,
|
||||||
|
duplicate: insertHb.count === 0,
|
||||||
});
|
});
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const msg = err instanceof Error ? err.message : "Unknown error";
|
const msg = err instanceof Error ? err.message : "Unknown error";
|
||||||
|
|||||||
@@ -27,6 +27,29 @@ function asRecord(value: unknown): Record<string, unknown> | null {
|
|||||||
return value as Record<string, unknown>;
|
return value as Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toFiniteNumber(value: unknown): number | null {
|
||||||
|
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||||
|
if (typeof value === "string") {
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (Number.isFinite(parsed)) return parsed;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toFiniteInt(value: unknown): number | null {
|
||||||
|
const parsed = toFiniteNumber(value);
|
||||||
|
if (parsed == null) return null;
|
||||||
|
return Math.trunc(parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickFirstNumber(...values: unknown[]) {
|
||||||
|
for (const value of values) {
|
||||||
|
const parsed = toFiniteNumber(value);
|
||||||
|
if (parsed != null) return parsed;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function readPath(root: unknown, path: string[]): unknown {
|
function readPath(root: unknown, path: string[]): unknown {
|
||||||
let current = root;
|
let current = root;
|
||||||
for (const key of path) {
|
for (const key of path) {
|
||||||
@@ -160,28 +183,37 @@ export async function POST(req: Request) {
|
|||||||
orgId = machine.orgId;
|
orgId = machine.orgId;
|
||||||
|
|
||||||
const woRecord = (body.activeWorkOrder ?? {}) as Record<string, unknown>;
|
const woRecord = (body.activeWorkOrder ?? {}) as Record<string, unknown>;
|
||||||
const good =
|
const activeWorkOrderId = woRecord.id != null ? String(woRecord.id).trim() : "";
|
||||||
typeof woRecord.good === "number"
|
const activeSku = woRecord.sku != null ? String(woRecord.sku).trim() : "";
|
||||||
? woRecord.good
|
const activeStatus = woRecord.status != null ? String(woRecord.status).trim() : "";
|
||||||
: typeof woRecord.goodParts === "number"
|
const activeTargetQty = toFiniteInt(woRecord.target);
|
||||||
? woRecord.goodParts
|
const activeCycleTime = toFiniteNumber(woRecord.cycleTime);
|
||||||
: typeof woRecord.good_parts === "number"
|
const good = pickFirstNumber(woRecord.good, woRecord.goodParts, woRecord.good_parts);
|
||||||
? woRecord.good_parts
|
const scrap = pickFirstNumber(woRecord.scrap, woRecord.scrapParts, woRecord.scrap_parts);
|
||||||
: null;
|
const activeGoodParts = Math.max(0, Math.trunc(good ?? 0));
|
||||||
const scrap =
|
const activeScrapParts = Math.max(0, Math.trunc(scrap ?? 0));
|
||||||
typeof woRecord.scrap === "number"
|
const activeCycleCount = Math.max(
|
||||||
? woRecord.scrap
|
0,
|
||||||
: typeof woRecord.scrapParts === "number"
|
toFiniteInt(woRecord.cycleCount ?? woRecord.cycle_count ?? body.cycle_count) ?? 0
|
||||||
? woRecord.scrapParts
|
);
|
||||||
: typeof woRecord.scrap_parts === "number"
|
const snapshotCycleCount =
|
||||||
? woRecord.scrap_parts
|
toFiniteInt(body.cycle_count) ??
|
||||||
: null;
|
toFiniteInt(woRecord.cycle_count) ??
|
||||||
|
toFiniteInt(woRecord.cycleCount);
|
||||||
|
const snapshotGoodParts =
|
||||||
|
toFiniteInt(body.good_parts) ??
|
||||||
|
toFiniteInt(woRecord.good_parts) ??
|
||||||
|
toFiniteInt(woRecord.goodParts);
|
||||||
|
const snapshotScrapParts =
|
||||||
|
toFiniteInt(body.scrap_parts) ??
|
||||||
|
toFiniteInt(woRecord.scrap_parts) ??
|
||||||
|
toFiniteInt(woRecord.scrapParts);
|
||||||
const k = body.kpis ?? {};
|
const k = body.kpis ?? {};
|
||||||
const safeCycleTime =
|
const safeCycleTime =
|
||||||
typeof body.cycleTime === "number" && body.cycleTime > 0
|
typeof body.cycleTime === "number" && body.cycleTime > 0
|
||||||
? body.cycleTime
|
? body.cycleTime
|
||||||
: typeof woRecord.cycleTime === "number" && woRecord.cycleTime > 0
|
: activeCycleTime != null && activeCycleTime > 0
|
||||||
? woRecord.cycleTime
|
? activeCycleTime
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const safeCavities =
|
const safeCavities =
|
||||||
@@ -190,44 +222,74 @@ export async function POST(req: Request) {
|
|||||||
: typeof woRecord.cavities === "number" && woRecord.cavities > 0
|
: typeof woRecord.cavities === "number" && woRecord.cavities > 0
|
||||||
? woRecord.cavities
|
? woRecord.cavities
|
||||||
: null;
|
: null;
|
||||||
// Write snapshot (ts = tsDevice; tsServer auto)
|
// Write snapshot (ts = tsDevice; tsServer auto). Idempotent on (org, machine, ts) to absorb retries.
|
||||||
const row = await prisma.machineKpiSnapshot.create({
|
const kpiData = {
|
||||||
data: {
|
|
||||||
orgId,
|
orgId,
|
||||||
machineId: machine.id,
|
machineId: machine.id,
|
||||||
|
|
||||||
// Phase 0 meta
|
|
||||||
schemaVersion,
|
schemaVersion,
|
||||||
seq,
|
seq,
|
||||||
ts: tsDeviceDate, // store device-time in ts; server-time goes to ts_server
|
ts: tsDeviceDate,
|
||||||
|
workOrderId: activeWorkOrderId || null,
|
||||||
// Work order fields
|
sku: activeSku || null,
|
||||||
workOrderId: woRecord.id != null ? String(woRecord.id) : null,
|
target: activeTargetQty,
|
||||||
sku: woRecord.sku != null ? String(woRecord.sku) : null,
|
|
||||||
target: typeof woRecord.target === "number" ? Math.trunc(woRecord.target) : null,
|
|
||||||
good: good != null ? Math.trunc(good) : null,
|
good: good != null ? Math.trunc(good) : null,
|
||||||
scrap: scrap != null ? Math.trunc(scrap) : null,
|
scrap: scrap != null ? Math.trunc(scrap) : null,
|
||||||
|
cycleCount: snapshotCycleCount,
|
||||||
// Counters
|
goodParts: snapshotGoodParts,
|
||||||
cycleCount: typeof body.cycle_count === "number" ? body.cycle_count : null,
|
scrapParts: snapshotScrapParts,
|
||||||
goodParts: typeof body.good_parts === "number" ? body.good_parts : null,
|
|
||||||
scrapParts: typeof body.scrap_parts === "number" ? body.scrap_parts : null,
|
|
||||||
cavities: safeCavities,
|
cavities: safeCavities,
|
||||||
|
|
||||||
// Cycle times
|
|
||||||
cycleTime: safeCycleTime,
|
cycleTime: safeCycleTime,
|
||||||
actualCycle: typeof body.actualCycleTime === "number" ? body.actualCycleTime : null,
|
actualCycle: typeof body.actualCycleTime === "number" ? body.actualCycleTime : null,
|
||||||
|
|
||||||
// KPIs (0..100)
|
|
||||||
availability: typeof k.availability === "number" ? k.availability : null,
|
availability: typeof k.availability === "number" ? k.availability : null,
|
||||||
performance: typeof k.performance === "number" ? k.performance : null,
|
performance: typeof k.performance === "number" ? k.performance : null,
|
||||||
quality: typeof k.quality === "number" ? k.quality : null,
|
quality: typeof k.quality === "number" ? k.quality : null,
|
||||||
oee: typeof k.oee === "number" ? k.oee : null,
|
oee: typeof k.oee === "number" ? k.oee : null,
|
||||||
|
|
||||||
trackingEnabled: typeof body.trackingEnabled === "boolean" ? body.trackingEnabled : null,
|
trackingEnabled: typeof body.trackingEnabled === "boolean" ? body.trackingEnabled : null,
|
||||||
productionStarted: typeof body.productionStarted === "boolean" ? body.productionStarted : null,
|
productionStarted: typeof body.productionStarted === "boolean" ? body.productionStarted : null,
|
||||||
|
};
|
||||||
|
const insertKpi = await prisma.machineKpiSnapshot.createMany({
|
||||||
|
data: [kpiData],
|
||||||
|
skipDuplicates: true,
|
||||||
|
});
|
||||||
|
const row = await prisma.machineKpiSnapshot.findFirst({
|
||||||
|
where: { orgId, machineId: machine.id, ts: tsDeviceDate },
|
||||||
|
orderBy: { tsServer: "asc" },
|
||||||
|
});
|
||||||
|
if (!row) {
|
||||||
|
return NextResponse.json({ ok: false, error: "Server error", detail: "KPI snapshot row missing" }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeWorkOrderId) {
|
||||||
|
await prisma.machineWorkOrder.upsert({
|
||||||
|
where: {
|
||||||
|
machineId_workOrderId: {
|
||||||
|
machineId: machine.id,
|
||||||
|
workOrderId: activeWorkOrderId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
orgId: machine.orgId,
|
||||||
|
machineId: machine.id,
|
||||||
|
workOrderId: activeWorkOrderId,
|
||||||
|
sku: activeSku || null,
|
||||||
|
targetQty: activeTargetQty,
|
||||||
|
cycleTime: activeCycleTime,
|
||||||
|
status: activeStatus || "RUNNING",
|
||||||
|
goodParts: activeGoodParts,
|
||||||
|
scrapParts: activeScrapParts,
|
||||||
|
cycleCount: activeCycleCount,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
sku: activeSku || undefined,
|
||||||
|
targetQty: activeTargetQty ?? undefined,
|
||||||
|
cycleTime: activeCycleTime ?? undefined,
|
||||||
|
status: activeStatus || undefined,
|
||||||
|
goodParts: activeGoodParts,
|
||||||
|
scrapParts: activeScrapParts,
|
||||||
|
cycleCount: activeCycleCount,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Optional but useful: update machine "last seen" meta fields
|
// Optional but useful: update machine "last seen" meta fields
|
||||||
await prisma.machine.update({
|
await prisma.machine.update({
|
||||||
@@ -266,6 +328,7 @@ export async function POST(req: Request) {
|
|||||||
id: row.id,
|
id: row.id,
|
||||||
tsDevice: row.ts,
|
tsDevice: row.ts,
|
||||||
tsServer: row.tsServer,
|
tsServer: row.tsServer,
|
||||||
|
duplicate: insertKpi.count === 0,
|
||||||
trace: traceEnabled ? trace : undefined,
|
trace: traceEnabled ? trace : undefined,
|
||||||
});
|
});
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
|
|||||||
@@ -258,14 +258,65 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ mach
|
|||||||
})
|
})
|
||||||
: allowed;
|
: allowed;
|
||||||
|
|
||||||
const seen = new Set<string>();
|
// Build a lookup of raw event metadata (incidentKey, status, is_auto_ack)
|
||||||
const deduped = filtered.filter((event) => {
|
// by event id, so we can collapse the normalized events down to one
|
||||||
const key = `${event.eventType}-${event.ts ?? ""}-${event.title}`;
|
// "active" + one "resolved" per incident.
|
||||||
if (seen.has(key)) return false;
|
const rawMetaById = new Map<string, { incidentKey: string | null; status: string | null; isAutoAck: boolean }>();
|
||||||
seen.add(key);
|
for (const row of rawEvents) {
|
||||||
return true;
|
let parsed: unknown = row.data;
|
||||||
|
if (typeof parsed === "string") {
|
||||||
|
try { parsed = JSON.parse(parsed); } catch { parsed = null; }
|
||||||
|
}
|
||||||
|
const data: Record<string, unknown> =
|
||||||
|
parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
||||||
|
? (parsed as Record<string, unknown>)
|
||||||
|
: {};
|
||||||
|
const isAutoAck =
|
||||||
|
data.is_auto_ack === true ||
|
||||||
|
data.isAutoAck === true ||
|
||||||
|
data.is_auto_ack === "true" ||
|
||||||
|
data.isAutoAck === "true";
|
||||||
|
const incidentKey =
|
||||||
|
typeof data.incidentKey === "string" ? data.incidentKey :
|
||||||
|
typeof data.incident_key === "string" ? data.incident_key : null;
|
||||||
|
const status = typeof data.status === "string" ? data.status.toLowerCase() : null;
|
||||||
|
rawMetaById.set(row.id, { incidentKey, status, isAutoAck });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drop pure auto-ack refresh pings.
|
||||||
|
const filteredNoAutoAck = filtered.filter((event) => {
|
||||||
|
const meta = rawMetaById.get(event.id);
|
||||||
|
return !meta?.isAutoAck;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Group by incidentKey: keep at most one "active" (oldest = original happen)
|
||||||
|
// and one "resolved" (newest = actual end) per incident. Events without
|
||||||
|
// incidentKey pass through unchanged (mold-change, edge-case events).
|
||||||
|
const byGroup = new Map<string, typeof filteredNoAutoAck[number]>();
|
||||||
|
const passthrough: typeof filteredNoAutoAck = [];
|
||||||
|
|
||||||
|
for (const event of filteredNoAutoAck) {
|
||||||
|
const meta = rawMetaById.get(event.id);
|
||||||
|
const groupId = meta?.incidentKey;
|
||||||
|
if (!groupId) {
|
||||||
|
passthrough.push(event);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const statusKey = meta.status === "resolved" ? "resolved" : "active";
|
||||||
|
const key = `${groupId}:${statusKey}`;
|
||||||
|
const existing = byGroup.get(key);
|
||||||
|
if (!existing) {
|
||||||
|
byGroup.set(key, event);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const existingTs = existing.ts ? existing.ts.getTime() : 0;
|
||||||
|
const eventTs = event.ts ? event.ts.getTime() : 0;
|
||||||
|
const pickNewest = statusKey === "resolved";
|
||||||
|
const shouldReplace = pickNewest ? eventTs > existingTs : eventTs < existingTs;
|
||||||
|
if (shouldReplace) byGroup.set(key, event);
|
||||||
|
}
|
||||||
|
|
||||||
|
const deduped = [...passthrough, ...byGroup.values()];
|
||||||
deduped.sort((a, b) => {
|
deduped.sort((a, b) => {
|
||||||
const at = a.ts ? a.ts.getTime() : 0;
|
const at = a.ts ? a.ts.getTime() : 0;
|
||||||
const bt = b.ts ? b.ts.getTime() : 0;
|
const bt = b.ts ? b.ts.getTime() : 0;
|
||||||
|
|||||||
492
app/api/machines/[machineId]/route.ts.bak
Normal file
492
app/api/machines/[machineId]/route.ts.bak
Normal file
@@ -0,0 +1,492 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
import { Prisma } from "@prisma/client";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { requireSession } from "@/lib/auth/requireSession";
|
||||||
|
import { normalizeEvent } from "@/lib/events/normalizeEvent";
|
||||||
|
import { invalidateMachineAuth } from "@/lib/machineAuthCache";
|
||||||
|
|
||||||
|
const machineIdSchema = z.string().uuid();
|
||||||
|
|
||||||
|
const ALLOWED_EVENT_TYPES = new Set([
|
||||||
|
"slow-cycle",
|
||||||
|
"microstop",
|
||||||
|
"macrostop",
|
||||||
|
"offline",
|
||||||
|
"error",
|
||||||
|
"oee-drop",
|
||||||
|
"quality-spike",
|
||||||
|
"performance-degradation",
|
||||||
|
"predictive-oee-decline",
|
||||||
|
"alert-delivery-failed",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function canManageMachines(role?: string | null) {
|
||||||
|
return role === "OWNER" || role === "ADMIN";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||||
|
return !!value && typeof value === "object" && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseNumber(value: string | null, fallback: number) {
|
||||||
|
if (value == null || value === "") return fallback;
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed) ? parsed : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
type MachineFkReference = {
|
||||||
|
tableName: string;
|
||||||
|
columnName: string;
|
||||||
|
deleteRule: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function quoteIdent(identifier: string) {
|
||||||
|
return `"${identifier.replace(/"/g, "\"\"")}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cleanupMachineReferences(machineId: string) {
|
||||||
|
const refs = await prisma.$queryRaw<MachineFkReference[]>`
|
||||||
|
SELECT DISTINCT
|
||||||
|
tc.table_name AS "tableName",
|
||||||
|
kcu.column_name AS "columnName",
|
||||||
|
rc.delete_rule AS "deleteRule"
|
||||||
|
FROM information_schema.table_constraints tc
|
||||||
|
JOIN information_schema.key_column_usage kcu
|
||||||
|
ON tc.constraint_name = kcu.constraint_name
|
||||||
|
AND tc.table_schema = kcu.table_schema
|
||||||
|
JOIN information_schema.referential_constraints rc
|
||||||
|
ON tc.constraint_name = rc.constraint_name
|
||||||
|
AND tc.table_schema = rc.constraint_schema
|
||||||
|
WHERE tc.constraint_type = 'FOREIGN KEY'
|
||||||
|
AND tc.table_schema = 'public'
|
||||||
|
AND rc.unique_constraint_schema = 'public'
|
||||||
|
AND rc.unique_constraint_name IN (
|
||||||
|
SELECT constraint_name
|
||||||
|
FROM information_schema.table_constraints
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
AND table_name = 'Machine'
|
||||||
|
AND constraint_type IN ('PRIMARY KEY', 'UNIQUE')
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
|
||||||
|
for (const ref of refs) {
|
||||||
|
if (ref.tableName === "Machine") continue;
|
||||||
|
const table = quoteIdent(ref.tableName);
|
||||||
|
const column = quoteIdent(ref.columnName);
|
||||||
|
const rule = String(ref.deleteRule ?? "").toUpperCase();
|
||||||
|
|
||||||
|
if (rule === "CASCADE") continue;
|
||||||
|
|
||||||
|
if (rule === "SET NULL") {
|
||||||
|
await prisma.$executeRawUnsafe(`UPDATE ${table} SET ${column} = NULL WHERE ${column} = $1`, machineId);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.$executeRawUnsafe(`DELETE FROM ${table} WHERE ${column} = $1`, machineId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (!machineIdSchema.safeParse(machineId).success) {
|
||||||
|
return NextResponse.json({ ok: false, error: "Invalid machine id" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(req.url);
|
||||||
|
const windowSec = Math.max(0, parseNumber(url.searchParams.get("windowSec"), 3600));
|
||||||
|
const eventsWindowSec = Math.max(0, parseNumber(url.searchParams.get("eventsWindowSec"), 21600));
|
||||||
|
const eventsMode = url.searchParams.get("events") ?? "critical";
|
||||||
|
const eventsOnly = url.searchParams.get("eventsOnly") === "1";
|
||||||
|
|
||||||
|
const [machineRow, orgSettings, machineSettings] = await Promise.all([
|
||||||
|
prisma.machine.findFirst({
|
||||||
|
where: { id: machineId, orgId: session.orgId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
code: true,
|
||||||
|
location: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
heartbeats: {
|
||||||
|
orderBy: { tsServer: "desc" },
|
||||||
|
take: 1,
|
||||||
|
select: { ts: true, tsServer: true, status: true, message: true, ip: true, fwVersion: true },
|
||||||
|
},
|
||||||
|
kpiSnapshots: {
|
||||||
|
orderBy: { ts: "desc" },
|
||||||
|
take: 1,
|
||||||
|
select: {
|
||||||
|
ts: true,
|
||||||
|
oee: true,
|
||||||
|
availability: true,
|
||||||
|
performance: true,
|
||||||
|
quality: true,
|
||||||
|
workOrderId: true,
|
||||||
|
sku: true,
|
||||||
|
good: true,
|
||||||
|
scrap: true,
|
||||||
|
target: true,
|
||||||
|
cycleTime: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.orgSettings.findUnique({
|
||||||
|
where: { orgId: session.orgId },
|
||||||
|
select: { stoppageMultiplier: true, macroStoppageMultiplier: true },
|
||||||
|
}),
|
||||||
|
prisma.machineSettings.findUnique({
|
||||||
|
where: { machineId },
|
||||||
|
select: { overridesJson: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!machineRow) {
|
||||||
|
return NextResponse.json({ ok: false, error: "Machine not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const overrides = isPlainObject(machineSettings?.overridesJson) ? machineSettings?.overridesJson : {};
|
||||||
|
const thresholdsOverride = isPlainObject(overrides.thresholds) ? overrides.thresholds : {};
|
||||||
|
const stoppageMultiplier =
|
||||||
|
typeof thresholdsOverride.stoppageMultiplier === "number"
|
||||||
|
? thresholdsOverride.stoppageMultiplier
|
||||||
|
: Number(orgSettings?.stoppageMultiplier ?? 1.5);
|
||||||
|
const macroStoppageMultiplier =
|
||||||
|
typeof thresholdsOverride.macroStoppageMultiplier === "number"
|
||||||
|
? thresholdsOverride.macroStoppageMultiplier
|
||||||
|
: Number(orgSettings?.macroStoppageMultiplier ?? 5);
|
||||||
|
|
||||||
|
const thresholds = {
|
||||||
|
stoppageMultiplier,
|
||||||
|
macroStoppageMultiplier,
|
||||||
|
};
|
||||||
|
|
||||||
|
const machine = {
|
||||||
|
...machineRow,
|
||||||
|
effectiveCycleTime: null,
|
||||||
|
latestHeartbeat: machineRow.heartbeats[0] ?? null,
|
||||||
|
latestKpi: machineRow.kpiSnapshots[0] ?? null,
|
||||||
|
heartbeats: undefined,
|
||||||
|
kpiSnapshots: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
const cycles = eventsOnly
|
||||||
|
? []
|
||||||
|
: await prisma.machineCycle.findMany({
|
||||||
|
where: {
|
||||||
|
orgId: session.orgId,
|
||||||
|
machineId,
|
||||||
|
ts: { gte: new Date(Date.now() - windowSec * 1000) },
|
||||||
|
},
|
||||||
|
orderBy: { ts: "asc" },
|
||||||
|
select: {
|
||||||
|
ts: true,
|
||||||
|
tsServer: true,
|
||||||
|
cycleCount: true,
|
||||||
|
actualCycleTime: true,
|
||||||
|
theoreticalCycleTime: true,
|
||||||
|
workOrderId: true,
|
||||||
|
sku: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const cyclesOut = cycles.map((row) => {
|
||||||
|
const ts = row.tsServer ?? row.ts;
|
||||||
|
return {
|
||||||
|
ts,
|
||||||
|
t: ts.getTime(),
|
||||||
|
cycleCount: row.cycleCount ?? null,
|
||||||
|
actual: row.actualCycleTime,
|
||||||
|
ideal: row.theoreticalCycleTime ?? null,
|
||||||
|
workOrderId: row.workOrderId ?? null,
|
||||||
|
sku: row.sku ?? null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const eventWindowStart = new Date(Date.now() - eventsWindowSec * 1000);
|
||||||
|
const criticalSeverities = ["critical", "error", "high"];
|
||||||
|
const eventWhereBase = {
|
||||||
|
orgId: session.orgId,
|
||||||
|
machineId,
|
||||||
|
ts: { gte: eventWindowStart },
|
||||||
|
};
|
||||||
|
|
||||||
|
const [rawEvents, eventsCountAll] = await Promise.all([
|
||||||
|
prisma.machineEvent.findMany({
|
||||||
|
where: eventWhereBase,
|
||||||
|
orderBy: { ts: "desc" },
|
||||||
|
take: eventsOnly ? 300 : 120,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
ts: true,
|
||||||
|
topic: true,
|
||||||
|
eventType: true,
|
||||||
|
severity: true,
|
||||||
|
title: true,
|
||||||
|
description: true,
|
||||||
|
requiresAck: true,
|
||||||
|
data: true,
|
||||||
|
workOrderId: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.machineEvent.count({ where: eventWhereBase }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const normalized = rawEvents.map((row) =>
|
||||||
|
normalizeEvent(row, { microMultiplier: stoppageMultiplier, macroMultiplier: macroStoppageMultiplier })
|
||||||
|
);
|
||||||
|
|
||||||
|
const allowed = normalized.filter((event) => ALLOWED_EVENT_TYPES.has(event.eventType));
|
||||||
|
const criticalEventTypes = new Set(["macrostop", "microstop", "slow-cycle", "offline", "error"]);
|
||||||
|
const filtered =
|
||||||
|
eventsMode === "critical"
|
||||||
|
? allowed.filter((event) => {
|
||||||
|
const severity = String(event.severity ?? "").toLowerCase();
|
||||||
|
return (
|
||||||
|
criticalEventTypes.has(event.eventType) ||
|
||||||
|
event.requiresAck === true ||
|
||||||
|
criticalSeverities.includes(severity)
|
||||||
|
);
|
||||||
|
})
|
||||||
|
: allowed;
|
||||||
|
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const deduped = filtered.filter((event) => {
|
||||||
|
const key = `${event.eventType}-${event.ts ?? ""}-${event.title}`;
|
||||||
|
if (seen.has(key)) return false;
|
||||||
|
seen.add(key);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
deduped.sort((a, b) => {
|
||||||
|
const at = a.ts ? a.ts.getTime() : 0;
|
||||||
|
const bt = b.ts ? b.ts.getTime() : 0;
|
||||||
|
return bt - at;
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
machine,
|
||||||
|
events: deduped,
|
||||||
|
eventsCountAll,
|
||||||
|
cycles: cyclesOut,
|
||||||
|
thresholds,
|
||||||
|
activeStoppage: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(_req: Request, { 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 (!machineIdSchema.safeParse(machineId).success) {
|
||||||
|
return NextResponse.json({ ok: false, error: "Invalid machine id" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const membership = await prisma.orgUser.findUnique({
|
||||||
|
where: {
|
||||||
|
orgId_userId: {
|
||||||
|
orgId: session.orgId,
|
||||||
|
userId: session.userId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: { role: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!canManageMachines(membership?.role)) {
|
||||||
|
return NextResponse.json({ ok: false, error: "Forbidden" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||||
|
try {
|
||||||
|
if (attempt === 0) {
|
||||||
|
// Revoke credentials first in a committed write so ingest auth fails immediately.
|
||||||
|
const revoked = await prisma.machine.updateMany({
|
||||||
|
where: {
|
||||||
|
id: machineId,
|
||||||
|
orgId: session.orgId,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
apiKey: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (revoked.count === 0) {
|
||||||
|
return NextResponse.json({ ok: false, error: "Machine not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
invalidateMachineAuth(machineId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Avoid long interactive transactions on very large history tables (P2028 timeout).
|
||||||
|
// This sequence is idempotent and safe to retry because apiKey is revoked first.
|
||||||
|
await prisma.machineCycle.deleteMany({
|
||||||
|
where: {
|
||||||
|
machineId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.machineHeartbeat.deleteMany({
|
||||||
|
where: {
|
||||||
|
machineId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.machineKpiSnapshot.deleteMany({
|
||||||
|
where: {
|
||||||
|
machineId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.machineEvent.deleteMany({
|
||||||
|
where: {
|
||||||
|
machineId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.machineWorkOrder.deleteMany({
|
||||||
|
where: {
|
||||||
|
machineId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.machineSettings.deleteMany({
|
||||||
|
where: {
|
||||||
|
machineId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.settingsAudit.deleteMany({
|
||||||
|
where: {
|
||||||
|
machineId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.alertNotification.deleteMany({
|
||||||
|
where: {
|
||||||
|
machineId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.machineFinancialOverride.deleteMany({
|
||||||
|
where: {
|
||||||
|
machineId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.reasonEntry.deleteMany({
|
||||||
|
where: {
|
||||||
|
machineId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.downtimeAction.updateMany({
|
||||||
|
where: {
|
||||||
|
machineId,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
machineId: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await prisma.machine.deleteMany({
|
||||||
|
where: {
|
||||||
|
id: machineId,
|
||||||
|
orgId: session.orgId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.count === 0) {
|
||||||
|
return NextResponse.json({ ok: false, error: "Machine not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
invalidateMachineAuth(machineId);
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const code = err instanceof Prisma.PrismaClientKnownRequestError ? err.code : undefined;
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
console.error("DELETE /api/machines/[machineId] failed", {
|
||||||
|
machineId,
|
||||||
|
orgId: session.orgId,
|
||||||
|
attempt,
|
||||||
|
code,
|
||||||
|
message,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (code === "P2003") {
|
||||||
|
if (attempt < 2) {
|
||||||
|
try {
|
||||||
|
await cleanupMachineReferences(machineId);
|
||||||
|
} catch (cleanupErr: unknown) {
|
||||||
|
const cleanupMessage = cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr);
|
||||||
|
console.error("DELETE /api/machines/[machineId] cleanup failed", {
|
||||||
|
machineId,
|
||||||
|
orgId: session.orgId,
|
||||||
|
attempt,
|
||||||
|
cleanupMessage,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, (attempt + 1) * 150));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
ok: false,
|
||||||
|
error: "Machine has dependent records and could not be removed",
|
||||||
|
code,
|
||||||
|
},
|
||||||
|
{ status: 409 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code === "P2022") {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
ok: false,
|
||||||
|
error: "Server schema is out of date for machine delete",
|
||||||
|
code,
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code === "P2028") {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
ok: false,
|
||||||
|
error: "Delete timed out while removing machine history",
|
||||||
|
code,
|
||||||
|
},
|
||||||
|
{ status: 503 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
ok: false,
|
||||||
|
error: "Delete failed due to database error",
|
||||||
|
code,
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: false, error: "Delete failed" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: false, error: "Delete failed", code: "DELETE_RETRY_EXHAUSTED" }, { status: 500 });
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import { requireSession } from "@/lib/auth/requireSession";
|
|||||||
import {
|
import {
|
||||||
fetchLatestHeartbeats,
|
fetchLatestHeartbeats,
|
||||||
fetchLatestKpis,
|
fetchLatestKpis,
|
||||||
|
fetchLatestMacrostops,
|
||||||
fetchMachineBase,
|
fetchMachineBase,
|
||||||
mergeMachineOverviewRows,
|
mergeMachineOverviewRows,
|
||||||
} from "@/lib/machines/withLatest";
|
} from "@/lib/machines/withLatest";
|
||||||
@@ -58,6 +59,10 @@ export async function GET(req: Request) {
|
|||||||
if (perfEnabled) timings.kpiQuery = elapsedMs(kpiStart);
|
if (perfEnabled) timings.kpiQuery = elapsedMs(kpiStart);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const macrostopStart = nowMs();
|
||||||
|
const macrostops = await fetchLatestMacrostops(session.orgId, machineIds);
|
||||||
|
if (perfEnabled) timings.macrostopsQuery = elapsedMs(macrostopStart);
|
||||||
|
|
||||||
const postQueryStart = nowMs();
|
const postQueryStart = nowMs();
|
||||||
|
|
||||||
// flatten latest heartbeat for UI convenience
|
// flatten latest heartbeat for UI convenience
|
||||||
@@ -65,6 +70,7 @@ export async function GET(req: Request) {
|
|||||||
machines,
|
machines,
|
||||||
heartbeats,
|
heartbeats,
|
||||||
kpis,
|
kpis,
|
||||||
|
macrostops,
|
||||||
includeKpi,
|
includeKpi,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
161
app/api/machines/route.ts.bak
Normal file
161
app/api/machines/route.ts.bak
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { randomBytes } from "crypto";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { generatePairingCode } from "@/lib/pairingCode";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { logLine } from "@/lib/logger";
|
||||||
|
import { elapsedMs, formatServerTiming, nowMs, PERF_LOGS_ENABLED } from "@/lib/perf/serverTiming";
|
||||||
|
import { requireSession } from "@/lib/auth/requireSession";
|
||||||
|
import {
|
||||||
|
fetchLatestHeartbeats,
|
||||||
|
fetchLatestKpis,
|
||||||
|
fetchMachineBase,
|
||||||
|
mergeMachineOverviewRows,
|
||||||
|
} from "@/lib/machines/withLatest";
|
||||||
|
|
||||||
|
let machinesColdStart = true;
|
||||||
|
|
||||||
|
function getColdStartInfo() {
|
||||||
|
const coldStart = machinesColdStart;
|
||||||
|
machinesColdStart = false;
|
||||||
|
return { coldStart, uptimeMs: Math.round(process.uptime() * 1000) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const createMachineSchema = z.object({
|
||||||
|
name: z.string().trim().min(1).max(80),
|
||||||
|
code: z.string().trim().max(40).optional(),
|
||||||
|
location: z.string().trim().max(80).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function GET(req: Request) {
|
||||||
|
const perfEnabled = PERF_LOGS_ENABLED;
|
||||||
|
const totalStart = nowMs();
|
||||||
|
const timings: Record<string, number> = {};
|
||||||
|
const { coldStart, uptimeMs } = getColdStartInfo();
|
||||||
|
const url = new URL(req.url);
|
||||||
|
const includeKpi = url.searchParams.get("includeKpi") === "1";
|
||||||
|
|
||||||
|
const authStart = nowMs();
|
||||||
|
const session = await requireSession();
|
||||||
|
if (perfEnabled) timings.auth = elapsedMs(authStart);
|
||||||
|
if (!session) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
|
const preQueryStart = nowMs();
|
||||||
|
const machinesStart = nowMs();
|
||||||
|
if (perfEnabled) timings.preQuery = elapsedMs(preQueryStart);
|
||||||
|
const machines = await fetchMachineBase(session.orgId);
|
||||||
|
if (perfEnabled) timings.machinesQuery = elapsedMs(machinesStart);
|
||||||
|
|
||||||
|
const heartbeatStart = nowMs();
|
||||||
|
const machineIds = machines.map((machine) => machine.id);
|
||||||
|
const heartbeats = await fetchLatestHeartbeats(session.orgId, machineIds);
|
||||||
|
if (perfEnabled) timings.heartbeatsQuery = elapsedMs(heartbeatStart);
|
||||||
|
|
||||||
|
let kpis: Awaited<ReturnType<typeof fetchLatestKpis>> = [];
|
||||||
|
if (includeKpi) {
|
||||||
|
const kpiStart = nowMs();
|
||||||
|
kpis = await fetchLatestKpis(session.orgId, machineIds);
|
||||||
|
if (perfEnabled) timings.kpiQuery = elapsedMs(kpiStart);
|
||||||
|
}
|
||||||
|
|
||||||
|
const postQueryStart = nowMs();
|
||||||
|
|
||||||
|
// flatten latest heartbeat for UI convenience
|
||||||
|
const out = mergeMachineOverviewRows({
|
||||||
|
machines,
|
||||||
|
heartbeats,
|
||||||
|
kpis,
|
||||||
|
includeKpi,
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = { ok: true, machines: out };
|
||||||
|
|
||||||
|
const responseHeaders = new Headers();
|
||||||
|
if (perfEnabled) {
|
||||||
|
timings.postQuery = elapsedMs(postQueryStart);
|
||||||
|
timings.total = elapsedMs(totalStart);
|
||||||
|
responseHeaders.set("Server-Timing", formatServerTiming(timings));
|
||||||
|
const payloadBytes = Buffer.byteLength(JSON.stringify(payload));
|
||||||
|
logLine("perf.machines.api", {
|
||||||
|
orgId: session.orgId,
|
||||||
|
coldStart,
|
||||||
|
uptimeMs,
|
||||||
|
timings,
|
||||||
|
counts: { machines: out.length },
|
||||||
|
payloadBytes,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(payload, { headers: responseHeaders });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const session = await requireSession();
|
||||||
|
if (!session) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
|
const body = await req.json().catch(() => ({}));
|
||||||
|
const parsed = createMachineSchema.safeParse(body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json({ ok: false, error: "Invalid machine payload" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = parsed.data.name;
|
||||||
|
const codeRaw = parsed.data.code ?? "";
|
||||||
|
const locationRaw = parsed.data.location ?? "";
|
||||||
|
|
||||||
|
const existing = await prisma.machine.findFirst({
|
||||||
|
where: { orgId: session.orgId, name },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
return NextResponse.json({ ok: false, error: "Machine name already exists" }, { status: 409 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiKey = randomBytes(24).toString("hex");
|
||||||
|
const pairingExpiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
let machine = null as null | {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
code?: string | null;
|
||||||
|
location?: string | null;
|
||||||
|
pairingCode?: string | null;
|
||||||
|
pairingCodeExpiresAt?: Date | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||||
|
const pairingCode = generatePairingCode();
|
||||||
|
try {
|
||||||
|
machine = await prisma.machine.create({
|
||||||
|
data: {
|
||||||
|
orgId: session.orgId,
|
||||||
|
name,
|
||||||
|
code: codeRaw || null,
|
||||||
|
location: locationRaw || null,
|
||||||
|
apiKey,
|
||||||
|
pairingCode,
|
||||||
|
pairingCodeExpiresAt: pairingExpiresAt,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
code: true,
|
||||||
|
location: true,
|
||||||
|
pairingCode: true,
|
||||||
|
pairingCodeExpiresAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const code = typeof err === "object" && err !== null ? (err as { code?: string }).code : undefined;
|
||||||
|
if (code !== "P2002") throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!machine?.pairingCode) {
|
||||||
|
return NextResponse.json({ ok: false, error: "Failed to generate pairing code" }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true, machine });
|
||||||
|
}
|
||||||
@@ -1,12 +1,8 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { requireSession } from "@/lib/auth/requireSession";
|
import { requireSession } from "@/lib/auth/requireSession";
|
||||||
import {
|
import { flattenReasonCatalog, normalizeReasonCatalog, type ReasonCatalogKind } from "@/lib/reasonCatalog";
|
||||||
flattenReasonCatalog,
|
import { effectiveReasonCatalogForOrg, loadReasonCatalogFromDb } from "@/lib/reasonCatalogDb";
|
||||||
loadFallbackReasonCatalog,
|
|
||||||
normalizeReasonCatalog,
|
|
||||||
type ReasonCatalogKind,
|
|
||||||
} from "@/lib/reasonCatalog";
|
|
||||||
|
|
||||||
function asKind(value: string | null): ReasonCatalogKind | null {
|
function asKind(value: string | null): ReasonCatalogKind | null {
|
||||||
const kind = String(value ?? "").toLowerCase();
|
const kind = String(value ?? "").toLowerCase();
|
||||||
@@ -26,20 +22,30 @@ export async function GET(req: Request) {
|
|||||||
|
|
||||||
const orgSettings = await prisma.orgSettings.findUnique({
|
const orgSettings = await prisma.orgSettings.findUnique({
|
||||||
where: { orgId: session.orgId },
|
where: { orgId: session.orgId },
|
||||||
select: { defaultsJson: true },
|
select: { defaultsJson: true, version: true },
|
||||||
});
|
});
|
||||||
const defaultsJson =
|
const version = orgSettings?.version ?? 1;
|
||||||
orgSettings?.defaultsJson && typeof orgSettings.defaultsJson === "object" && !Array.isArray(orgSettings.defaultsJson)
|
const defaultsJson = orgSettings?.defaultsJson ?? null;
|
||||||
? (orgSettings.defaultsJson as Record<string, unknown>)
|
|
||||||
|
const fromDb = await loadReasonCatalogFromDb(session.orgId, version);
|
||||||
|
const catalog = await effectiveReasonCatalogForOrg(session.orgId, defaultsJson, version);
|
||||||
|
|
||||||
|
const defs =
|
||||||
|
defaultsJson && typeof defaultsJson === "object" && !Array.isArray(defaultsJson)
|
||||||
|
? (defaultsJson as Record<string, unknown>)
|
||||||
: {};
|
: {};
|
||||||
const settingsCatalog = normalizeReasonCatalog(defaultsJson.reasonCatalog ?? defaultsJson.reasonCatalogData);
|
const legacyJson = normalizeReasonCatalog(defs.reasonCatalog ?? defs.reasonCatalogData);
|
||||||
const fallbackCatalog = await loadFallbackReasonCatalog();
|
|
||||||
const catalog = settingsCatalog ?? fallbackCatalog;
|
let source: "db" | "legacy" | "fallback";
|
||||||
const rows = flattenReasonCatalog(catalog, kind);
|
if (fromDb) source = "db";
|
||||||
|
else if (legacyJson) source = "legacy";
|
||||||
|
else source = "fallback";
|
||||||
|
|
||||||
|
const rows = flattenReasonCatalog(catalog, kind, { activeOnly: true });
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
ok: true,
|
ok: true,
|
||||||
source: settingsCatalog ? "settings" : "fallback",
|
source,
|
||||||
kind,
|
kind,
|
||||||
catalogVersion: catalog.version,
|
catalogVersion: catalog.version,
|
||||||
categories: catalog[kind],
|
categories: catalog[kind],
|
||||||
|
|||||||
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",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
29
app/api/recap/route.ts
Normal file
29
app/api/recap/route.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
import { requireSession } from "@/lib/auth/requireSession";
|
||||||
|
import { getRecapDataCached, parseRecapQuery } from "@/lib/recap/getRecapData";
|
||||||
|
|
||||||
|
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 query = parseRecapQuery({
|
||||||
|
machineId: url.searchParams.get("machineId"),
|
||||||
|
start: url.searchParams.get("start"),
|
||||||
|
end: url.searchParams.get("end"),
|
||||||
|
shift: url.searchParams.get("shift"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const recap = await getRecapDataCached({
|
||||||
|
orgId: session.orgId,
|
||||||
|
machineId: query.machineId,
|
||||||
|
start: query.start ?? undefined,
|
||||||
|
end: query.end ?? undefined,
|
||||||
|
shift: query.shift ?? undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(recap);
|
||||||
|
}
|
||||||
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",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
39
app/api/recap/timeline/route.ts
Normal file
39
app/api/recap/timeline/route.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
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) {
|
||||||
|
const session = await requireSession();
|
||||||
|
if (!session) return bad(401, "Unauthorized");
|
||||||
|
|
||||||
|
const url = new URL(req.url);
|
||||||
|
const machineId = url.searchParams.get("machineId");
|
||||||
|
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 { 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",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import type { NextRequest } from "next/server";
|
import type { NextRequest } from "next/server";
|
||||||
|
import { createHash } from "crypto";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { requireSession } from "@/lib/auth/requireSession";
|
import { requireSession } from "@/lib/auth/requireSession";
|
||||||
import { logLine } from "@/lib/logger";
|
import { logLine } from "@/lib/logger";
|
||||||
@@ -42,6 +43,10 @@ function pickRange(req: NextRequest) {
|
|||||||
return { start: new Date(now.getTime() - ms), end: now };
|
return { start: new Date(now.getTime() - ms), end: now };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toMs(value?: Date | null) {
|
||||||
|
return value ? value.getTime() : 0;
|
||||||
|
}
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
const perfEnabled = PERF_LOGS_ENABLED;
|
const perfEnabled = PERF_LOGS_ENABLED;
|
||||||
const totalStart = nowMs();
|
const totalStart = nowMs();
|
||||||
@@ -67,6 +72,32 @@ export async function GET(req: NextRequest) {
|
|||||||
|
|
||||||
if (perfEnabled) timings.preQuery = elapsedMs(preQueryStart);
|
if (perfEnabled) timings.preQuery = elapsedMs(preQueryStart);
|
||||||
|
|
||||||
|
const versionStart = nowMs();
|
||||||
|
const cycleMax = await prisma.machineCycle.aggregate({
|
||||||
|
where: baseWhere,
|
||||||
|
_max: { tsServer: true },
|
||||||
|
});
|
||||||
|
if (perfEnabled) timings.version = elapsedMs(versionStart);
|
||||||
|
|
||||||
|
const versionParts = [
|
||||||
|
session.orgId,
|
||||||
|
range,
|
||||||
|
machineId ?? "",
|
||||||
|
toMs(cycleMax._max.tsServer),
|
||||||
|
];
|
||||||
|
const etag = `W/"${createHash("sha1").update(versionParts.join("|")).digest("hex")}"`;
|
||||||
|
const responseHeaders = new Headers({
|
||||||
|
"Cache-Control": "private, no-cache, max-age=0, must-revalidate",
|
||||||
|
ETag: etag,
|
||||||
|
"Last-Modified": new Date(toMs(cycleMax._max.tsServer) || 0).toUTCString(),
|
||||||
|
Vary: "Cookie",
|
||||||
|
});
|
||||||
|
|
||||||
|
const ifNoneMatch = req.headers.get("if-none-match");
|
||||||
|
if (ifNoneMatch && ifNoneMatch === etag) {
|
||||||
|
return new NextResponse(null, { status: 304, headers: responseHeaders });
|
||||||
|
}
|
||||||
|
|
||||||
const workOrdersStart = nowMs();
|
const workOrdersStart = nowMs();
|
||||||
const workOrderRows = await prisma.machineCycle.findMany({
|
const workOrderRows = await prisma.machineCycle.findMany({
|
||||||
where: { ...baseWhere, workOrderId: { not: null } },
|
where: { ...baseWhere, workOrderId: { not: null } },
|
||||||
@@ -90,7 +121,6 @@ export async function GET(req: NextRequest) {
|
|||||||
|
|
||||||
const payload = { ok: true, workOrders, skus };
|
const payload = { ok: true, workOrders, skus };
|
||||||
|
|
||||||
const responseHeaders = new Headers();
|
|
||||||
if (perfEnabled) {
|
if (perfEnabled) {
|
||||||
timings.postQuery = elapsedMs(postQueryStart);
|
timings.postQuery = elapsedMs(postQueryStart);
|
||||||
timings.total = elapsedMs(totalStart);
|
timings.total = elapsedMs(totalStart);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import type { NextRequest } from "next/server";
|
import type { NextRequest } from "next/server";
|
||||||
|
import { createHash } from "crypto";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { requireSession } from "@/lib/auth/requireSession";
|
import { requireSession } from "@/lib/auth/requireSession";
|
||||||
import { logLine } from "@/lib/logger";
|
import { logLine } from "@/lib/logger";
|
||||||
@@ -46,6 +47,14 @@ function safeNum(v: unknown) {
|
|||||||
return typeof v === "number" && Number.isFinite(v) ? v : null;
|
return typeof v === "number" && Number.isFinite(v) ? v : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isProductionSnapshot(trackingEnabled: unknown, productionStarted: unknown) {
|
||||||
|
return trackingEnabled === true && productionStarted === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toMs(value?: Date | null) {
|
||||||
|
return value ? value.getTime() : 0;
|
||||||
|
}
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
const perfEnabled = PERF_LOGS_ENABLED;
|
const perfEnabled = PERF_LOGS_ENABLED;
|
||||||
const totalStart = nowMs();
|
const totalStart = nowMs();
|
||||||
@@ -73,6 +82,52 @@ export async function GET(req: NextRequest) {
|
|||||||
|
|
||||||
if (perfEnabled) timings.preQuery = elapsedMs(preQueryStart);
|
if (perfEnabled) timings.preQuery = elapsedMs(preQueryStart);
|
||||||
|
|
||||||
|
const versionStart = nowMs();
|
||||||
|
const [kpiMax, cycleMax, eventMax] = await Promise.all([
|
||||||
|
prisma.machineKpiSnapshot.aggregate({
|
||||||
|
where: { ...baseWhere, ts: { gte: start, lte: end } },
|
||||||
|
_max: { tsServer: true },
|
||||||
|
}),
|
||||||
|
prisma.machineCycle.aggregate({
|
||||||
|
where: { ...baseWhere, ts: { gte: start, lte: end } },
|
||||||
|
_max: { tsServer: true },
|
||||||
|
}),
|
||||||
|
prisma.machineEvent.aggregate({
|
||||||
|
where: { ...baseWhere, ts: { gte: start, lte: end } },
|
||||||
|
_max: { tsServer: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
if (perfEnabled) timings.version = elapsedMs(versionStart);
|
||||||
|
|
||||||
|
const lastModifiedMs = Math.max(
|
||||||
|
toMs(kpiMax._max.tsServer),
|
||||||
|
toMs(cycleMax._max.tsServer),
|
||||||
|
toMs(eventMax._max.tsServer)
|
||||||
|
);
|
||||||
|
|
||||||
|
const versionParts = [
|
||||||
|
session.orgId,
|
||||||
|
range,
|
||||||
|
machineId ?? "",
|
||||||
|
workOrderId ?? "",
|
||||||
|
sku ?? "",
|
||||||
|
toMs(kpiMax._max.tsServer),
|
||||||
|
toMs(cycleMax._max.tsServer),
|
||||||
|
toMs(eventMax._max.tsServer),
|
||||||
|
];
|
||||||
|
const etag = `W/"${createHash("sha1").update(versionParts.join("|")).digest("hex")}"`;
|
||||||
|
const responseHeaders = new Headers({
|
||||||
|
"Cache-Control": "private, no-cache, max-age=0, must-revalidate",
|
||||||
|
ETag: etag,
|
||||||
|
"Last-Modified": new Date(lastModifiedMs || 0).toUTCString(),
|
||||||
|
Vary: "Cookie",
|
||||||
|
});
|
||||||
|
|
||||||
|
const ifNoneMatch = req.headers.get("if-none-match");
|
||||||
|
if (ifNoneMatch && ifNoneMatch === etag) {
|
||||||
|
return new NextResponse(null, { status: 304, headers: responseHeaders });
|
||||||
|
}
|
||||||
|
|
||||||
const kpiStart = nowMs();
|
const kpiStart = nowMs();
|
||||||
const kpiRows = await prisma.machineKpiSnapshot.findMany({
|
const kpiRows = await prisma.machineKpiSnapshot.findMany({
|
||||||
where: { ...baseWhere, ts: { gte: start, lte: end } },
|
where: { ...baseWhere, ts: { gte: start, lte: end } },
|
||||||
@@ -86,6 +141,8 @@ export async function GET(req: NextRequest) {
|
|||||||
good: true,
|
good: true,
|
||||||
scrap: true,
|
scrap: true,
|
||||||
target: true,
|
target: true,
|
||||||
|
trackingEnabled: true,
|
||||||
|
productionStarted: true,
|
||||||
machineId: true,
|
machineId: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -100,7 +157,9 @@ export async function GET(req: NextRequest) {
|
|||||||
let qualSum = 0;
|
let qualSum = 0;
|
||||||
let qualCount = 0;
|
let qualCount = 0;
|
||||||
|
|
||||||
|
// OEE-family summaries are production-only to avoid mixing downtime/off windows.
|
||||||
for (const k of kpiRows) {
|
for (const k of kpiRows) {
|
||||||
|
if (!isProductionSnapshot(k.trackingEnabled, k.productionStarted)) continue;
|
||||||
if (safeNum(k.oee) != null) {
|
if (safeNum(k.oee) != null) {
|
||||||
oeeSum += Number(k.oee);
|
oeeSum += Number(k.oee);
|
||||||
oeeCount += 1;
|
oeeCount += 1;
|
||||||
@@ -223,7 +282,7 @@ export async function GET(req: NextRequest) {
|
|||||||
else if (type === "oee-drop") oeeDropCount += 1;
|
else if (type === "oee-drop") oeeDropCount += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
type TrendPoint = { t: string; v: number };
|
type TrendPoint = { t: string; v: number | null };
|
||||||
|
|
||||||
const trend: {
|
const trend: {
|
||||||
oee: TrendPoint[];
|
oee: TrendPoint[];
|
||||||
@@ -239,17 +298,68 @@ export async function GET(req: NextRequest) {
|
|||||||
scrapRate: [],
|
scrapRate: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type TsBucket = {
|
||||||
|
oeeSum: number; oeeCount: number;
|
||||||
|
availSum: number; availCount: number;
|
||||||
|
perfSum: number; perfCount: number;
|
||||||
|
qualSum: number; qualCount: number;
|
||||||
|
goodSum: number; scrapSum: number;
|
||||||
|
anyProduction: boolean;
|
||||||
|
};
|
||||||
|
const tsBuckets = new Map<string, TsBucket>();
|
||||||
|
|
||||||
for (const k of kpiRows) {
|
for (const k of kpiRows) {
|
||||||
const t = k.ts.toISOString();
|
const t = k.ts.toISOString();
|
||||||
if (safeNum(k.oee) != null) trend.oee.push({ t, v: Number(k.oee) });
|
let b = tsBuckets.get(t);
|
||||||
if (safeNum(k.availability) != null) trend.availability.push({ t, v: Number(k.availability) });
|
if (!b) {
|
||||||
if (safeNum(k.performance) != null) trend.performance.push({ t, v: Number(k.performance) });
|
b = {
|
||||||
if (safeNum(k.quality) != null) trend.quality.push({ t, v: Number(k.quality) });
|
oeeSum: 0, oeeCount: 0,
|
||||||
|
availSum: 0, availCount: 0,
|
||||||
|
perfSum: 0, perfCount: 0,
|
||||||
|
qualSum: 0, qualCount: 0,
|
||||||
|
goodSum: 0, scrapSum: 0,
|
||||||
|
anyProduction: false,
|
||||||
|
};
|
||||||
|
tsBuckets.set(t, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isProd = isProductionSnapshot(k.trackingEnabled, k.productionStarted);
|
||||||
|
if (isProd) {
|
||||||
|
b.anyProduction = true;
|
||||||
|
const oee = safeNum(k.oee);
|
||||||
|
if (oee != null) { b.oeeSum += Number(oee); b.oeeCount += 1; }
|
||||||
|
const avail = safeNum(k.availability);
|
||||||
|
if (avail != null) { b.availSum += Number(avail); b.availCount += 1; }
|
||||||
|
const perf = safeNum(k.performance);
|
||||||
|
if (perf != null) { b.perfSum += Number(perf); b.perfCount += 1; }
|
||||||
|
const qual = safeNum(k.quality);
|
||||||
|
if (qual != null) { b.qualSum += Number(qual); b.qualCount += 1; }
|
||||||
|
}
|
||||||
|
|
||||||
const good = safeNum(k.good);
|
const good = safeNum(k.good);
|
||||||
const scrap = safeNum(k.scrap);
|
const scrap = safeNum(k.scrap);
|
||||||
if (good != null && scrap != null && good + scrap > 0) {
|
if (good != null) b.goodSum += Number(good);
|
||||||
trend.scrapRate.push({ t, v: (scrap / (good + scrap)) * 100 });
|
if (scrap != null) b.scrapSum += Number(scrap);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Iterate sorted ts. kpiRows already orderBy ts asc, but Map insertion
|
||||||
|
// order matches that, so spreading keys preserves order.
|
||||||
|
for (const [t, b] of tsBuckets) {
|
||||||
|
if (!b.anyProduction) {
|
||||||
|
// No machine producing at this ts -> gap, same as before.
|
||||||
|
trend.oee.push({ t, v: null });
|
||||||
|
trend.availability.push({ t, v: null });
|
||||||
|
trend.performance.push({ t, v: null });
|
||||||
|
trend.quality.push({ t, v: null });
|
||||||
|
} else {
|
||||||
|
trend.oee.push({ t, v: b.oeeCount ? b.oeeSum / b.oeeCount : null });
|
||||||
|
trend.availability.push({ t, v: b.availCount ? b.availSum / b.availCount : null });
|
||||||
|
trend.performance.push({ t, v: b.perfCount ? b.perfSum / b.perfCount : null });
|
||||||
|
trend.quality.push({ t, v: b.qualCount ? b.qualSum / b.qualCount : null });
|
||||||
|
}
|
||||||
|
const total = b.goodSum + b.scrapSum;
|
||||||
|
if (total > 0) {
|
||||||
|
trend.scrapRate.push({ t, v: (b.scrapSum / total) * 100 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const cycleRowsStart = nowMs();
|
const cycleRowsStart = nowMs();
|
||||||
@@ -405,7 +515,6 @@ export async function GET(req: NextRequest) {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const responseHeaders = new Headers();
|
|
||||||
if (perfEnabled) {
|
if (perfEnabled) {
|
||||||
timings.postQuery = elapsedMs(postQueryStart);
|
timings.postQuery = elapsedMs(postQueryStart);
|
||||||
timings.total = elapsedMs(totalStart);
|
timings.total = elapsedMs(totalStart);
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
validateShiftOverrides,
|
validateShiftOverrides,
|
||||||
validateThresholds,
|
validateThresholds,
|
||||||
} from "@/lib/settings";
|
} from "@/lib/settings";
|
||||||
import { loadFallbackReasonCatalog, normalizeReasonCatalog, type ReasonCatalog } from "@/lib/reasonCatalog";
|
import { effectiveReasonCatalogForOrg } from "@/lib/reasonCatalogDb";
|
||||||
import { publishSettingsUpdate } from "@/lib/mqtt";
|
import { publishSettingsUpdate } from "@/lib/mqtt";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
@@ -46,21 +46,18 @@ function pickAllowedOverrides(raw: unknown) {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
function withReasonCatalog<T extends Record<string, unknown>>(payload: T, fallbackCatalog: ReasonCatalog) {
|
async function attachReasonCatalog(
|
||||||
const base = (isPlainObject(payload) ? { ...payload } : {}) as T;
|
orgId: string,
|
||||||
const defaults = isPlainObject(base.defaults) ? base.defaults : {};
|
defaultsJson: unknown,
|
||||||
const parsed =
|
settingsVersion: number,
|
||||||
normalizeReasonCatalog(base.reasonCatalog) ??
|
base: Record<string, unknown>
|
||||||
normalizeReasonCatalog(base.reasonCatalogData) ??
|
): Promise<Record<string, unknown>> {
|
||||||
normalizeReasonCatalog(defaults.reasonCatalog) ??
|
const catalog = await effectiveReasonCatalogForOrg(orgId, defaultsJson, settingsVersion);
|
||||||
normalizeReasonCatalog(defaults.reasonCatalogData) ??
|
|
||||||
fallbackCatalog;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...base,
|
...base,
|
||||||
reasonCatalog: parsed,
|
reasonCatalog: catalog,
|
||||||
reasonCatalogData: parsed,
|
reasonCatalogData: catalog,
|
||||||
reasonCatalogVersion: Number(parsed.version || 1),
|
reasonCatalogVersion: Number(catalog.version || 1),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,9 +161,7 @@ export async function GET(
|
|||||||
if (!machine) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
|
if (!machine) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
|
||||||
orgId = machine.orgId;
|
orgId = machine.orgId;
|
||||||
}
|
}
|
||||||
const fallbackCatalog = await loadFallbackReasonCatalog();
|
const { orgRow, shifts, rawOverrides } = await prisma.$transaction(async (tx) => {
|
||||||
|
|
||||||
const { settings, overrides } = await prisma.$transaction(async (tx) => {
|
|
||||||
const orgSettings = await ensureOrgSettings(tx, orgId as string, userId);
|
const orgSettings = await ensureOrgSettings(tx, orgId as string, userId);
|
||||||
if (!orgSettings?.settings) throw new Error("SETTINGS_NOT_FOUND");
|
if (!orgSettings?.settings) throw new Error("SETTINGS_NOT_FOUND");
|
||||||
|
|
||||||
@@ -175,25 +170,24 @@ export async function GET(
|
|||||||
select: { overridesJson: true },
|
select: { overridesJson: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
const orgPayload = withReasonCatalog(
|
|
||||||
buildSettingsPayload(orgSettings.settings, orgSettings.shifts ?? []),
|
|
||||||
fallbackCatalog
|
|
||||||
);
|
|
||||||
const rawOverrides = pickAllowedOverrides(machineSettings?.overridesJson ?? {});
|
const rawOverrides = pickAllowedOverrides(machineSettings?.overridesJson ?? {});
|
||||||
const effective = withReasonCatalog(
|
return {
|
||||||
deepMerge(orgPayload, rawOverrides) as Record<string, unknown>,
|
orgRow: orgSettings.settings,
|
||||||
fallbackCatalog
|
shifts: orgSettings.shifts ?? [],
|
||||||
);
|
rawOverrides,
|
||||||
|
};
|
||||||
return { settings: { org: orgPayload, effective }, overrides: rawOverrides };
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const baseOrg = buildSettingsPayload(orgRow, shifts) as Record<string, unknown>;
|
||||||
|
const orgPayload = await attachReasonCatalog(orgId as string, orgRow.defaultsJson, orgRow.version, baseOrg);
|
||||||
|
const effective = deepMerge(orgPayload, rawOverrides) as Record<string, unknown>;
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
ok: true,
|
ok: true,
|
||||||
machineId,
|
machineId,
|
||||||
orgSettings: settings.org,
|
orgSettings: orgPayload,
|
||||||
effectiveSettings: settings.effective,
|
effectiveSettings: effective,
|
||||||
overrides,
|
overrides: rawOverrides,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -413,25 +407,23 @@ export async function PUT(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const fallbackCatalog = await loadFallbackReasonCatalog();
|
|
||||||
const orgPayload = withReasonCatalog(
|
|
||||||
buildSettingsPayload(orgSettings.settings, orgSettings.shifts ?? []),
|
|
||||||
fallbackCatalog
|
|
||||||
);
|
|
||||||
const overrides = pickAllowedOverrides(saved.overridesJson ?? {});
|
|
||||||
const effective = withReasonCatalog(
|
|
||||||
deepMerge(orgPayload, overrides) as Record<string, unknown>,
|
|
||||||
fallbackCatalog
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
orgPayload,
|
orgSettingsRow: orgSettings.settings,
|
||||||
overrides,
|
shifts: orgSettings.shifts ?? [],
|
||||||
effective,
|
overrides: pickAllowedOverrides(saved.overridesJson ?? {}),
|
||||||
overridesUpdatedAt: saved.updatedAt,
|
overridesUpdatedAt: saved.updatedAt,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const baseOrg = buildSettingsPayload(result.orgSettingsRow, result.shifts) as Record<string, unknown>;
|
||||||
|
const orgPayload = await attachReasonCatalog(
|
||||||
|
session.orgId,
|
||||||
|
result.orgSettingsRow.defaultsJson,
|
||||||
|
result.orgSettingsRow.version,
|
||||||
|
baseOrg
|
||||||
|
);
|
||||||
|
const effective = deepMerge(orgPayload, result.overrides) as Record<string, unknown>;
|
||||||
|
|
||||||
const overridesUpdatedAt =
|
const overridesUpdatedAt =
|
||||||
result.overridesUpdatedAt && result.overridesUpdatedAt instanceof Date
|
result.overridesUpdatedAt && result.overridesUpdatedAt instanceof Date
|
||||||
? result.overridesUpdatedAt.toISOString()
|
? result.overridesUpdatedAt.toISOString()
|
||||||
@@ -440,7 +432,7 @@ export async function PUT(
|
|||||||
await publishSettingsUpdate({
|
await publishSettingsUpdate({
|
||||||
orgId: session.orgId,
|
orgId: session.orgId,
|
||||||
machineId,
|
machineId,
|
||||||
version: Number(result.orgPayload.version ?? 0),
|
version: Number(result.orgSettingsRow.version ?? 0),
|
||||||
source,
|
source,
|
||||||
overridesUpdatedAt,
|
overridesUpdatedAt,
|
||||||
});
|
});
|
||||||
@@ -451,8 +443,8 @@ export async function PUT(
|
|||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
ok: true,
|
ok: true,
|
||||||
machineId,
|
machineId,
|
||||||
orgSettings: result.orgPayload,
|
orgSettings: orgPayload,
|
||||||
effectiveSettings: result.effective,
|
effectiveSettings: effective,
|
||||||
overrides: result.overrides,
|
overrides: result.overrides,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
106
app/api/settings/reason-catalog/categories/[categoryId]/route.ts
Normal file
106
app/api/settings/reason-catalog/categories/[categoryId]/route.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { requireOrgAdminSession } from "@/lib/auth/requireOrgAdminSession";
|
||||||
|
import { bumpOrgSettingsVersion, composeReasonCode } from "@/lib/reasonCatalogDb";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const PREFIX_RE = /^[A-Za-z][A-Za-z0-9-]*$/;
|
||||||
|
|
||||||
|
const patchSchema = z.object({
|
||||||
|
name: z.string().trim().min(1).max(200).optional(),
|
||||||
|
codePrefix: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(1)
|
||||||
|
.max(32)
|
||||||
|
.transform((s) => s.toUpperCase())
|
||||||
|
.optional(),
|
||||||
|
sortOrder: z.number().int().optional(),
|
||||||
|
active: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function PATCH(
|
||||||
|
req: Request,
|
||||||
|
{ params }: { params: Promise<{ categoryId: string }> }
|
||||||
|
) {
|
||||||
|
const auth = await requireOrgAdminSession();
|
||||||
|
if (!auth.ok) return auth.response;
|
||||||
|
|
||||||
|
const { categoryId } = await params;
|
||||||
|
const parsed = patchSchema.safeParse(await req.json().catch(() => null));
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json({ ok: false, error: "Invalid body", issues: parsed.error.flatten() }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await prisma.reasonCatalogCategory.findFirst({
|
||||||
|
where: { id: categoryId, orgId: auth.session.orgId },
|
||||||
|
include: { items: true },
|
||||||
|
});
|
||||||
|
if (!existing) return NextResponse.json({ ok: false, error: "Not found" }, { status: 404 });
|
||||||
|
|
||||||
|
const nextPrefix = parsed.data.codePrefix ?? existing.codePrefix;
|
||||||
|
if (parsed.data.codePrefix !== undefined && !PREFIX_RE.test(nextPrefix)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ ok: false, error: "codePrefix must start with a letter; letters, digits, hyphen allowed." },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.data.codePrefix !== undefined && parsed.data.codePrefix !== existing.codePrefix) {
|
||||||
|
const proposed = new Set<string>();
|
||||||
|
for (const it of existing.items) {
|
||||||
|
proposed.add(composeReasonCode(nextPrefix, it.codeSuffix));
|
||||||
|
}
|
||||||
|
const codes = [...proposed];
|
||||||
|
const conflicts = await prisma.reasonCatalogItem.findMany({
|
||||||
|
where: {
|
||||||
|
orgId: auth.session.orgId,
|
||||||
|
reasonCode: { in: codes },
|
||||||
|
NOT: { categoryId: existing.id },
|
||||||
|
},
|
||||||
|
select: { reasonCode: true },
|
||||||
|
});
|
||||||
|
if (conflicts.length) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ ok: false, error: "Prefix change would duplicate codes", conflicts: conflicts.map((c) => c.reasonCode) },
|
||||||
|
{ status: 409 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
await tx.reasonCatalogCategory.update({
|
||||||
|
where: { id: categoryId },
|
||||||
|
data: {
|
||||||
|
...(parsed.data.name !== undefined ? { name: parsed.data.name } : {}),
|
||||||
|
...(parsed.data.codePrefix !== undefined ? { codePrefix: parsed.data.codePrefix } : {}),
|
||||||
|
...(parsed.data.sortOrder !== undefined ? { sortOrder: parsed.data.sortOrder } : {}),
|
||||||
|
...(parsed.data.active !== undefined ? { active: parsed.data.active } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (parsed.data.codePrefix !== undefined && parsed.data.codePrefix !== existing.codePrefix) {
|
||||||
|
for (const it of existing.items) {
|
||||||
|
const reasonCode = composeReasonCode(nextPrefix, it.codeSuffix);
|
||||||
|
await tx.reasonCatalogItem.update({
|
||||||
|
where: { id: it.id },
|
||||||
|
data: { reasonCode },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await bumpOrgSettingsVersion(tx, auth.session.orgId, auth.session.userId);
|
||||||
|
});
|
||||||
|
|
||||||
|
const updated = await prisma.reasonCatalogCategory.findUnique({
|
||||||
|
where: { id: categoryId },
|
||||||
|
include: { items: { orderBy: [{ sortOrder: "asc" }, { reasonCode: "asc" }] } },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true, category: updated });
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[reason-catalog category PATCH]", e);
|
||||||
|
return NextResponse.json({ ok: false, error: "Update failed" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
64
app/api/settings/reason-catalog/categories/route.ts
Normal file
64
app/api/settings/reason-catalog/categories/route.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { requireOrgAdminSession } from "@/lib/auth/requireOrgAdminSession";
|
||||||
|
import { bumpOrgSettingsVersion } from "@/lib/reasonCatalogDb";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const PREFIX_RE = /^[A-Za-z][A-Za-z0-9-]*$/;
|
||||||
|
|
||||||
|
const bodySchema = z.object({
|
||||||
|
kind: z.enum(["downtime", "scrap"]),
|
||||||
|
name: z.string().trim().min(1).max(200),
|
||||||
|
codePrefix: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(1)
|
||||||
|
.max(32)
|
||||||
|
.transform((s) => s.toUpperCase()),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const auth = await requireOrgAdminSession();
|
||||||
|
if (!auth.ok) return auth.response;
|
||||||
|
|
||||||
|
const parsed = bodySchema.safeParse(await req.json().catch(() => null));
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json({ ok: false, error: "Invalid body", issues: parsed.error.flatten() }, { status: 400 });
|
||||||
|
}
|
||||||
|
const { kind, name, codePrefix } = parsed.data;
|
||||||
|
if (!PREFIX_RE.test(codePrefix)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ ok: false, error: "codePrefix must start with a letter; letters, digits, hyphen allowed." },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const row = await prisma.$transaction(async (tx) => {
|
||||||
|
const last = await tx.reasonCatalogCategory.findFirst({
|
||||||
|
where: { orgId: auth.session.orgId, kind },
|
||||||
|
orderBy: { sortOrder: "desc" },
|
||||||
|
select: { sortOrder: true },
|
||||||
|
});
|
||||||
|
const sortOrder = (last?.sortOrder ?? -1) + 1;
|
||||||
|
|
||||||
|
const created = await tx.reasonCatalogCategory.create({
|
||||||
|
data: {
|
||||||
|
orgId: auth.session.orgId,
|
||||||
|
kind,
|
||||||
|
name,
|
||||||
|
codePrefix,
|
||||||
|
sortOrder,
|
||||||
|
active: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await bumpOrgSettingsVersion(tx, auth.session.orgId, auth.session.userId);
|
||||||
|
return created;
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true, category: row });
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[reason-catalog categories POST]", e);
|
||||||
|
return NextResponse.json({ ok: false, error: "Create failed" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
69
app/api/settings/reason-catalog/items/[itemId]/route.ts
Normal file
69
app/api/settings/reason-catalog/items/[itemId]/route.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { requireOrgAdminSession } from "@/lib/auth/requireOrgAdminSession";
|
||||||
|
import { bumpOrgSettingsVersion, composeReasonCode, isNumericSuffix } from "@/lib/reasonCatalogDb";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const patchSchema = z.object({
|
||||||
|
name: z.string().trim().min(1).max(500).optional(),
|
||||||
|
codeSuffix: z.string().trim().min(1).max(32).optional(),
|
||||||
|
sortOrder: z.number().int().optional(),
|
||||||
|
active: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function PATCH(
|
||||||
|
req: Request,
|
||||||
|
{ params }: { params: Promise<{ itemId: string }> }
|
||||||
|
) {
|
||||||
|
const auth = await requireOrgAdminSession();
|
||||||
|
if (!auth.ok) return auth.response;
|
||||||
|
|
||||||
|
const { itemId } = await params;
|
||||||
|
const parsed = patchSchema.safeParse(await req.json().catch(() => null));
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json({ ok: false, error: "Invalid body", issues: parsed.error.flatten() }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await prisma.reasonCatalogItem.findFirst({
|
||||||
|
where: { id: itemId, orgId: auth.session.orgId },
|
||||||
|
include: { category: true },
|
||||||
|
});
|
||||||
|
if (!existing) return NextResponse.json({ ok: false, error: "Not found" }, { status: 404 });
|
||||||
|
|
||||||
|
const nextSuffix = parsed.data.codeSuffix ?? existing.codeSuffix;
|
||||||
|
if (parsed.data.codeSuffix !== undefined && !isNumericSuffix(nextSuffix)) {
|
||||||
|
return NextResponse.json({ ok: false, error: "codeSuffix must be digits only" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const reasonCode = composeReasonCode(existing.category.codePrefix, nextSuffix);
|
||||||
|
if (reasonCode !== existing.reasonCode) {
|
||||||
|
const conflict = await prisma.reasonCatalogItem.findFirst({
|
||||||
|
where: { orgId: auth.session.orgId, reasonCode, NOT: { id: itemId } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (conflict) {
|
||||||
|
return NextResponse.json({ ok: false, error: "Duplicate reasonCode for this organization" }, { status: 409 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
await tx.reasonCatalogItem.update({
|
||||||
|
where: { id: itemId },
|
||||||
|
data: {
|
||||||
|
...(parsed.data.name !== undefined ? { name: parsed.data.name } : {}),
|
||||||
|
...(parsed.data.codeSuffix !== undefined ? { codeSuffix: nextSuffix, reasonCode } : {}),
|
||||||
|
...(parsed.data.sortOrder !== undefined ? { sortOrder: parsed.data.sortOrder } : {}),
|
||||||
|
...(parsed.data.active !== undefined ? { active: parsed.data.active } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await bumpOrgSettingsVersion(tx, auth.session.orgId, auth.session.userId);
|
||||||
|
});
|
||||||
|
|
||||||
|
const updated = await prisma.reasonCatalogItem.findUnique({ where: { id: itemId } });
|
||||||
|
return NextResponse.json({ ok: true, item: updated });
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[reason-catalog item PATCH]", e);
|
||||||
|
return NextResponse.json({ ok: false, error: "Update failed" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
71
app/api/settings/reason-catalog/items/route.ts
Normal file
71
app/api/settings/reason-catalog/items/route.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { requireOrgAdminSession } from "@/lib/auth/requireOrgAdminSession";
|
||||||
|
import { bumpOrgSettingsVersion, composeReasonCode, isNumericSuffix } from "@/lib/reasonCatalogDb";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const bodySchema = z.object({
|
||||||
|
categoryId: z.string().uuid(),
|
||||||
|
codeSuffix: z.string().trim().min(1).max(32),
|
||||||
|
name: z.string().trim().min(1).max(500),
|
||||||
|
sortOrder: z.number().int().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const auth = await requireOrgAdminSession();
|
||||||
|
if (!auth.ok) return auth.response;
|
||||||
|
|
||||||
|
const parsed = bodySchema.safeParse(await req.json().catch(() => null));
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json({ ok: false, error: "Invalid body", issues: parsed.error.flatten() }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { categoryId, codeSuffix, name, sortOrder } = parsed.data;
|
||||||
|
if (!isNumericSuffix(codeSuffix)) {
|
||||||
|
return NextResponse.json({ ok: false, error: "codeSuffix must be digits only" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const category = await prisma.reasonCatalogCategory.findFirst({
|
||||||
|
where: { id: categoryId, orgId: auth.session.orgId },
|
||||||
|
});
|
||||||
|
if (!category) return NextResponse.json({ ok: false, error: "Category not found" }, { status: 404 });
|
||||||
|
|
||||||
|
const reasonCode = composeReasonCode(category.codePrefix, codeSuffix);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const row = await prisma.$transaction(async (tx) => {
|
||||||
|
let nextOrder = sortOrder;
|
||||||
|
if (nextOrder === undefined) {
|
||||||
|
const last = await tx.reasonCatalogItem.findFirst({
|
||||||
|
where: { categoryId },
|
||||||
|
orderBy: { sortOrder: "desc" },
|
||||||
|
select: { sortOrder: true },
|
||||||
|
});
|
||||||
|
nextOrder = (last?.sortOrder ?? -1) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const created = await tx.reasonCatalogItem.create({
|
||||||
|
data: {
|
||||||
|
orgId: auth.session.orgId,
|
||||||
|
categoryId,
|
||||||
|
name,
|
||||||
|
codeSuffix,
|
||||||
|
reasonCode,
|
||||||
|
sortOrder: nextOrder,
|
||||||
|
active: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await bumpOrgSettingsVersion(tx, auth.session.orgId, auth.session.userId);
|
||||||
|
return created;
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true, item: row });
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const code = typeof e === "object" && e && "code" in e ? (e as { code: string }).code : "";
|
||||||
|
if (code === "P2002") {
|
||||||
|
return NextResponse.json({ ok: false, error: "Duplicate reasonCode for this organization" }, { status: 409 });
|
||||||
|
}
|
||||||
|
console.error("[reason-catalog items POST]", e);
|
||||||
|
return NextResponse.json({ ok: false, error: "Create failed" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
43
app/api/settings/reason-catalog/route.ts
Normal file
43
app/api/settings/reason-catalog/route.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { requireOrgAdminSession } from "@/lib/auth/requireOrgAdminSession";
|
||||||
|
|
||||||
|
/** Full tree for Control Tower (includes inactive rows). */
|
||||||
|
export async function GET() {
|
||||||
|
const auth = await requireOrgAdminSession();
|
||||||
|
if (!auth.ok) return auth.response;
|
||||||
|
|
||||||
|
const orgSettings = await prisma.orgSettings.findUnique({
|
||||||
|
where: { orgId: auth.session.orgId },
|
||||||
|
select: { version: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const categories = await prisma.reasonCatalogCategory.findMany({
|
||||||
|
where: { orgId: auth.session.orgId },
|
||||||
|
include: {
|
||||||
|
items: { orderBy: [{ sortOrder: "asc" }, { reasonCode: "asc" }] },
|
||||||
|
},
|
||||||
|
orderBy: [{ kind: "asc" }, { sortOrder: "asc" }, { name: "asc" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
catalogVersion: orgSettings?.version ?? 1,
|
||||||
|
categories: categories.map((c) => ({
|
||||||
|
id: c.id,
|
||||||
|
kind: c.kind,
|
||||||
|
name: c.name,
|
||||||
|
codePrefix: c.codePrefix,
|
||||||
|
sortOrder: c.sortOrder,
|
||||||
|
active: c.active,
|
||||||
|
items: c.items.map((it) => ({
|
||||||
|
id: it.id,
|
||||||
|
name: it.name,
|
||||||
|
codeSuffix: it.codeSuffix,
|
||||||
|
reasonCode: it.reasonCode,
|
||||||
|
sortOrder: it.sortOrder,
|
||||||
|
active: it.active,
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -19,7 +19,7 @@ import {
|
|||||||
validateShiftOverrides,
|
validateShiftOverrides,
|
||||||
validateThresholds,
|
validateThresholds,
|
||||||
} from "@/lib/settings";
|
} from "@/lib/settings";
|
||||||
import { loadFallbackReasonCatalog, normalizeReasonCatalog, type ReasonCatalog } from "@/lib/reasonCatalog";
|
import { effectiveReasonCatalogForOrg } from "@/lib/reasonCatalogDb";
|
||||||
import { publishSettingsUpdate } from "@/lib/mqtt";
|
import { publishSettingsUpdate } from "@/lib/mqtt";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
@@ -39,21 +39,18 @@ function canManageSettings(role?: string | null) {
|
|||||||
return role === "OWNER" || role === "ADMIN";
|
return role === "OWNER" || role === "ADMIN";
|
||||||
}
|
}
|
||||||
|
|
||||||
function withReasonCatalog<T extends Record<string, unknown>>(payload: T, fallbackCatalog: ReasonCatalog) {
|
async function attachReasonCatalog(
|
||||||
const base = (isPlainObject(payload) ? { ...payload } : {}) as T;
|
orgId: string,
|
||||||
const defaults = isPlainObject(base.defaults) ? base.defaults : {};
|
defaultsJson: unknown,
|
||||||
const parsed =
|
settingsVersion: number,
|
||||||
normalizeReasonCatalog(base.reasonCatalog) ??
|
base: Record<string, unknown>
|
||||||
normalizeReasonCatalog(base.reasonCatalogData) ??
|
): Promise<Record<string, unknown>> {
|
||||||
normalizeReasonCatalog(defaults.reasonCatalog) ??
|
const catalog = await effectiveReasonCatalogForOrg(orgId, defaultsJson, settingsVersion);
|
||||||
normalizeReasonCatalog(defaults.reasonCatalogData) ??
|
|
||||||
fallbackCatalog;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...base,
|
...base,
|
||||||
reasonCatalog: parsed,
|
reasonCatalog: catalog,
|
||||||
reasonCatalogData: parsed,
|
reasonCatalogData: catalog,
|
||||||
reasonCatalogVersion: Number(parsed.version || 1),
|
reasonCatalogVersion: Number(catalog.version || 1),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,7 +63,6 @@ const settingsPayloadSchema = z
|
|||||||
thresholds: z.any().optional(),
|
thresholds: z.any().optional(),
|
||||||
alerts: z.any().optional(),
|
alerts: z.any().optional(),
|
||||||
defaults: z.any().optional(),
|
defaults: z.any().optional(),
|
||||||
reasonCatalog: z.any().optional(),
|
|
||||||
version: z.union([z.number(), z.string()]).optional(),
|
version: z.union([z.number(), z.string()]).optional(),
|
||||||
})
|
})
|
||||||
.passthrough();
|
.passthrough();
|
||||||
@@ -145,8 +141,13 @@ async function loadSettingsPayload(orgId: string, userId: string) {
|
|||||||
return found;
|
return found;
|
||||||
});
|
});
|
||||||
|
|
||||||
const fallbackCatalog = await loadFallbackReasonCatalog();
|
const base = buildSettingsPayload(loaded.settings, loaded.shifts ?? []) as Record<string, unknown>;
|
||||||
const payload = withReasonCatalog(buildSettingsPayload(loaded.settings, loaded.shifts ?? []), fallbackCatalog);
|
const payload = await attachReasonCatalog(
|
||||||
|
orgId,
|
||||||
|
loaded.settings.defaultsJson,
|
||||||
|
loaded.settings.version,
|
||||||
|
base
|
||||||
|
);
|
||||||
const defaultsRaw = isPlainObject(loaded.settings.defaultsJson) ? (loaded.settings.defaultsJson as any) : {};
|
const defaultsRaw = isPlainObject(loaded.settings.defaultsJson) ? (loaded.settings.defaultsJson as any) : {};
|
||||||
const modulesRaw = isPlainObject(defaultsRaw.modules) ? defaultsRaw.modules : {};
|
const modulesRaw = isPlainObject(defaultsRaw.modules) ? defaultsRaw.modules : {};
|
||||||
const modules = { screenlessMode: modulesRaw.screenlessMode === true };
|
const modules = { screenlessMode: modulesRaw.screenlessMode === true };
|
||||||
@@ -221,7 +222,6 @@ export async function PUT(req: Request) {
|
|||||||
const thresholds = parsed.data.thresholds;
|
const thresholds = parsed.data.thresholds;
|
||||||
const alerts = parsed.data.alerts;
|
const alerts = parsed.data.alerts;
|
||||||
const defaults = parsed.data.defaults;
|
const defaults = parsed.data.defaults;
|
||||||
const reasonCatalogRaw = parsed.data.reasonCatalog;
|
|
||||||
const expectedVersion = parsed.data.version;
|
const expectedVersion = parsed.data.version;
|
||||||
const modules = parsed.data.modules;
|
const modules = parsed.data.modules;
|
||||||
|
|
||||||
@@ -233,7 +233,6 @@ export async function PUT(req: Request) {
|
|||||||
thresholds === undefined &&
|
thresholds === undefined &&
|
||||||
alerts === undefined &&
|
alerts === undefined &&
|
||||||
defaults === undefined &&
|
defaults === undefined &&
|
||||||
reasonCatalogRaw === undefined &&
|
|
||||||
modules === undefined
|
modules === undefined
|
||||||
|
|
||||||
) {
|
) {
|
||||||
@@ -252,13 +251,6 @@ export async function PUT(req: Request) {
|
|||||||
if (defaults !== undefined && !isPlainObject(defaults)) {
|
if (defaults !== undefined && !isPlainObject(defaults)) {
|
||||||
return NextResponse.json({ ok: false, error: "defaults must be an object" }, { status: 400 });
|
return NextResponse.json({ ok: false, error: "defaults must be an object" }, { status: 400 });
|
||||||
}
|
}
|
||||||
const nextReasonCatalog =
|
|
||||||
reasonCatalogRaw === undefined || reasonCatalogRaw === null
|
|
||||||
? reasonCatalogRaw
|
|
||||||
: normalizeReasonCatalog(reasonCatalogRaw);
|
|
||||||
if (reasonCatalogRaw !== undefined && reasonCatalogRaw !== null && !nextReasonCatalog) {
|
|
||||||
return NextResponse.json({ ok: false, error: "reasonCatalog must be a valid catalog payload" }, { status: 400 });
|
|
||||||
}
|
|
||||||
if (modules !== undefined && !isPlainObject(modules)) {
|
if (modules !== undefined && !isPlainObject(modules)) {
|
||||||
return NextResponse.json({ ok: false, error: "Invalid modules payload" }, { status: 400 });
|
return NextResponse.json({ ok: false, error: "Invalid modules payload" }, { status: 400 });
|
||||||
}
|
}
|
||||||
@@ -333,20 +325,16 @@ export async function PUT(req: Request) {
|
|||||||
: { ...currentModulesRaw, screenlessMode };
|
: { ...currentModulesRaw, screenlessMode };
|
||||||
|
|
||||||
// Write defaultsJson if either defaults changed OR modules changed
|
// Write defaultsJson if either defaults changed OR modules changed
|
||||||
const shouldWriteDefaultsJson =
|
const shouldWriteDefaultsJson = !!nextDefaultsCore || screenlessMode !== undefined;
|
||||||
!!nextDefaultsCore || screenlessMode !== undefined || reasonCatalogRaw !== undefined;
|
|
||||||
|
|
||||||
const nextDefaultsJson = shouldWriteDefaultsJson
|
const nextDefaultsJson = shouldWriteDefaultsJson
|
||||||
? { ...(nextDefaultsCore ?? normalizeDefaults(currentDefaultsRaw)), modules: nextModules }
|
? { ...(nextDefaultsCore ?? normalizeDefaults(currentDefaultsRaw)), modules: nextModules }
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
if (nextDefaultsJson && reasonCatalogRaw !== undefined) {
|
if (nextDefaultsJson) {
|
||||||
const defaultsTarget = nextDefaultsJson as Record<string, unknown>;
|
const defaultsTarget = nextDefaultsJson as Record<string, unknown>;
|
||||||
if (nextReasonCatalog === null) {
|
|
||||||
delete defaultsTarget.reasonCatalog;
|
delete defaultsTarget.reasonCatalog;
|
||||||
} else if (nextReasonCatalog) {
|
delete defaultsTarget.reasonCatalogData;
|
||||||
defaultsTarget.reasonCatalog = nextReasonCatalog;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -444,12 +432,18 @@ export async function PUT(req: Request) {
|
|||||||
return NextResponse.json({ ok: false, error: updated.error }, { status: 400 });
|
return NextResponse.json({ ok: false, error: updated.error }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = buildSettingsPayload(updated.settings, updated.shifts ?? []);
|
const baseOut = buildSettingsPayload(updated.settings, updated.shifts ?? []) as Record<string, unknown>;
|
||||||
|
const payload = await attachReasonCatalog(
|
||||||
|
session.orgId,
|
||||||
|
updated.settings.defaultsJson,
|
||||||
|
updated.settings.version,
|
||||||
|
baseOut
|
||||||
|
);
|
||||||
const updatedAt =
|
const updatedAt =
|
||||||
typeof payload.updatedAt === "string"
|
typeof payload.updatedAt === "string"
|
||||||
? payload.updatedAt
|
? payload.updatedAt
|
||||||
: payload.updatedAt
|
: payload.updatedAt
|
||||||
? payload.updatedAt.toISOString()
|
? (payload.updatedAt as Date).toISOString()
|
||||||
: undefined;
|
: undefined;
|
||||||
try {
|
try {
|
||||||
await publishSettingsUpdate({
|
await publishSettingsUpdate({
|
||||||
|
|||||||
@@ -43,6 +43,9 @@ export async function GET(
|
|||||||
sku: row.sku,
|
sku: row.sku,
|
||||||
targetQty: row.targetQty,
|
targetQty: row.targetQty,
|
||||||
cycleTime: row.cycleTime,
|
cycleTime: row.cycleTime,
|
||||||
|
mold: row.mold,
|
||||||
|
cavitiesTotal: row.cavitiesTotal,
|
||||||
|
cavitiesActive: row.cavitiesActive,
|
||||||
status: row.status,
|
status: row.status,
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,8 +12,10 @@ function canManage(role?: string | null) {
|
|||||||
const MAX_WORK_ORDERS = 2000;
|
const MAX_WORK_ORDERS = 2000;
|
||||||
const MAX_WORK_ORDER_ID_LENGTH = 64;
|
const MAX_WORK_ORDER_ID_LENGTH = 64;
|
||||||
const MAX_SKU_LENGTH = 64;
|
const MAX_SKU_LENGTH = 64;
|
||||||
|
const MAX_MOLD_LENGTH = 256;
|
||||||
const MAX_TARGET_QTY = 2_000_000_000;
|
const MAX_TARGET_QTY = 2_000_000_000;
|
||||||
const MAX_CYCLE_TIME = 86_400;
|
const MAX_CYCLE_TIME = 86_400;
|
||||||
|
const MAX_CAVITIES = 100_000;
|
||||||
const WORK_ORDER_ID_RE = /^[A-Za-z0-9._-]+$/;
|
const WORK_ORDER_ID_RE = /^[A-Za-z0-9._-]+$/;
|
||||||
|
|
||||||
const uploadBodySchema = z.object({
|
const uploadBodySchema = z.object({
|
||||||
@@ -51,6 +53,15 @@ type WorkOrderInput = {
|
|||||||
sku?: string | null;
|
sku?: string | null;
|
||||||
targetQty?: number | null;
|
targetQty?: number | null;
|
||||||
cycleTime?: number | null;
|
cycleTime?: number | null;
|
||||||
|
mold?: string | null;
|
||||||
|
cavitiesTotal?: number | null;
|
||||||
|
cavitiesActive?: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RowIssue = {
|
||||||
|
row: number;
|
||||||
|
workOrderId: string | null;
|
||||||
|
errors: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
function normalizeWorkOrders(raw: unknown[]) {
|
function normalizeWorkOrders(raw: unknown[]) {
|
||||||
@@ -78,17 +89,98 @@ function normalizeWorkOrders(raw: unknown[]) {
|
|||||||
const cycleTime =
|
const cycleTime =
|
||||||
cycleTimeRaw == null ? null : Math.min(Math.max(cycleTimeRaw, 0), MAX_CYCLE_TIME);
|
cycleTimeRaw == null ? null : Math.min(Math.max(cycleTimeRaw, 0), MAX_CYCLE_TIME);
|
||||||
|
|
||||||
|
const mold = cleanText(
|
||||||
|
record.mold ?? record.moldId ?? record.mold_id ?? null,
|
||||||
|
MAX_MOLD_LENGTH
|
||||||
|
);
|
||||||
|
const cavitiesTotalRaw = toIntOrNull(
|
||||||
|
record.cavitiesTotal ??
|
||||||
|
record.cavities_total ??
|
||||||
|
record.totalCavities ??
|
||||||
|
record.total_cavities
|
||||||
|
);
|
||||||
|
const cavitiesActiveRaw = toIntOrNull(
|
||||||
|
record.cavitiesActive ??
|
||||||
|
record.cavities_active ??
|
||||||
|
record.activeCavities ??
|
||||||
|
record.active_cavities
|
||||||
|
);
|
||||||
|
const cavitiesTotal =
|
||||||
|
cavitiesTotalRaw == null
|
||||||
|
? null
|
||||||
|
: Math.min(Math.max(cavitiesTotalRaw, 0), MAX_CAVITIES);
|
||||||
|
const cavitiesActive =
|
||||||
|
cavitiesActiveRaw == null
|
||||||
|
? null
|
||||||
|
: Math.min(Math.max(cavitiesActiveRaw, 0), MAX_CAVITIES);
|
||||||
|
|
||||||
cleaned.push({
|
cleaned.push({
|
||||||
workOrderId: idRaw,
|
workOrderId: idRaw,
|
||||||
sku: sku ?? null,
|
sku: sku ?? null,
|
||||||
targetQty: targetQty ?? null,
|
targetQty: targetQty ?? null,
|
||||||
cycleTime: cycleTime ?? null,
|
cycleTime: cycleTime ?? null,
|
||||||
|
mold: mold ?? null,
|
||||||
|
cavitiesTotal: cavitiesTotal ?? null,
|
||||||
|
cavitiesActive: cavitiesActive ?? null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return cleaned;
|
return cleaned;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ✨ NUEVO: validación estricta del Excel
|
||||||
|
// Cada fila debe tener mold (no vacío), cavitiesTotal (>=1), cavitiesActive (>=1, <=cavitiesTotal)
|
||||||
|
// Si UNA SOLA fila falla, se rechaza el archivo completo (Opción A)
|
||||||
|
function validateRows(rows: WorkOrderInput[], rawList: unknown[]): RowIssue[] {
|
||||||
|
const issues: RowIssue[] = [];
|
||||||
|
|
||||||
|
// Validar lista cruda primero (si hay duplicados o IDs inválidos no llegaron a `cleaned`)
|
||||||
|
// Pero aquí enfocamos en la validación de mold/cavidades sobre filas ya normalizadas.
|
||||||
|
rows.forEach((row, idx) => {
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
// Mold requerido
|
||||||
|
if (!row.mold || row.mold.length === 0) {
|
||||||
|
errors.push("Mold is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cavities Total requerido y >= 1
|
||||||
|
if (row.cavitiesTotal == null) {
|
||||||
|
errors.push("Total Cavities is required");
|
||||||
|
} else if (row.cavitiesTotal < 1) {
|
||||||
|
errors.push("Total Cavities must be at least 1");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cavities Active requerido y >= 1
|
||||||
|
if (row.cavitiesActive == null) {
|
||||||
|
errors.push("Active Cavities is required");
|
||||||
|
} else if (row.cavitiesActive < 1) {
|
||||||
|
errors.push("Active Cavities must be at least 1");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Active <= Total
|
||||||
|
if (
|
||||||
|
row.cavitiesActive != null &&
|
||||||
|
row.cavitiesTotal != null &&
|
||||||
|
row.cavitiesActive > row.cavitiesTotal
|
||||||
|
) {
|
||||||
|
errors.push(
|
||||||
|
`Active Cavities (${row.cavitiesActive}) cannot exceed Total Cavities (${row.cavitiesTotal})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errors.length > 0) {
|
||||||
|
issues.push({
|
||||||
|
row: idx + 1, // 1-indexed para el operador
|
||||||
|
workOrderId: row.workOrderId,
|
||||||
|
errors,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return issues;
|
||||||
|
}
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
const session = await requireSession();
|
const session = await requireSession();
|
||||||
if (!session) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
|
if (!session) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
|
||||||
@@ -138,6 +230,21 @@ export async function POST(req: NextRequest) {
|
|||||||
return NextResponse.json({ ok: false, error: "No valid work orders provided" }, { status: 400 });
|
return NextResponse.json({ ok: false, error: "No valid work orders provided" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ✨ NUEVO: validación estricta de mold/cavidades
|
||||||
|
// Si una sola fila falla, rechazamos el archivo completo
|
||||||
|
const issues = validateRows(cleaned, listRaw);
|
||||||
|
if (issues.length > 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
ok: false,
|
||||||
|
error: "Validation failed",
|
||||||
|
summary: `Excel rejected: ${issues.length} of ${cleaned.length} work order(s) have errors. All work orders must include mold name, total cavities, and active cavities. Fix and re-upload.`,
|
||||||
|
issues,
|
||||||
|
},
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const created = await prisma.machineWorkOrder.createMany({
|
const created = await prisma.machineWorkOrder.createMany({
|
||||||
data: cleaned.map((row) => ({
|
data: cleaned.map((row) => ({
|
||||||
orgId: session.orgId,
|
orgId: session.orgId,
|
||||||
@@ -146,6 +253,9 @@ export async function POST(req: NextRequest) {
|
|||||||
sku: row.sku ?? null,
|
sku: row.sku ?? null,
|
||||||
targetQty: row.targetQty ?? null,
|
targetQty: row.targetQty ?? null,
|
||||||
cycleTime: row.cycleTime ?? null,
|
cycleTime: row.cycleTime ?? null,
|
||||||
|
mold: row.mold ?? null,
|
||||||
|
cavitiesTotal: row.cavitiesTotal ?? null,
|
||||||
|
cavitiesActive: row.cavitiesActive ?? null,
|
||||||
status: "PENDING",
|
status: "PENDING",
|
||||||
})),
|
})),
|
||||||
skipDuplicates: true,
|
skipDuplicates: true,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
redirect("/machines");
|
redirect("/recap");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,14 @@ type ApiParetoRes = {
|
|||||||
total?: number;
|
total?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type LegacyParetoItem = {
|
||||||
|
reasonCode?: string;
|
||||||
|
reasonLabel?: string;
|
||||||
|
value?: number; // minutes (downtime) or qty (scrap)
|
||||||
|
count?: number;
|
||||||
|
cumPct?: number;
|
||||||
|
};
|
||||||
|
|
||||||
type ApiDowntimeEvent = {
|
type ApiDowntimeEvent = {
|
||||||
id: string;
|
id: string;
|
||||||
episodeId: string | null;
|
episodeId: string | null;
|
||||||
@@ -104,18 +112,139 @@ function fmtDT(iso: string | null) {
|
|||||||
return d.toLocaleString("en-US", { hour12: true });
|
return d.toLocaleString("en-US", { hour12: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeParetoRes(input: ApiParetoRes): ApiParetoRes {
|
||||||
|
const rows = Array.isArray(input?.rows) ? input.rows : [];
|
||||||
|
if (rows.length > 0) return input;
|
||||||
|
|
||||||
|
// Support a legacy envelope where the server returns `items[]` instead of `rows[]`.
|
||||||
|
const legacyItems = (input as any)?.items as unknown;
|
||||||
|
if (!Array.isArray(legacyItems) || legacyItems.length === 0) return input;
|
||||||
|
|
||||||
|
const items = legacyItems as LegacyParetoItem[];
|
||||||
|
const safeItems = items
|
||||||
|
.map((it) => ({
|
||||||
|
reasonCode: String(it?.reasonCode ?? "").trim(),
|
||||||
|
reasonLabel: String(it?.reasonLabel ?? it?.reasonCode ?? "").trim(),
|
||||||
|
value: typeof it?.value === "number" && Number.isFinite(it.value) ? it.value : 0,
|
||||||
|
count: typeof it?.count === "number" && Number.isFinite(it.count) ? it.count : 0,
|
||||||
|
}))
|
||||||
|
.filter((x) => x.reasonCode);
|
||||||
|
|
||||||
|
// Legacy `items` are usually pre-sorted by value desc; enforce it anyway.
|
||||||
|
safeItems.sort((a, b) => b.value - a.value);
|
||||||
|
|
||||||
|
const total = safeItems.reduce((acc, x) => acc + x.value, 0);
|
||||||
|
let cum = 0;
|
||||||
|
let threshold80Index: number | null = null;
|
||||||
|
|
||||||
|
const outRows: ApiParetoRow[] = safeItems.map((x, idx) => {
|
||||||
|
const pctOfTotal = total > 0 ? (x.value / total) * 100 : 0;
|
||||||
|
cum += x.value;
|
||||||
|
const cumulativePct = total > 0 ? (cum / total) * 100 : 0;
|
||||||
|
if (threshold80Index === null && cumulativePct >= 80) threshold80Index = idx;
|
||||||
|
|
||||||
|
return {
|
||||||
|
reasonCode: x.reasonCode,
|
||||||
|
reasonLabel: x.reasonLabel || x.reasonCode,
|
||||||
|
minutesLost: input.kind === "scrap" ? undefined : x.value,
|
||||||
|
scrapQty: input.kind === "scrap" ? x.value : undefined,
|
||||||
|
pctOfTotal,
|
||||||
|
cumulativePct,
|
||||||
|
count: x.count,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const threshold80 =
|
||||||
|
threshold80Index === null
|
||||||
|
? null
|
||||||
|
: {
|
||||||
|
index: threshold80Index,
|
||||||
|
reasonCode: outRows[threshold80Index].reasonCode,
|
||||||
|
reasonLabel: outRows[threshold80Index].reasonLabel,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
...input,
|
||||||
|
rows: outRows,
|
||||||
|
top3: outRows.slice(0, 3),
|
||||||
|
threshold80,
|
||||||
|
totalMinutesLost: input.kind === "scrap" ? undefined : total,
|
||||||
|
totalScrap: input.kind === "scrap" ? total : undefined,
|
||||||
|
total,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildParetoFromEvents(events: ApiDowntimeEvent[]): ApiParetoRes | null {
|
||||||
|
if (!Array.isArray(events) || events.length === 0) return null;
|
||||||
|
|
||||||
|
const byCode = new Map<
|
||||||
|
string,
|
||||||
|
{ reasonCode: string; reasonLabel: string; minutes: number; count: number }
|
||||||
|
>();
|
||||||
|
|
||||||
|
for (const e of events) {
|
||||||
|
const reasonCode = String(e?.reasonCode ?? "").trim();
|
||||||
|
if (!reasonCode) continue;
|
||||||
|
const reasonLabel = String(e?.reasonLabel ?? reasonCode).trim() || reasonCode;
|
||||||
|
const minutes =
|
||||||
|
(typeof e?.durationMinutes === "number" && Number.isFinite(e.durationMinutes)
|
||||||
|
? e.durationMinutes
|
||||||
|
: null) ??
|
||||||
|
(typeof e?.durationSeconds === "number" && Number.isFinite(e.durationSeconds)
|
||||||
|
? e.durationSeconds / 60
|
||||||
|
: 0);
|
||||||
|
|
||||||
|
const slot =
|
||||||
|
byCode.get(reasonCode) ?? { reasonCode, reasonLabel, minutes: 0, count: 0 };
|
||||||
|
slot.minutes += Math.max(0, minutes);
|
||||||
|
slot.count += 1;
|
||||||
|
// prefer the most recent non-empty label if they differ
|
||||||
|
if (reasonLabel && reasonLabel !== reasonCode) slot.reasonLabel = reasonLabel;
|
||||||
|
byCode.set(reasonCode, slot);
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = [...byCode.values()].filter((x) => x.minutes > 0 || x.count > 0);
|
||||||
|
items.sort((a, b) => b.minutes - a.minutes);
|
||||||
|
|
||||||
|
const totalMinutesLost = items.reduce((acc, x) => acc + x.minutes, 0);
|
||||||
|
let cum = 0;
|
||||||
|
let threshold80Index: number | null = null;
|
||||||
|
|
||||||
|
const rows: ApiParetoRow[] = items.map((x, idx) => {
|
||||||
|
const pctOfTotal = totalMinutesLost > 0 ? (x.minutes / totalMinutesLost) * 100 : 0;
|
||||||
|
cum += x.minutes;
|
||||||
|
const cumulativePct = totalMinutesLost > 0 ? (cum / totalMinutesLost) * 100 : 0;
|
||||||
|
if (threshold80Index === null && cumulativePct >= 80) threshold80Index = idx;
|
||||||
|
return {
|
||||||
|
reasonCode: x.reasonCode,
|
||||||
|
reasonLabel: x.reasonLabel,
|
||||||
|
minutesLost: Math.round(x.minutes * 10) / 10,
|
||||||
|
pctOfTotal,
|
||||||
|
cumulativePct,
|
||||||
|
count: x.count,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const threshold80 =
|
||||||
|
threshold80Index === null
|
||||||
|
? null
|
||||||
|
: {
|
||||||
|
index: threshold80Index,
|
||||||
|
reasonCode: rows[threshold80Index].reasonCode,
|
||||||
|
reasonLabel: rows[threshold80Index].reasonLabel,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
kind: "downtime",
|
||||||
|
totalMinutesLost: Math.round(totalMinutesLost * 10) / 10,
|
||||||
|
rows,
|
||||||
|
top3: rows.slice(0, 3),
|
||||||
|
threshold80,
|
||||||
|
total: totalMinutesLost,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
type ApiCoverageRes = {
|
|
||||||
ok: boolean;
|
|
||||||
error?: string;
|
|
||||||
orgId?: string;
|
|
||||||
machineId?: string | null;
|
|
||||||
range?: "24h" | "7d" | "30d";
|
|
||||||
start?: string;
|
|
||||||
receivedEpisodes?: number;
|
|
||||||
receivedMinutes?: number;
|
|
||||||
note?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type Range = "24h" | "7d" | "30d";
|
type Range = "24h" | "7d" | "30d";
|
||||||
type Metric = "minutes" | "count";
|
type Metric = "minutes" | "count";
|
||||||
@@ -1156,6 +1285,9 @@ export default function DowntimePageClient() {
|
|||||||
// client-only filters (shareable)
|
// client-only filters (shareable)
|
||||||
const metric = ((sp.get("metric") as Metric) || "minutes") as Metric;
|
const metric = ((sp.get("metric") as Metric) || "minutes") as Metric;
|
||||||
const reasonCode = sp.get("reasonCode") || null;
|
const reasonCode = sp.get("reasonCode") || null;
|
||||||
|
const shift = (sp.get("shift") || "all").toUpperCase();
|
||||||
|
const planned = (sp.get("planned") as "all" | "planned" | "unplanned") || "all";
|
||||||
|
const microstopLtMin = sp.get("microstopLtMin") || "2";
|
||||||
|
|
||||||
const hmDay = sp.get("hmDay");
|
const hmDay = sp.get("hmDay");
|
||||||
const hmHour = sp.get("hmHour");
|
const hmHour = sp.get("hmHour");
|
||||||
@@ -1167,7 +1299,6 @@ export default function DowntimePageClient() {
|
|||||||
|
|
||||||
|
|
||||||
const [pareto, setPareto] = useState<ApiParetoRes | null>(null);
|
const [pareto, setPareto] = useState<ApiParetoRes | null>(null);
|
||||||
const [coverage, setCoverage] = useState<ApiCoverageRes | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [err, setErr] = useState<string | null>(null);
|
const [err, setErr] = useState<string | null>(null);
|
||||||
const [eventsRes, setEventsRes] = useState<ApiDowntimeEventsRes | null>(null);
|
const [eventsRes, setEventsRes] = useState<ApiDowntimeEventsRes | null>(null);
|
||||||
@@ -1178,6 +1309,7 @@ export default function DowntimePageClient() {
|
|||||||
|
|
||||||
const [eventsLimit, setEventsLimit] = useState<number>(200);
|
const [eventsLimit, setEventsLimit] = useState<number>(200);
|
||||||
const [eventsBefore, setEventsBefore] = useState<string | null>(null);
|
const [eventsBefore, setEventsBefore] = useState<string | null>(null);
|
||||||
|
const debug = sp.get("debug") === "1";
|
||||||
|
|
||||||
// simple client filter (fast): text search on machine/reason/wo
|
// simple client filter (fast): text search on machine/reason/wo
|
||||||
const [eventSearch, setEventSearch] = useState("");
|
const [eventSearch, setEventSearch] = useState("");
|
||||||
@@ -1222,41 +1354,28 @@ export default function DowntimePageClient() {
|
|||||||
qs.set("kind", "downtime");
|
qs.set("kind", "downtime");
|
||||||
qs.set("range", range);
|
qs.set("range", range);
|
||||||
if (machineId) qs.set("machineId", machineId);
|
if (machineId) qs.set("machineId", machineId);
|
||||||
|
qs.set("shift", shift);
|
||||||
|
qs.set("planned", planned);
|
||||||
|
qs.set("microstopLtMin", microstopLtMin);
|
||||||
|
|
||||||
const [r1, r2] = await Promise.all([
|
const r1 = await fetch(`/api/analytics/pareto?${qs.toString()}`, {
|
||||||
fetch(`/api/analytics/pareto?${qs.toString()}`, {
|
|
||||||
cache: "no-cache",
|
cache: "no-cache",
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
signal: ac.signal,
|
signal: ac.signal,
|
||||||
}),
|
});
|
||||||
fetch(`/api/analytics/coverage?${qs.toString()}`, {
|
|
||||||
cache: "no-cache",
|
|
||||||
credentials: "include",
|
|
||||||
signal: ac.signal,
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const j1 = (await r1.json().catch(() => ({}))) as ApiParetoRes;
|
const j1raw = (await r1.json().catch(() => ({}))) as ApiParetoRes;
|
||||||
const j2 = (await r2.json().catch(() => ({}))) as ApiCoverageRes;
|
|
||||||
|
|
||||||
if (!alive) return;
|
if (!alive) return;
|
||||||
|
|
||||||
if (!r1.ok || j1.ok === false) {
|
if (!r1.ok || j1raw.ok === false) {
|
||||||
setErr(j1?.error ?? "Failed to load pareto");
|
setErr(j1raw?.error ?? "Failed to load pareto");
|
||||||
setPareto(null);
|
setPareto(null);
|
||||||
setCoverage(null);
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!r2.ok || j2.ok === false) {
|
setPareto(normalizeParetoRes(j1raw));
|
||||||
// coverage is “nice to have” — don’t kill the page
|
|
||||||
setCoverage(null);
|
|
||||||
} else {
|
|
||||||
setCoverage(j2);
|
|
||||||
}
|
|
||||||
|
|
||||||
setPareto(j1);
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (!alive) return;
|
if (!alive) return;
|
||||||
@@ -1270,7 +1389,7 @@ export default function DowntimePageClient() {
|
|||||||
alive = false;
|
alive = false;
|
||||||
ac.abort();
|
ac.abort();
|
||||||
};
|
};
|
||||||
}, [range, machineId]);
|
}, [range, machineId, shift, planned, microstopLtMin]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let alive = true;
|
let alive = true;
|
||||||
@@ -1320,6 +1439,9 @@ export default function DowntimePageClient() {
|
|||||||
qs.set("limit", String(eventsLimit));
|
qs.set("limit", String(eventsLimit));
|
||||||
if (machineId) qs.set("machineId", machineId);
|
if (machineId) qs.set("machineId", machineId);
|
||||||
if (reasonCode) qs.set("reasonCode", reasonCode);
|
if (reasonCode) qs.set("reasonCode", reasonCode);
|
||||||
|
qs.set("shift", shift);
|
||||||
|
qs.set("planned", planned);
|
||||||
|
qs.set("microstopLtMin", microstopLtMin);
|
||||||
if (eventsBefore) qs.set("before", eventsBefore);
|
if (eventsBefore) qs.set("before", eventsBefore);
|
||||||
|
|
||||||
const r = await fetch(`/api/analytics/downtime-events?${qs.toString()}`, {
|
const r = await fetch(`/api/analytics/downtime-events?${qs.toString()}`, {
|
||||||
@@ -1352,10 +1474,26 @@ export default function DowntimePageClient() {
|
|||||||
alive = false;
|
alive = false;
|
||||||
ac.abort();
|
ac.abort();
|
||||||
};
|
};
|
||||||
}, [range, machineId, reasonCode, eventsLimit, eventsBefore]);
|
}, [range, machineId, reasonCode, shift, planned, microstopLtMin, eventsLimit, eventsBefore]);
|
||||||
|
|
||||||
// Derived data
|
// Derived data
|
||||||
const baseRows = pareto?.rows ?? [];
|
const events = eventsRes?.events ?? [];
|
||||||
|
const paretoEffective = useMemo(() => {
|
||||||
|
const normalized = pareto ? normalizeParetoRes(pareto) : null;
|
||||||
|
if (normalized?.rows && normalized.rows.length > 0) return normalized;
|
||||||
|
const fromEvents = buildParetoFromEvents(events);
|
||||||
|
if (!fromEvents) return normalized;
|
||||||
|
return {
|
||||||
|
...fromEvents,
|
||||||
|
range: (eventsRes?.range as any) ?? normalized?.range,
|
||||||
|
start: eventsRes?.start ?? normalized?.start,
|
||||||
|
orgId: eventsRes?.orgId ?? normalized?.orgId,
|
||||||
|
machineId: eventsRes?.machineId ?? normalized?.machineId ?? null,
|
||||||
|
};
|
||||||
|
}, [pareto, events, eventsRes?.orgId, eventsRes?.machineId, eventsRes?.range, eventsRes?.start]);
|
||||||
|
const usingEventsFallback = (paretoEffective?.rows?.length ?? 0) > 0 && (pareto?.rows?.length ?? 0) === 0 && events.length > 0;
|
||||||
|
|
||||||
|
const baseRows = paretoEffective?.rows ?? [];
|
||||||
const metricRowsAll = useMemo(() => computeMetricRows(baseRows, metric), [baseRows, metric]);
|
const metricRowsAll = useMemo(() => computeMetricRows(baseRows, metric), [baseRows, metric]);
|
||||||
|
|
||||||
const metricRowsFiltered = useMemo(() => {
|
const metricRowsFiltered = useMemo(() => {
|
||||||
@@ -1386,7 +1524,7 @@ export default function DowntimePageClient() {
|
|||||||
}));
|
}));
|
||||||
}, [catalogRows]);
|
}, [catalogRows]);
|
||||||
|
|
||||||
const totalMinutes = pareto?.totalMinutesLost ?? 0;
|
const totalMinutes = paretoEffective?.totalMinutesLost ?? 0;
|
||||||
const totalStops = useMemo(
|
const totalStops = useMemo(
|
||||||
() => baseRows.reduce((acc, r) => acc + (r.count ?? 0), 0),
|
() => baseRows.reduce((acc, r) => acc + (r.count ?? 0), 0),
|
||||||
[baseRows]
|
[baseRows]
|
||||||
@@ -1401,10 +1539,10 @@ export default function DowntimePageClient() {
|
|||||||
|
|
||||||
const threshold80Index = useMemo(() => {
|
const threshold80Index = useMemo(() => {
|
||||||
// If API threshold80 exists, it’s based on minutes. For count metric, compute locally.
|
// If API threshold80 exists, it’s based on minutes. For count metric, compute locally.
|
||||||
if (metric === "minutes") return pareto?.threshold80?.index ?? null;
|
if (metric === "minutes") return paretoEffective?.threshold80?.index ?? null;
|
||||||
const idx = metricRowsAll.findIndex((r) => (r.cumulativePct ?? 0) >= 80);
|
const idx = metricRowsAll.findIndex((r) => (r.cumulativePct ?? 0) >= 80);
|
||||||
return idx >= 0 ? idx : null;
|
return idx >= 0 ? idx : null;
|
||||||
}, [metric, pareto?.threshold80?.index, metricRowsAll]);
|
}, [metric, paretoEffective?.threshold80?.index, metricRowsAll]);
|
||||||
|
|
||||||
const heroData = useMemo(() => {
|
const heroData = useMemo(() => {
|
||||||
// Keep hero readable: top 12 (like your screenshot)
|
// Keep hero readable: top 12 (like your screenshot)
|
||||||
@@ -1420,12 +1558,11 @@ export default function DowntimePageClient() {
|
|||||||
}));
|
}));
|
||||||
}, [metricRowsAll]);
|
}, [metricRowsAll]);
|
||||||
|
|
||||||
const totalDowntimeMin = pareto?.totalMinutesLost ?? 0;
|
const totalDowntimeMin = paretoEffective?.totalMinutesLost ?? 0;
|
||||||
const events = eventsRes?.events ?? [];
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setEventsBefore(null);
|
setEventsBefore(null);
|
||||||
}, [range, machineId, reasonCode]);
|
}, [range, machineId, reasonCode, shift, planned, microstopLtMin]);
|
||||||
|
|
||||||
const filteredEvents = useMemo(() => {
|
const filteredEvents = useMemo(() => {
|
||||||
let list = events;
|
let list = events;
|
||||||
@@ -1455,8 +1592,8 @@ const filteredEvents = useMemo(() => {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Use distinct episodes as "stops" (best available now)
|
// Use filtered pareto totals so top filters always affect the KPI.
|
||||||
const stops = coverage?.receivedEpisodes ?? totalStops;
|
const stops = totalStops;
|
||||||
|
|
||||||
// Window minutes for MTBF/Availability
|
// Window minutes for MTBF/Availability
|
||||||
const windowMin =
|
const windowMin =
|
||||||
@@ -1571,11 +1708,6 @@ const estImpactMxn = rate > 0 ? totalDowntimeMin * rate : 0;
|
|||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
const shift = sp.get("shift") || "all";
|
|
||||||
const planned = (sp.get("planned") as "all" | "planned" | "unplanned") || "all";
|
|
||||||
const microstopLtMin = sp.get("microstopLtMin") || "2";
|
|
||||||
|
|
||||||
|
|
||||||
const filtersRow = (
|
const filtersRow = (
|
||||||
<div className="mt-4 flex items-center justify-between gap-4">
|
<div className="mt-4 flex items-center justify-between gap-4">
|
||||||
{/* LEFT: range + metric + reset (never wrap) */}
|
{/* LEFT: range + metric + reset (never wrap) */}
|
||||||
@@ -1805,8 +1937,51 @@ const estImpactMxn = rate > 0 ? totalDowntimeMin * rate : 0;
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{debug ? (
|
||||||
|
<div className="mt-6 rounded-2xl border border-white/10 bg-black/30 p-4 text-xs text-zinc-300">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div className="font-semibold text-white">Debug</div>
|
||||||
|
<div className="text-[11px] text-zinc-500">
|
||||||
|
Disable with <span className="text-zinc-300">debug=0</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 grid grid-cols-1 gap-2 sm:grid-cols-2 xl:grid-cols-4">
|
||||||
|
<div className="rounded-xl border border-white/10 bg-white/5 p-3">
|
||||||
|
<div className="text-[11px] text-zinc-500">Status</div>
|
||||||
|
<div className="mt-1 text-zinc-200">
|
||||||
|
loading={String(loading)} · err={err ?? "null"} · eventsLoading={String(eventsLoading)} · eventsErr=
|
||||||
|
{eventsErr ?? "null"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-white/10 bg-white/5 p-3">
|
||||||
|
<div className="text-[11px] text-zinc-500">Filters</div>
|
||||||
|
<div className="mt-1 text-zinc-200">
|
||||||
|
range={range} · machineId={machineId ?? "null"} · reasonCode={reasonCode ?? "null"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-white/10 bg-white/5 p-3">
|
||||||
|
<div className="text-[11px] text-zinc-500">API payload sizes</div>
|
||||||
|
<div className="mt-1 text-zinc-200">
|
||||||
|
pareto.rows={(pareto?.rows?.length ?? 0)} · events={(eventsRes?.events?.length ?? 0)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-white/10 bg-white/5 p-3">
|
||||||
|
<div className="text-[11px] text-zinc-500">Effective (used by UI)</div>
|
||||||
|
<div className="mt-1 text-zinc-200">
|
||||||
|
rows={(paretoEffective?.rows?.length ?? 0)} · usingEventsFallback={String(usingEventsFallback)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{!loading && !err && (
|
{!loading && !err && (
|
||||||
<>
|
<>
|
||||||
|
{eventsErr ? (
|
||||||
|
<div className="mt-6 rounded-2xl border border-amber-500/20 bg-amber-500/10 p-4 text-sm text-amber-100">
|
||||||
|
Events list unavailable: {eventsErr}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
{/* KPI strip */}
|
{/* KPI strip */}
|
||||||
<div className="mt-6 grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-8">
|
<div className="mt-6 grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-8">
|
||||||
<KPI
|
<KPI
|
||||||
@@ -1818,7 +1993,7 @@ const estImpactMxn = rate > 0 ? totalDowntimeMin * rate : 0;
|
|||||||
<KPI
|
<KPI
|
||||||
label="Stops count"
|
label="Stops count"
|
||||||
value={fmtNum(stops, 0)}
|
value={fmtNum(stops, 0)}
|
||||||
sub="Distinct episodes (coverage)"
|
sub="Distinct episodes (filtered)"
|
||||||
accent="zinc"
|
accent="zinc"
|
||||||
/>
|
/>
|
||||||
<KPI
|
<KPI
|
||||||
@@ -2047,29 +2222,25 @@ const estImpactMxn = rate > 0 ? totalDowntimeMin * rate : 0;
|
|||||||
|
|
||||||
{/* Coverage mini */}
|
{/* Coverage mini */}
|
||||||
<div className="mt-4 rounded-2xl border border-white/10 bg-white/5 p-4">
|
<div className="mt-4 rounded-2xl border border-white/10 bg-white/5 p-4">
|
||||||
<div className="text-sm font-semibold text-white">Coverage received</div>
|
<div className="text-sm font-semibold text-white">Filtered downtime summary</div>
|
||||||
<div className="mt-1 text-xs text-zinc-400">
|
<div className="mt-1 text-xs text-zinc-400">
|
||||||
Sync health from Control Tower ingest
|
Reflects the active range/machine/shift/planned/microstop filters
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-3 grid grid-cols-2 gap-3">
|
<div className="mt-3 grid grid-cols-2 gap-3">
|
||||||
<div className="rounded-xl border border-white/10 bg-black/20 p-3">
|
<div className="rounded-xl border border-white/10 bg-black/20 p-3">
|
||||||
<div className="text-[11px] text-zinc-400">Episodes</div>
|
<div className="text-[11px] text-zinc-400">Episodes</div>
|
||||||
<div className="mt-1 text-base font-semibold text-white">
|
<div className="mt-1 text-base font-semibold text-white">
|
||||||
{coverage?.receivedEpisodes != null ? fmtNum(coverage.receivedEpisodes, 0) : "—"}
|
{fmtNum(stops, 0)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-xl border border-white/10 bg-black/20 p-3">
|
<div className="rounded-xl border border-white/10 bg-black/20 p-3">
|
||||||
<div className="text-[11px] text-zinc-400">Minutes</div>
|
<div className="text-[11px] text-zinc-400">Minutes</div>
|
||||||
<div className="mt-1 text-base font-semibold text-white">
|
<div className="mt-1 text-base font-semibold text-white">
|
||||||
{coverage?.receivedMinutes != null ? fmtNum(coverage.receivedMinutes, 1) : "—"}
|
{fmtNum(totalDowntimeMin, 1)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{coverage?.note ? (
|
|
||||||
<div className="mt-3 text-[11px] text-zinc-500">{coverage.note}</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,7 +3,18 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname, useRouter } from "next/navigation";
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
import { useEffect, useMemo, useState, useTransition } from "react";
|
import { useEffect, useMemo, useState, useTransition } from "react";
|
||||||
import { BarChart3, Bell, DollarSign, LayoutGrid, Loader2, LogOut, Settings, Wrench, X } from "lucide-react";
|
import {
|
||||||
|
BarChart3,
|
||||||
|
Bell,
|
||||||
|
DollarSign,
|
||||||
|
LayoutGrid,
|
||||||
|
Loader2,
|
||||||
|
LogOut,
|
||||||
|
Settings,
|
||||||
|
Sunrise,
|
||||||
|
Wrench,
|
||||||
|
X,
|
||||||
|
} from "lucide-react";
|
||||||
import type { LucideIcon } from "lucide-react";
|
import type { LucideIcon } from "lucide-react";
|
||||||
import { useI18n } from "@/lib/i18n/useI18n";
|
import { useI18n } from "@/lib/i18n/useI18n";
|
||||||
import { useScreenlessMode } from "@/lib/ui/screenlessMode";
|
import { useScreenlessMode } from "@/lib/ui/screenlessMode";
|
||||||
@@ -19,15 +30,15 @@ type NavItem = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const items: NavItem[] = [
|
const items: NavItem[] = [
|
||||||
|
{ href: "/recap", labelKey: "nav.recap", icon: Sunrise },
|
||||||
{ href: "/overview", labelKey: "nav.overview", icon: LayoutGrid },
|
{ href: "/overview", labelKey: "nav.overview", icon: LayoutGrid },
|
||||||
{ href: "/machines", labelKey: "nav.machines", icon: Wrench },
|
{ href: "/machines", labelKey: "nav.machines", icon: Wrench },
|
||||||
{ href: "/reports", labelKey: "nav.reports", icon: BarChart3 },
|
{ href: "/reports", labelKey: "nav.reports", icon: BarChart3 },
|
||||||
{ href: "/alerts", labelKey: "nav.alerts", icon: Bell },
|
{ href: "/alerts", labelKey: "nav.alerts", icon: Bell },
|
||||||
{ href: "/financial", labelKey: "nav.financial", icon: DollarSign, ownerOnly: true },
|
{ href: "/financial", labelKey: "nav.financial", icon: DollarSign, ownerOnly: true },
|
||||||
{ href: "/settings", labelKey: "nav.settings", icon: Settings },
|
|
||||||
{ href: "/downtime", labelKey: "nav.downtime", icon: BarChart3 },
|
{ href: "/downtime", labelKey: "nav.downtime", icon: BarChart3 },
|
||||||
|
|
||||||
];
|
];
|
||||||
|
const settingsItem: NavItem = { href: "/settings", labelKey: "nav.settings", icon: Settings };
|
||||||
|
|
||||||
type SidebarProps = {
|
type SidebarProps = {
|
||||||
variant?: "desktop" | "drawer";
|
variant?: "desktop" | "drawer";
|
||||||
@@ -97,16 +108,7 @@ export function Sidebar({ variant = "desktop", onNavigate, onClose }: SidebarPro
|
|||||||
}
|
}
|
||||||
}, [screenlessMode, pathname, router]);
|
}, [screenlessMode, pathname, router]);
|
||||||
|
|
||||||
useEffect(() => {
|
const markNavStart = (href: string, ts: number) => {
|
||||||
if (!pendingHref) return;
|
|
||||||
if (pathname === pendingHref || pathname.startsWith(`${pendingHref}/`)) {
|
|
||||||
setPendingHref(null);
|
|
||||||
} else if (!isPending) {
|
|
||||||
setPendingHref(null);
|
|
||||||
}
|
|
||||||
}, [pathname, pendingHref, isPending]);
|
|
||||||
|
|
||||||
const markNavStart = (href: string) => {
|
|
||||||
if (!PERF_ENABLED) return;
|
if (!PERF_ENABLED) return;
|
||||||
try {
|
try {
|
||||||
sessionStorage.setItem(
|
sessionStorage.setItem(
|
||||||
@@ -114,7 +116,7 @@ export function Sidebar({ variant = "desktop", onNavigate, onClose }: SidebarPro
|
|||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
href,
|
href,
|
||||||
from: pathname,
|
from: pathname,
|
||||||
ts: Date.now(),
|
ts,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -128,32 +130,12 @@ export function Sidebar({ variant = "desktop", onNavigate, onClose }: SidebarPro
|
|||||||
"relative z-20 flex flex-col border-r border-white/10 bg-black/40 shrink-0",
|
"relative z-20 flex flex-col border-r border-white/10 bg-black/40 shrink-0",
|
||||||
variant === "desktop" ? "hidden md:flex h-screen w-64" : "flex h-full w-72 max-w-[85vw]",
|
variant === "desktop" ? "hidden md:flex h-screen w-64" : "flex h-full w-72 max-w-[85vw]",
|
||||||
].join(" ");
|
].join(" ");
|
||||||
|
const navLocked = isPending;
|
||||||
|
|
||||||
return (
|
const renderNavItem = (it: NavItem) => {
|
||||||
<aside className={shellClass} aria-label={t("sidebar.productTitle")}>
|
|
||||||
<div className="px-5 py-4 flex items-center justify-between gap-3">
|
|
||||||
<div>
|
|
||||||
<div className="text-white font-semibold tracking-wide">{t("sidebar.productTitle")}</div>
|
|
||||||
<div className="text-xs text-zinc-500">{t("sidebar.productSubtitle")}</div>
|
|
||||||
</div>
|
|
||||||
{variant === "drawer" && onClose && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClose}
|
|
||||||
aria-label={t("common.close")}
|
|
||||||
className="rounded-lg border border-white/10 bg-white/5 p-2 text-zinc-300 hover:bg-white/10 hover:text-white md:hidden"
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<nav className="px-3 py-2 flex-1 space-y-1">
|
|
||||||
{visibleItems.map((it) => {
|
|
||||||
const isCurrent = pathname === it.href;
|
const isCurrent = pathname === it.href;
|
||||||
const active = isCurrent || pathname.startsWith(it.href + "/");
|
const active = isCurrent || pathname.startsWith(it.href + "/");
|
||||||
const isPendingItem = isPending && pendingHref === it.href;
|
const isPendingItem = isPending && pendingHref === it.href;
|
||||||
const navLocked = isPending;
|
|
||||||
const Icon = it.icon;
|
const Icon = it.icon;
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
@@ -178,7 +160,7 @@ export function Sidebar({ variant = "desktop", onNavigate, onClose }: SidebarPro
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
markNavStart(it.href);
|
markNavStart(it.href, Math.round(performance.timeOrigin + event.timeStamp));
|
||||||
setPendingHref(it.href);
|
setPendingHref(it.href);
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
router.push(it.href);
|
router.push(it.href);
|
||||||
@@ -199,7 +181,30 @@ export function Sidebar({ variant = "desktop", onNavigate, onClose }: SidebarPro
|
|||||||
{isPendingItem ? <Loader2 className="ml-auto h-4 w-4 animate-spin text-emerald-300" /> : null}
|
{isPendingItem ? <Loader2 className="ml-auto h-4 w-4 animate-spin text-emerald-300" /> : null}
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
})}
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className={shellClass} aria-label={t("sidebar.productTitle")}>
|
||||||
|
<div className="px-5 py-4 flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="text-white font-semibold tracking-wide">{t("sidebar.productTitle")}</div>
|
||||||
|
<div className="text-xs text-zinc-500">{t("sidebar.productSubtitle")}</div>
|
||||||
|
</div>
|
||||||
|
{variant === "drawer" && onClose && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label={t("common.close")}
|
||||||
|
className="rounded-lg border border-white/10 bg-white/5 p-2 text-zinc-300 hover:bg-white/10 hover:text-white md:hidden"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="px-3 py-2 flex-1 flex flex-col gap-2">
|
||||||
|
<div className="space-y-1">{visibleItems.map(renderNavItem)}</div>
|
||||||
|
<div className="mt-auto space-y-1 border-t border-white/10 pt-2">{renderNavItem(settingsItem)}</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="px-5 py-4 border-t border-white/10 space-y-3">
|
<div className="px-5 py-4 border-t border-white/10 space-y-3">
|
||||||
|
|||||||
46
components/recap/RecapBanners.tsx
Normal file
46
components/recap/RecapBanners.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
"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" })
|
||||||
|
: "--:--";
|
||||||
|
const showOffline = offlineForMin != null && offlineForMin > 10;
|
||||||
|
const hideMoldBecauseOffline = showOffline && moldChangeStartMs != null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{moldChangeStartMs && !hideMoldBecauseOffline ? (
|
||||||
|
<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}
|
||||||
|
|
||||||
|
{showOffline ? (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
components/recap/RecapDowntimeTop.tsx
Normal file
33
components/recap/RecapDowntimeTop.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useI18n } from "@/lib/i18n/useI18n";
|
||||||
|
import type { RecapDowntimeTopRow } from "@/lib/recap/types";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
rows: RecapDowntimeTopRow[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RecapDowntimeTop({ rows }: Props) {
|
||||||
|
const { t } = 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.downtime.top")}</div>
|
||||||
|
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
<div className="text-sm text-zinc-400">{t("recap.empty.production")}</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{rows.slice(0, 3).map((row) => (
|
||||||
|
<div key={row.reasonLabel} className="rounded-xl border border-white/10 bg-black/20 p-3">
|
||||||
|
<div className="text-sm font-medium text-white">{row.reasonLabel}</div>
|
||||||
|
<div className="mt-1 text-xs text-zinc-300">
|
||||||
|
{row.minutes.toFixed(1)} min · {row.percent.toFixed(1)}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
115
components/recap/RecapFullTimeline.tsx
Normal file
115
components/recap/RecapFullTimeline.tsx
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { RecapRangeMode, RecapTimelineSegment } from "@/lib/recap/types";
|
||||||
|
import {
|
||||||
|
computeWidths,
|
||||||
|
formatDuration,
|
||||||
|
formatTime,
|
||||||
|
LABEL_MIN_WIDTH_PCT,
|
||||||
|
normalizeTimelineSegments,
|
||||||
|
SEGMENT_MIN_WIDTH_PCT,
|
||||||
|
TIMELINE_COLORS,
|
||||||
|
} from "@/components/recap/timelineRender";
|
||||||
|
import { useI18n } from "@/lib/i18n/useI18n";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
rangeStart: string;
|
||||||
|
rangeEnd: string;
|
||||||
|
segments: RecapTimelineSegment[];
|
||||||
|
locale: string;
|
||||||
|
hasData?: boolean;
|
||||||
|
loading?: boolean;
|
||||||
|
rangeMode?: RecapRangeMode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RecapFullTimeline({
|
||||||
|
rangeStart,
|
||||||
|
rangeEnd,
|
||||||
|
segments,
|
||||||
|
locale,
|
||||||
|
hasData = false,
|
||||||
|
loading = false,
|
||||||
|
rangeMode,
|
||||||
|
}: 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 = hasData ? normalizeTimelineSegments(segments, startMs, endMs) : [];
|
||||||
|
const widths = computeWidths(normalized, totalMs, SEGMENT_MIN_WIDTH_PCT);
|
||||||
|
const rangeSuffix =
|
||||||
|
rangeMode === "shift"
|
||||||
|
? t("recap.range.shiftCurrent")
|
||||||
|
: rangeMode === "yesterday"
|
||||||
|
? t("recap.range.yesterday")
|
||||||
|
: rangeMode === "custom"
|
||||||
|
? t("recap.range.custom")
|
||||||
|
: t("recap.range.24h");
|
||||||
|
const titleText = `${t("recap.timeline.title")} · ${rangeSuffix}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border border-white/10 bg-black/40 p-4">
|
||||||
|
<div className="mb-3 text-sm font-semibold text-white">{titleText}</div>
|
||||||
|
{loading ? (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<div className="min-w-[560px]">
|
||||||
|
<div className="flex h-14 w-full animate-pulse overflow-hidden rounded-xl bg-white/5">
|
||||||
|
<div className="h-full w-[12%] bg-zinc-700/70" />
|
||||||
|
<div className="h-full w-[8%] bg-orange-500/60" />
|
||||||
|
<div className="h-full w-[14%] bg-zinc-700/70" />
|
||||||
|
<div className="h-full w-[7%] bg-red-500/60" />
|
||||||
|
<div className="h-full w-[59%] bg-zinc-700/70" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{!loading && !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}
|
||||||
|
{!loading && 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}` : ""}`;
|
||||||
|
|
||||||
|
const showLabel = widthPct > LABEL_MIN_WIDTH_PCT;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`${segment.type}:${segment.startMs}:${segment.endMs}:${segment.label}`}
|
||||||
|
className={`flex h-full items-center justify-center overflow-hidden text-xs font-semibold ${
|
||||||
|
showLabel ? "truncate px-2" : ""
|
||||||
|
} ${TIMELINE_COLORS[segment.type]} ${
|
||||||
|
index === 0 ? "rounded-l-xl" : ""
|
||||||
|
} ${index === normalized.length - 1 ? "rounded-r-xl" : ""}`}
|
||||||
|
style={{ width: `${Math.max(0, widthPct)}%` }}
|
||||||
|
title={title}
|
||||||
|
>
|
||||||
|
{showLabel ? segment.label : ""}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
50
components/recap/RecapKpiRow.tsx
Normal file
50
components/recap/RecapKpiRow.tsx
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useI18n } from "@/lib/i18n/useI18n";
|
||||||
|
import type { RecapRangeMode } from "@/lib/recap/types";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
oeeAvg: number | null;
|
||||||
|
goodParts: number;
|
||||||
|
totalStops: number;
|
||||||
|
scrapParts: number;
|
||||||
|
rangeMode?: RecapRangeMode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RecapKpiRow({ oeeAvg, goodParts, totalStops, scrapParts, rangeMode = "24h" }: Props) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const oeeLabel =
|
||||||
|
rangeMode === "shift"
|
||||||
|
? t("recap.kpi.oeeShift")
|
||||||
|
: rangeMode === "yesterday"
|
||||||
|
? t("recap.kpi.oeeYesterday")
|
||||||
|
: rangeMode === "custom"
|
||||||
|
? t("recap.kpi.oeeCustom")
|
||||||
|
: t("recap.kpi.oee24h");
|
||||||
|
|
||||||
|
const items = [
|
||||||
|
{ label: t("recap.kpi.good"), value: String(goodParts), valueClass: "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-300" : "text-white" },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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">{oeeLabel}</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) => (
|
||||||
|
<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="mt-1 text-xs uppercase tracking-wide text-zinc-400">{item.label}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
160
components/recap/RecapMachineCard.tsx
Normal file
160
components/recap/RecapMachineCard.tsx
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
"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",
|
||||||
|
idle: "bg-zinc-400",
|
||||||
|
};
|
||||||
|
|
||||||
|
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");
|
||||||
|
if (status === "idle") return t("recap.status.idle");
|
||||||
|
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 zeroActivity = machine.goodParts === 0 && machine.scrap === 0 && machine.stopsCount === 0;
|
||||||
|
const primaryMetric = machine.oee == null ? "—" : `${machine.oee.toFixed(1)}%`;
|
||||||
|
const ongoingStopMin = machine.ongoingStopMin ?? 0;
|
||||||
|
const isUrgent = machine.status === "stopped" && ongoingStopMin >= 5;
|
||||||
|
const isCalm = machine.status === "idle";
|
||||||
|
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 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(() => {
|
||||||
|
let alive = true;
|
||||||
|
|
||||||
|
async function loadTimeline() {
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/recap/${machine.machineId}/timeline?range=24h`,
|
||||||
|
{ 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 p-4 transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-300/80 ${
|
||||||
|
isUrgent
|
||||||
|
? "border-red-500/60 bg-red-500/10 hover:bg-red-500/15 ring-2 ring-red-500/40 animate-pulse"
|
||||||
|
: isCalm
|
||||||
|
? "border-white/5 bg-white/[0.02] hover:bg-white/[0.04] opacity-70"
|
||||||
|
: "border-white/10 bg-white/5 hover:bg-white/10"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<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}
|
||||||
|
|
||||||
|
<div className={`mt-3 text-xs ${isUrgent ? "text-red-200 font-semibold" : isCalm ? "text-zinc-500" : "text-zinc-400"}`}>
|
||||||
|
{isUrgent
|
||||||
|
? t("recap.card.stoppedFor", { min: ongoingStopMin })
|
||||||
|
+ (machine.activeWorkOrderId ? ` · WO ${machine.activeWorkOrderId}` : "")
|
||||||
|
: machine.status === "idle"
|
||||||
|
? t("recap.card.idle")
|
||||||
|
: footerText}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
142
components/recap/RecapMachineCard.tsx.bak
Normal file
142
components/recap/RecapMachineCard.tsx.bak
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
"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 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 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(() => {
|
||||||
|
let alive = true;
|
||||||
|
|
||||||
|
async function loadTimeline() {
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/recap/${machine.machineId}/timeline?range=24h&compact=1&maxSegments=60`,
|
||||||
|
{ 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}
|
||||||
|
|
||||||
|
<div className="mt-3 text-xs text-zinc-400">{footerText}</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
153
components/recap/RecapMachineCard.tsx.bak.step5
Normal file
153
components/recap/RecapMachineCard.tsx.bak.step5
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
"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 zeroActivity = machine.goodParts === 0 && machine.scrap === 0 && machine.stopsCount === 0;
|
||||||
|
const primaryMetric = machine.oee == null ? "—" : `${machine.oee.toFixed(1)}%`;
|
||||||
|
const ongoingStopMin = machine.ongoingStopMin ?? 0;
|
||||||
|
const isUrgent = machine.status === "stopped" && ongoingStopMin >= 5;
|
||||||
|
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 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(() => {
|
||||||
|
let alive = true;
|
||||||
|
|
||||||
|
async function loadTimeline() {
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/recap/${machine.machineId}/timeline?range=24h&compact=1&maxSegments=60`,
|
||||||
|
{ 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 p-4 transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-300/80 ${
|
||||||
|
isUrgent
|
||||||
|
? "border-red-500/60 bg-red-500/10 hover:bg-red-500/15 ring-2 ring-red-500/40 animate-pulse"
|
||||||
|
: "border-white/10 bg-white/5 hover:bg-white/10"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<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}
|
||||||
|
|
||||||
|
<div className={`mt-3 text-xs ${isUrgent ? "text-red-200 font-semibold" : "text-zinc-400"}`}>
|
||||||
|
{isUrgent
|
||||||
|
? t("recap.card.stoppedFor", { min: ongoingStopMin })
|
||||||
|
+ (machine.activeWorkOrderId ? ` · WO ${machine.activeWorkOrderId}` : "")
|
||||||
|
: footerText}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
34
components/recap/RecapMachineStatus.tsx
Normal file
34
components/recap/RecapMachineStatus.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useI18n } from "@/lib/i18n/useI18n";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
heartbeat: {
|
||||||
|
lastSeenAt: string | null;
|
||||||
|
uptimePct: number | null;
|
||||||
|
connectionStatus: "online" | "offline";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RecapMachineStatus({ heartbeat }: 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.machine.title")}</div>
|
||||||
|
<ul className="space-y-2 text-sm text-zinc-200">
|
||||||
|
<li>
|
||||||
|
<span className={heartbeat.connectionStatus === "online" ? "text-emerald-300" : "text-red-300"}>
|
||||||
|
{heartbeat.connectionStatus === "online" ? t("recap.machine.online") : t("recap.machine.offline")}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<li className="text-zinc-400">
|
||||||
|
{t("recap.machine.lastHeartbeat")}: {heartbeat.lastSeenAt ? new Date(heartbeat.lastSeenAt).toLocaleString(locale) : "--"}
|
||||||
|
</li>
|
||||||
|
<li className="text-zinc-400">
|
||||||
|
{t("recap.machine.uptime")}: {heartbeat.uptimePct == null ? "--" : `${heartbeat.uptimePct.toFixed(1)}%`}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</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 = 0.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>
|
||||||
|
);
|
||||||
|
}
|
||||||
43
components/recap/RecapProductionBySku.tsx
Normal file
43
components/recap/RecapProductionBySku.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useI18n } from "@/lib/i18n/useI18n";
|
||||||
|
import type { RecapSkuRow } from "@/lib/recap/types";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
rows: RecapSkuRow[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RecapProductionBySku({ rows }: Props) {
|
||||||
|
const { t } = 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.production.bySku")}</div>
|
||||||
|
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
<div className="text-sm text-zinc-400">{t("recap.empty.production")}</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-full text-sm text-zinc-200">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-white/10 text-left text-xs uppercase tracking-wide text-zinc-400">
|
||||||
|
<th className="py-2 pr-3">{t("recap.production.sku")}</th>
|
||||||
|
<th className="py-2 pr-3">{t("recap.production.good")}</th>
|
||||||
|
<th className="py-2">{t("recap.production.scrap")}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.slice(0, 10).map((row) => (
|
||||||
|
<tr key={`${row.sku}:${row.machineName}`} className="border-b border-white/5">
|
||||||
|
<td className="py-2 pr-3">{row.sku}</td>
|
||||||
|
<td className="py-2 pr-3">{row.good}</td>
|
||||||
|
<td className={`py-2 ${row.scrap > 0 ? "text-red-300" : ""}`}>{row.scrap}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
173
components/recap/RecapTimeline.tsx
Normal file
173
components/recap/RecapTimeline.tsx
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { RecapTimelineSegment } from "@/lib/recap/types";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
rangeStart: string;
|
||||||
|
rangeEnd: string;
|
||||||
|
segments: RecapTimelineSegment[];
|
||||||
|
locale: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const 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-amber-500 text-black",
|
||||||
|
idle: "bg-zinc-600 text-zinc-300",
|
||||||
|
};
|
||||||
|
const MIN_SEGMENT_PCT = 0.3;
|
||||||
|
const LABEL_MIN_PCT = 5;
|
||||||
|
|
||||||
|
function fmtTime(valueMs: number, locale: string) {
|
||||||
|
return new Date(valueMs).toLocaleTimeString(locale, { hour: "2-digit", minute: "2-digit" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDuration(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`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
const startMs = new Date(rangeStart).getTime();
|
||||||
|
const endMs = new Date(rangeEnd).getTime();
|
||||||
|
const totalMs = Math.max(1, endMs - startMs);
|
||||||
|
const normalized = normalizeForRender(segments, startMs, endMs);
|
||||||
|
const widths = computeWidths(normalized, totalMs, MIN_SEGMENT_PCT);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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="flex h-14 w-full overflow-hidden rounded-xl border border-white/10">
|
||||||
|
{normalized.map((segment, index) => {
|
||||||
|
const widthPct = widths[index] ?? 0;
|
||||||
|
const title = `${segment.type} · ${fmtTime(segment.startMs, locale)}-${fmtTime(segment.endMs, locale)} · ${fmtDuration(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 ${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_PCT ? segment.label : ""}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
61
components/recap/RecapWorkOrderStatus.tsx
Normal file
61
components/recap/RecapWorkOrderStatus.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useI18n } from "@/lib/i18n/useI18n";
|
||||||
|
import { formatRecapProgressPercent, progressBarWidthPercent } from "@/lib/recap/progressDisplay";
|
||||||
|
import type { RecapMachine } from "@/lib/recap/types";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
workOrders: RecapMachine["workOrders"];
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RecapWorkOrderStatus({ 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="mb-3">
|
||||||
|
<div className="text-xs uppercase tracking-wide text-zinc-400">{t("recap.workOrders.active")}</div>
|
||||||
|
{!workOrders.active ? (
|
||||||
|
<div className="mt-1 text-sm text-zinc-400">{t("recap.workOrders.none")}</div>
|
||||||
|
) : (
|
||||||
|
<div className="mt-2 rounded-xl border border-white/10 bg-black/30 p-3 text-sm text-zinc-200">
|
||||||
|
<div className="font-medium text-white">{workOrders.active.id}</div>
|
||||||
|
<div className="text-zinc-400">SKU: {workOrders.active.sku || "--"}</div>
|
||||||
|
<div className="mt-1 text-xs text-zinc-300">
|
||||||
|
{t("recap.production.progress")}: {formatRecapProgressPercent(workOrders.active.progressPct, locale)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 h-2 rounded-full bg-white/10">
|
||||||
|
<div
|
||||||
|
className="h-2 rounded-full bg-emerald-400 transition-[width]"
|
||||||
|
style={{ width: `${progressBarWidthPercent(workOrders.active.progressPct)}%` }}
|
||||||
|
/>
|
||||||
|
</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 className="text-xs uppercase tracking-wide text-zinc-400">{t("recap.workOrders.completed")}</div>
|
||||||
|
{workOrders.completed.length === 0 ? (
|
||||||
|
<div className="mt-1 text-sm text-zinc-400">{t("recap.workOrders.none")}</div>
|
||||||
|
) : (
|
||||||
|
<div className="mt-2 space-y-2">
|
||||||
|
{workOrders.completed.slice(0, 4).map((row) => (
|
||||||
|
<div key={row.id} className="rounded-xl border border-white/10 bg-black/30 p-3 text-xs text-zinc-300">
|
||||||
|
<div className="font-medium text-white">{row.id}</div>
|
||||||
|
<div>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>
|
||||||
|
);
|
||||||
|
}
|
||||||
64
components/recap/RecapWorkOrders.tsx
Normal file
64
components/recap/RecapWorkOrders.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useI18n } from "@/lib/i18n/useI18n";
|
||||||
|
import { formatRecapProgressPercent, progressBarWidthPercent } from "@/lib/recap/progressDisplay";
|
||||||
|
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-1 text-xs text-zinc-300">
|
||||||
|
{t("recap.production.progress")}:{" "}
|
||||||
|
{formatRecapProgressPercent(workOrders.active.progressPct, locale)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 h-2 rounded-full bg-white/10">
|
||||||
|
<div
|
||||||
|
className="h-2 rounded-full bg-emerald-400 transition-[width]"
|
||||||
|
style={{ width: `${progressBarWidthPercent(workOrders.active.progressPct)}%` }}
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
151
components/recap/timelineRender.ts
Normal file
151
components/recap/timelineRender.ts
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
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 const SEGMENT_MIN_WIDTH_PCT = 0.3;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
445
components/settings/ReasonCatalogConfig.tsx
Normal file
445
components/settings/ReasonCatalogConfig.tsx
Normal file
@@ -0,0 +1,445 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { useI18n } from "@/lib/i18n/useI18n";
|
||||||
|
|
||||||
|
type CatalogKind = "downtime" | "scrap";
|
||||||
|
|
||||||
|
type ApiItem = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
codeSuffix: string;
|
||||||
|
reasonCode: string;
|
||||||
|
sortOrder: number;
|
||||||
|
active: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ApiCategory = {
|
||||||
|
id: string;
|
||||||
|
kind: string;
|
||||||
|
name: string;
|
||||||
|
codePrefix: string;
|
||||||
|
sortOrder: number;
|
||||||
|
active: boolean;
|
||||||
|
items: ApiItem[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const PREFIX_RE = /^[A-Za-z][A-Za-z0-9-]*$/;
|
||||||
|
|
||||||
|
/** Matches composeReasonCode in reasonCatalogDb (client-safe). */
|
||||||
|
function formatPrintedPreview(prefix: string, digits: string): string {
|
||||||
|
const p = String(prefix).trim().toUpperCase();
|
||||||
|
const d = String(digits).trim();
|
||||||
|
if (!d) return p.length >= 3 ? `${p}-…` : `${p}…`;
|
||||||
|
if (/^\d+$/.test(d) && p.length >= 3) return `${p}-${d}`;
|
||||||
|
return `${p}${d}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readJson(res: Response) {
|
||||||
|
const data = await res.json().catch(() => null);
|
||||||
|
return data as Record<string, unknown> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReasonCatalogConfig({ disabled }: { disabled?: boolean }) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [catalogVersion, setCatalogVersion] = useState(1);
|
||||||
|
const [categories, setCategories] = useState<ApiCategory[]>([]);
|
||||||
|
const [kind, setKind] = useState<CatalogKind>("downtime");
|
||||||
|
const [selectedCategoryId, setSelectedCategoryId] = useState<string | null>(null);
|
||||||
|
const [newCatName, setNewCatName] = useState("");
|
||||||
|
const [newCatPrefix, setNewCatPrefix] = useState("");
|
||||||
|
const [newDigits, setNewDigits] = useState("");
|
||||||
|
const [newItemName, setNewItemName] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [editCatName, setEditCatName] = useState("");
|
||||||
|
const [editCatPrefix, setEditCatPrefix] = useState("");
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/settings/reason-catalog");
|
||||||
|
const data = await readJson(res);
|
||||||
|
if (!res.ok || !data || data.ok !== true) {
|
||||||
|
const msg = typeof data?.error === "string" ? data.error : "Load failed";
|
||||||
|
throw new Error(msg);
|
||||||
|
}
|
||||||
|
setCatalogVersion(Number(data.catalogVersion ?? 1));
|
||||||
|
setCategories(Array.isArray(data.categories) ? (data.categories as ApiCategory[]) : []);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Load failed");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const forKind = useMemo(
|
||||||
|
() => categories.filter((c) => String(c.kind).toLowerCase() === kind),
|
||||||
|
[categories, kind]
|
||||||
|
);
|
||||||
|
|
||||||
|
const selected = useMemo(
|
||||||
|
() => forKind.find((c) => c.id === selectedCategoryId) ?? null,
|
||||||
|
[forKind, selectedCategoryId]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selected) {
|
||||||
|
setEditCatName("");
|
||||||
|
setEditCatPrefix("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setEditCatName(selected.name);
|
||||||
|
setEditCatPrefix(selected.codePrefix);
|
||||||
|
}, [selected?.id, selected?.name, selected?.codePrefix]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!forKind.length) {
|
||||||
|
setSelectedCategoryId(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!selectedCategoryId || !forKind.some((c) => c.id === selectedCategoryId)) {
|
||||||
|
setSelectedCategoryId(forKind[0]?.id ?? null);
|
||||||
|
}
|
||||||
|
}, [forKind, selectedCategoryId]);
|
||||||
|
|
||||||
|
const onDigitsChange = (raw: string) => {
|
||||||
|
setNewDigits(raw.replace(/\D/g, ""));
|
||||||
|
};
|
||||||
|
|
||||||
|
const createCategory = async () => {
|
||||||
|
const name = newCatName.trim();
|
||||||
|
const codePrefix = newCatPrefix.trim().toUpperCase();
|
||||||
|
if (!name || !codePrefix) return;
|
||||||
|
if (!PREFIX_RE.test(codePrefix)) {
|
||||||
|
setError(t("settings.reasonCatalog.prefixInvalid"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/settings/reason-catalog/categories", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ kind, name, codePrefix }),
|
||||||
|
});
|
||||||
|
const data = await readJson(res);
|
||||||
|
if (!res.ok || !data || data.ok !== true) {
|
||||||
|
const msg = typeof data?.error === "string" ? data.error : "Create failed";
|
||||||
|
throw new Error(msg);
|
||||||
|
}
|
||||||
|
setNewCatName("");
|
||||||
|
setNewCatPrefix("");
|
||||||
|
await load();
|
||||||
|
const cat = data.category as { id?: string } | undefined;
|
||||||
|
if (cat?.id) setSelectedCategoryId(cat.id);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Create failed");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const addItem = async () => {
|
||||||
|
if (!selected) return;
|
||||||
|
const digits = newDigits.trim();
|
||||||
|
const name = newItemName.trim();
|
||||||
|
if (!digits || !name) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/settings/reason-catalog/items", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ categoryId: selected.id, codeSuffix: digits, name }),
|
||||||
|
});
|
||||||
|
const data = await readJson(res);
|
||||||
|
if (!res.ok || !data || data.ok !== true) {
|
||||||
|
const msg = typeof data?.error === "string" ? data.error : "Create failed";
|
||||||
|
throw new Error(msg);
|
||||||
|
}
|
||||||
|
setNewDigits("");
|
||||||
|
setNewItemName("");
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Create failed");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const patchItem = async (itemId: string, patch: Record<string, unknown>) => {
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/settings/reason-catalog/items/${itemId}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(patch),
|
||||||
|
});
|
||||||
|
const data = await readJson(res);
|
||||||
|
if (!res.ok || !data || data.ok !== true) {
|
||||||
|
const msg = typeof data?.error === "string" ? data.error : "Update failed";
|
||||||
|
throw new Error(msg);
|
||||||
|
}
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Update failed");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const patchCategory = async (categoryId: string, patch: Record<string, unknown>) => {
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/settings/reason-catalog/categories/${categoryId}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(patch),
|
||||||
|
});
|
||||||
|
const data = await readJson(res);
|
||||||
|
if (!res.ok || !data || data.ok !== true) {
|
||||||
|
const msg = typeof data?.error === "string" ? data.error : "Update failed";
|
||||||
|
throw new Error(msg);
|
||||||
|
}
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Update failed");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const inputCls =
|
||||||
|
"mt-1 w-full rounded-lg border border-white/10 bg-black/30 px-2 py-1.5 text-xs text-white placeholder:text-zinc-600";
|
||||||
|
|
||||||
|
const kindBtn = (k: CatalogKind, label: string) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={disabled || busy}
|
||||||
|
onClick={() => setKind(k)}
|
||||||
|
className={`rounded-lg px-3 py-1.5 text-xs font-medium transition ${
|
||||||
|
kind === k ? "bg-emerald-500/25 text-emerald-100 ring-1 ring-emerald-400/40" : "bg-black/30 text-zinc-400 hover:bg-white/5"
|
||||||
|
} disabled:opacity-40`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="rounded-xl border border-white/10 bg-black/20 p-4">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<div className="text-[11px] text-zinc-500">
|
||||||
|
{t("settings.reasonCatalog.dbVersionHint", { version: catalogVersion })}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={disabled || busy || loading}
|
||||||
|
onClick={() => void load()}
|
||||||
|
className="rounded-lg border border-white/10 bg-white/5 px-2 py-1 text-[11px] text-white hover:bg-white/10 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{t("settings.reasonCatalog.reload")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{loading ? <p className="mt-2 text-xs text-zinc-500">{t("settings.loading")}</p> : null}
|
||||||
|
{error ? (
|
||||||
|
<p className="mt-2 rounded-lg border border-red-500/30 bg-red-500/10 px-2 py-1.5 text-xs text-red-200">{error}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-xl border border-white/10 bg-black/20 p-4">
|
||||||
|
<div className="text-xs font-semibold text-zinc-300">{t("settings.reasonCatalog.stepKind")}</div>
|
||||||
|
<div className="mt-2 flex flex-wrap gap-2">
|
||||||
|
{kindBtn("downtime", t("settings.reasonCatalog.downtime"))}
|
||||||
|
{kindBtn("scrap", t("settings.reasonCatalog.scrap"))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-xl border border-white/10 bg-black/20 p-4">
|
||||||
|
<div className="text-xs font-semibold text-zinc-300">{t("settings.reasonCatalog.stepCategory")}</div>
|
||||||
|
<div className="mt-2 flex flex-col gap-2 sm:flex-row sm:items-end">
|
||||||
|
<label className="min-w-[200px] flex-1 text-[11px] text-zinc-400">
|
||||||
|
{t("settings.reasonCatalog.pickCategory")}
|
||||||
|
<select
|
||||||
|
disabled={disabled || busy || !forKind.length}
|
||||||
|
value={selectedCategoryId ?? ""}
|
||||||
|
onChange={(e) => setSelectedCategoryId(e.target.value || null)}
|
||||||
|
className={`${inputCls} cursor-pointer`}
|
||||||
|
>
|
||||||
|
{!forKind.length ? <option value="">{t("settings.reasonCatalog.emptyKind")}</option> : null}
|
||||||
|
{forKind.map((c) => (
|
||||||
|
<option key={c.id} value={c.id}>
|
||||||
|
{c.name} ({c.codePrefix}){c.active ? "" : ` — ${t("settings.reasonCatalog.inactive")}`}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selected ? (
|
||||||
|
<div className="mt-4 grid gap-3 rounded-lg border border-white/5 bg-black/30 p-3 sm:grid-cols-2">
|
||||||
|
<label className="text-[11px] text-zinc-400">
|
||||||
|
{t("settings.reasonCatalog.categoryNameEdit")}
|
||||||
|
<input
|
||||||
|
disabled={disabled || busy}
|
||||||
|
value={editCatName}
|
||||||
|
onChange={(e) => setEditCatName(e.target.value)}
|
||||||
|
onBlur={() => {
|
||||||
|
const n = editCatName.trim();
|
||||||
|
if (n && n !== selected.name) void patchCategory(selected.id, { name: n });
|
||||||
|
}}
|
||||||
|
className={inputCls}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="text-[11px] text-zinc-400">
|
||||||
|
{t("settings.reasonCatalog.codePrefixEdit")}
|
||||||
|
<input
|
||||||
|
disabled={disabled || busy}
|
||||||
|
value={editCatPrefix}
|
||||||
|
onChange={(e) => setEditCatPrefix(e.target.value.toUpperCase())}
|
||||||
|
onBlur={() => {
|
||||||
|
const v = editCatPrefix.trim().toUpperCase();
|
||||||
|
if (!v || !PREFIX_RE.test(v)) {
|
||||||
|
setEditCatPrefix(selected.codePrefix);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (v !== selected.codePrefix) void patchCategory(selected.id, { codePrefix: v });
|
||||||
|
}}
|
||||||
|
className={inputCls}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 text-[11px] text-zinc-400 sm:col-span-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
disabled={disabled || busy}
|
||||||
|
checked={selected.active}
|
||||||
|
onChange={(e) => void patchCategory(selected.id, { active: e.target.checked })}
|
||||||
|
className="h-3.5 w-3.5 rounded border border-white/20 bg-black/20"
|
||||||
|
/>
|
||||||
|
{t("settings.reasonCatalog.categoryActive")}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="mt-4 border-t border-white/5 pt-4">
|
||||||
|
<div className="text-[11px] font-semibold text-zinc-400">{t("settings.reasonCatalog.newCategorySection")}</div>
|
||||||
|
<div className="mt-2 grid gap-2 sm:grid-cols-2">
|
||||||
|
<label className="text-[11px] text-zinc-400">
|
||||||
|
{t("settings.reasonCatalog.categoryLabel")}
|
||||||
|
<input
|
||||||
|
disabled={disabled || busy}
|
||||||
|
value={newCatName}
|
||||||
|
onChange={(e) => setNewCatName(e.target.value)}
|
||||||
|
className={inputCls}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="text-[11px] text-zinc-400">
|
||||||
|
{t("settings.reasonCatalog.codePrefixField")}
|
||||||
|
<input
|
||||||
|
disabled={disabled || busy}
|
||||||
|
value={newCatPrefix}
|
||||||
|
onChange={(e) => setNewCatPrefix(e.target.value.toUpperCase())}
|
||||||
|
placeholder="DTPRC"
|
||||||
|
className={inputCls}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={disabled || busy || !newCatName.trim() || !newCatPrefix.trim()}
|
||||||
|
onClick={() => void createCategory()}
|
||||||
|
className="mt-2 rounded-lg border border-emerald-400/30 bg-emerald-500/15 px-3 py-1.5 text-xs text-emerald-100 hover:bg-emerald-500/25 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{t("settings.reasonCatalog.addCategory")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selected ? (
|
||||||
|
<div className="rounded-xl border border-white/10 bg-black/20 p-4">
|
||||||
|
<div className="text-xs font-semibold text-zinc-300">{t("settings.reasonCatalog.stepReason")}</div>
|
||||||
|
<p className="mt-1 text-[11px] text-zinc-500">{t("settings.reasonCatalog.digitsOnlyHint")}</p>
|
||||||
|
<div className="mt-3 flex flex-wrap items-end gap-3">
|
||||||
|
<div className="text-[11px] text-zinc-400">
|
||||||
|
<span className="block text-zinc-500">{t("settings.reasonCatalog.fullCodePreview")}</span>
|
||||||
|
<span className="mt-1 inline-flex min-h-[2rem] items-center rounded-lg border border-white/10 bg-black/40 px-3 font-mono text-sm text-emerald-200">
|
||||||
|
{formatPrintedPreview(selected.codePrefix, newDigits)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<label className="w-32 text-[11px] text-zinc-400">
|
||||||
|
{t("settings.reasonCatalog.numericSuffix")}
|
||||||
|
<input
|
||||||
|
disabled={disabled || busy}
|
||||||
|
inputMode="numeric"
|
||||||
|
pattern="[0-9]*"
|
||||||
|
value={newDigits}
|
||||||
|
onChange={(e) => onDigitsChange(e.target.value)}
|
||||||
|
placeholder="01"
|
||||||
|
className={inputCls}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="min-w-[180px] flex-1 text-[11px] text-zinc-400">
|
||||||
|
{t("settings.reasonCatalog.detailLabel")}
|
||||||
|
<input
|
||||||
|
disabled={disabled || busy}
|
||||||
|
value={newItemName}
|
||||||
|
onChange={(e) => setNewItemName(e.target.value)}
|
||||||
|
className={inputCls}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={disabled || busy || !newDigits.trim() || !newItemName.trim()}
|
||||||
|
onClick={() => void addItem()}
|
||||||
|
className="rounded-lg border border-emerald-400/30 bg-emerald-500/15 px-3 py-2 text-xs text-emerald-100 hover:bg-emerald-500/25 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{t("settings.reasonCatalog.addReason")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4">
|
||||||
|
<div className="text-[11px] font-semibold text-zinc-500">{t("settings.reasonCatalog.reasonsInCategory")}</div>
|
||||||
|
<div className="mt-2 space-y-2">
|
||||||
|
{selected.items.length === 0 ? (
|
||||||
|
<div className="text-xs text-zinc-500">{t("settings.reasonCatalog.noItemsYet")}</div>
|
||||||
|
) : (
|
||||||
|
selected.items.map((it) => (
|
||||||
|
<div
|
||||||
|
key={it.id}
|
||||||
|
className={`flex flex-wrap items-center justify-between gap-2 rounded-lg border border-white/5 px-3 py-2 ${
|
||||||
|
it.active ? "bg-black/30" : "bg-black/10 opacity-60"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="font-mono text-xs text-emerald-200">{it.reasonCode}</div>
|
||||||
|
<div className="min-w-0 flex-1 truncate text-xs text-white">{it.name}</div>
|
||||||
|
<label className="flex items-center gap-1.5 text-[10px] text-zinc-400">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
disabled={disabled || busy}
|
||||||
|
checked={it.active}
|
||||||
|
onChange={(e) => void patchItem(it.id, { active: e.target.checked })}
|
||||||
|
className="h-3.5 w-3.5 rounded border border-white/20 bg-black/20"
|
||||||
|
/>
|
||||||
|
{t("settings.reasonCatalog.active")}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<p className="text-[11px] leading-relaxed text-zinc-500">{t("settings.reasonCatalog.hint")}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
106
fix.md
Normal file
106
fix.md
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
Root cause found — CT has no authoritative WO counters
|
||||||
|
The actual bug
|
||||||
|
Your Node-RED writes goodParts/scrapParts/cycleCount to the Pi's MariaDB work_orders table. That's local. It never gets written to CT's Postgres machine_work_orders table — which the Prisma model doesn't even declare those columns. Confirmed:
|
||||||
|
|
||||||
|
model MachineWorkOrder {
|
||||||
|
id, orgId, machineId, workOrderId, sku, targetQty, cycleTime, status, createdAt, updatedAt
|
||||||
|
// NO good_parts, scrap_parts, cycle_count
|
||||||
|
}
|
||||||
|
lib/recap/getRecapData.ts line 259 probes information_schema.columns for those columns → always finds zero → workOrderCountersAvailable = false → loadWorkOrderCounterRows returns null → falls back to KPI delta math.
|
||||||
|
|
||||||
|
The KPI delta math (line 700 area):
|
||||||
|
|
||||||
|
const rangeGood = Math.max(0, (agg.maxGood ?? 0) - (agg.minGood ?? agg.maxGood ?? 0));
|
||||||
|
Node-RED sends the cumulative activeWorkOrder.goodParts in every KPI snapshot (not delta). If the WO has 353 parts now and has been stable (mold change, no new parts) across the 24h window, min=maxGood=353 → rangeGood = 0. If there was a brief scrap entry or counter drift, you get rangeGood = 1. That's where your "1 good part" comes from.
|
||||||
|
|
||||||
|
Recap is structurally unable to match Node-RED until CT persists the authoritative counter.
|
||||||
|
|
||||||
|
Fix — 3 changes, in order
|
||||||
|
1. Add counter columns to CT schema
|
||||||
|
File: prisma/schema.prisma
|
||||||
|
|
||||||
|
model MachineWorkOrder {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
orgId String
|
||||||
|
machineId String
|
||||||
|
workOrderId String
|
||||||
|
sku String?
|
||||||
|
targetQty Int?
|
||||||
|
cycleTime Float?
|
||||||
|
status String @default("PENDING")
|
||||||
|
goodParts Int @default(0) @map("good_parts") // NEW
|
||||||
|
scrapParts Int @default(0) @map("scrap_parts") // NEW
|
||||||
|
cycleCount Int @default(0) @map("cycle_count") // NEW
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
// rest unchanged
|
||||||
|
}
|
||||||
|
Generate migration: npx prisma migrate dev --name add_wo_counters. Run on prod DB.
|
||||||
|
|
||||||
|
2. Have KPI ingest upsert the counters
|
||||||
|
File: app/api/ingest/kpi/route.ts
|
||||||
|
|
||||||
|
Each KPI payload from Node-RED contains:
|
||||||
|
|
||||||
|
"activeWorkOrder": { "id": "OTBM-002", "sku": "RAMBOX", "goodParts": 353, "scrapParts": 1, "cycleCount": 353 }
|
||||||
|
Inside the handler, after creating the MachineKpiSnapshot, add:
|
||||||
|
|
||||||
|
const awo = payload?.activeWorkOrder;
|
||||||
|
if (awo?.id) {
|
||||||
|
await prisma.machineWorkOrder.upsert({
|
||||||
|
where: { machineId_workOrderId: { machineId: machine.id, workOrderId: String(awo.id) } },
|
||||||
|
create: {
|
||||||
|
orgId: machine.orgId,
|
||||||
|
machineId: machine.id,
|
||||||
|
workOrderId: String(awo.id),
|
||||||
|
sku: awo.sku ?? null,
|
||||||
|
targetQty: Number(awo.target) || null,
|
||||||
|
cycleTime: Number(awo.cycleTime) || null,
|
||||||
|
status: awo.status ?? "RUNNING",
|
||||||
|
goodParts: Number(awo.goodParts) || 0,
|
||||||
|
scrapParts: Number(awo.scrapParts) || 0,
|
||||||
|
cycleCount: Number(awo.cycleCount) || 0,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
sku: awo.sku ?? undefined,
|
||||||
|
targetQty: Number(awo.target) || undefined,
|
||||||
|
cycleTime: Number(awo.cycleTime) || undefined,
|
||||||
|
status: awo.status ?? undefined,
|
||||||
|
goodParts: Number(awo.goodParts) || 0,
|
||||||
|
scrapParts: Number(awo.scrapParts) || 0,
|
||||||
|
cycleCount: Number(awo.cycleCount) || 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
This makes CT's machine_work_orders rows track Pi's live state minute-by-minute.
|
||||||
|
|
||||||
|
3. Simplify recap aggregation
|
||||||
|
File: lib/recap/getRecapData.ts
|
||||||
|
|
||||||
|
Now that the columns exist, loadWorkOrderCounterRows will work. But also drop the updatedAt BETWEEN filter — it excludes WOs that haven't ticked recently (e.g. during mold change):
|
||||||
|
|
||||||
|
// REMOVE:
|
||||||
|
AND "updatedAt" >= ${params.start}
|
||||||
|
AND "updatedAt" <= ${params.end}
|
||||||
|
|
||||||
|
// KEEP only:
|
||||||
|
WHERE "orgId" = ${params.orgId}
|
||||||
|
AND "machineId" IN (${machineIdList})
|
||||||
|
Return all WOs for the machine; filter client-side or by another criterion if needed. For the "last 24h production" metric, sum goodParts across all WOs (simple, matches Home UI).
|
||||||
|
|
||||||
|
Also remove the whole KPI-delta fallback block (lines ~600-760) — don't need it anymore. The authoritative counter is always present once changes 1+2 are deployed.
|
||||||
|
|
||||||
|
Other issues you flagged
|
||||||
|
OEE 75% vs 47%: Recap uses time-weighted average across the window (24h including hours of stopped machine → pulls avg down). Machine detail shows a shorter-window or last-snapshot value. Decision: Recap avg is technically correct for "24h avg"; Machine detail's 75% is the "current instantaneous" OEE. Label them clearly: "OEE promedio 24h: 47%" vs "OEE actual: 75%". Don't make them the same number — they measure different things. Show both if you want.
|
||||||
|
|
||||||
|
Machine detail timeline flickering: probably a client useEffect dependency loop or a polling interval too short. Check app/(app)/machines/[machineId]/MachineDetailClient.tsx for a setInterval or SWR revalidation. Likely you're re-fetching every 2-3s and the data comes back with slightly different timestamps, causing re-render. Fix: increase poll to 15-30s and compare by segment hash before updating state.
|
||||||
|
|
||||||
|
"1 good part" bug: will self-fix once #1 and #2 are deployed (recap reads authoritative column instead of computing bad delta).
|
||||||
|
|
||||||
|
Deployment order
|
||||||
|
Merge schema migration to main. Run prisma migrate deploy in prod.
|
||||||
|
Ship KPI ingest change — Node-RED starts populating counters immediately.
|
||||||
|
Ship recap simplification — hits the now-populated columns.
|
||||||
|
Watch for ~5 min for CT to catch up (KPI ticks every minute from Pi).
|
||||||
|
Verify: SELECT work_order_id, good_parts, scrap_parts FROM machine_work_orders WHERE machine_id = '<uuid>' ORDER BY updated_at DESC LIMIT 5; — should match Home UI (353).
|
||||||
|
No Node-RED changes needed. Pi is already sending the right data; CT just wasn't storing it.
|
||||||
42
fix2.md
Normal file
42
fix2.md
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
Traced. Here's the truth for each filter.
|
||||||
|
|
||||||
|
Range logic (lib/recap/redesign.ts line 458-490)
|
||||||
|
Filter Window computed What it shows
|
||||||
|
24h now - 24h → now Rolling last 24h. Mold change at right edge = started ~9:46pm today
|
||||||
|
Turno actual Current-shift window from orgShift table Needs configured shifts; else falls back to 24h
|
||||||
|
Ayer now-48h → now-24h (rolling!) NOT "yesterday 00:00-23:59" — it's "24-48h ago". Label is misleading
|
||||||
|
Personalizado User-picked Explicit
|
||||||
|
What's actually wrong
|
||||||
|
1. "Paros totales 3,102,444 min" and "144,409 min" are stale data. Those are huge because old duplicate ReasonEntry rows (from before the is_update/is_auto_ack filter was deployed) are still in DB. The filter stops NEW duplicates but doesn't delete old ones.
|
||||||
|
|
||||||
|
Fix:
|
||||||
|
|
||||||
|
TRUNCATE TABLE "ReasonEntry";
|
||||||
|
Or targeted:
|
||||||
|
|
||||||
|
DELETE FROM "ReasonEntry" WHERE "capturedAt" < '<date-when-filter-deployed>';
|
||||||
|
After that, numbers will be realistic.
|
||||||
|
|
||||||
|
2. "Ayer" window is wrong. Current code is rolling 48-24h ago, not calendar yesterday. Fix in resolveDetailRange:
|
||||||
|
|
||||||
|
if (mode === "yesterday") {
|
||||||
|
// use local timezone calendar day, not rolling 24h
|
||||||
|
const tz = orgSettings.timezone ?? "America/Mexico_City";
|
||||||
|
const today0 = zonedToUtcDate({ ...localDayStart(now, tz), hours: 0, minutes: 0, timeZone: tz });
|
||||||
|
const yesterday0 = new Date(today0.getTime() - 24 * 60 * 60 * 1000);
|
||||||
|
return { mode, start: yesterday0, end: today0 };
|
||||||
|
}
|
||||||
|
3. "Sin señal hace 376 min" is real. Pi stopped sending heartbeats 6h ago. Simultaneously "Cambio de molde en curso" is stuck active because Pi went offline DURING the mold change — no resolved event ever arrived. Both facts are true. Banner logic is correct, UX could be improved:
|
||||||
|
|
||||||
|
If offlineMin > moldChangeAgeMin, show only the offline banner (more severe). Or combined: "Sin señal hace 376m — último estado: cambio de molde".
|
||||||
|
|
||||||
|
4. Different OEE across filters is expected (different windows, different math). Labels should make it obvious: OEE PROMEDIO 24h, OEE DEL TURNO, OEE AYER. Currently they all say "OEE PROMEDIO 24H" regardless of filter → confusing. Check RecapKpiRow.tsx — the label should come from the range mode, not be hardcoded.
|
||||||
|
|
||||||
|
5. Shift mode falls through to 24h if no shifts configured. That's why the numbers are slightly different — it actually ran with a real shift. Verify: SELECT * FROM "OrgShift" WHERE "orgId" = '<id>';. If empty, shifts aren't set; the filter is silently showing 24h and labeling it "Turno actual" → more confusion.
|
||||||
|
|
||||||
|
Priority order
|
||||||
|
Truncate ReasonEntry (kills 99% of the insanity).
|
||||||
|
Fix "Ayer" to be calendar-based.
|
||||||
|
Fix KPI row label to reflect selected range.
|
||||||
|
If no OrgShift rows exist, show a toast or disable "Turno actual" button instead of silently falling back.
|
||||||
|
Improve dual-banner priority (offline > mold-change)
|
||||||
54
fix3.md
Normal file
54
fix3.md
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
The fix created the production segment, but the mold-change active event never got a matching resolved event in CT. Two checks will tell us which.
|
||||||
|
|
||||||
|
On CT Postgres
|
||||||
|
SELECT ts, data->>'status' AS status,
|
||||||
|
data->>'incidentKey' AS ikey,
|
||||||
|
data->>'is_update' AS is_update
|
||||||
|
FROM "MachineEvent"
|
||||||
|
WHERE "machineId" = '<uuid-M4-5>'
|
||||||
|
AND "eventType" = 'mold-change'
|
||||||
|
ORDER BY ts DESC LIMIT 10;
|
||||||
|
Expected when working: active + resolved rows with same incidentKey.
|
||||||
|
|
||||||
|
If only active exists → resolved event never reached CT. Likely causes:
|
||||||
|
|
||||||
|
Flow wasn't redeployed after the edit (Node-RED still running old version — check if node.warn in auto-close is firing in debug sidebar).
|
||||||
|
state.moldChange persisted stale from before (cleared active manually somewhere).
|
||||||
|
User hit COMENZAR before deploying the updated flow → no close event ever emitted for that episode.
|
||||||
|
If both exist but incidentKey differs → my close event and start event used different startMs. Send me both rows and I'll trace.
|
||||||
|
|
||||||
|
Manual cleanup for the stuck episode
|
||||||
|
Until a new resolved event arrives, the banner won't clear. Force it:
|
||||||
|
|
||||||
|
-- Insert a synthetic resolved event matching the stuck active one
|
||||||
|
INSERT INTO "MachineEvent" (id, "orgId", "machineId", ts, topic, "eventType", severity, "requiresAck", title, description, data, "createdAt")
|
||||||
|
SELECT gen_random_uuid(), "orgId", "machineId", NOW(), 'mold-change', 'mold-change', 'info', false,
|
||||||
|
'Cambio de molde cerrado manualmente', 'cierre manual',
|
||||||
|
jsonb_build_object(
|
||||||
|
'status','resolved',
|
||||||
|
'incidentKey', data->>'incidentKey',
|
||||||
|
'start_ms', (data->>'start_ms')::bigint,
|
||||||
|
'end_ms', extract(epoch from NOW())*1000
|
||||||
|
),
|
||||||
|
NOW()
|
||||||
|
FROM "MachineEvent"
|
||||||
|
WHERE "machineId" = '<uuid-M4-5>' AND "eventType" = 'mold-change' AND data->>'status' = 'active'
|
||||||
|
ORDER BY ts DESC LIMIT 1;
|
||||||
|
Banner disappears on next recap refresh (cache 60s).
|
||||||
|
|
||||||
|
Permanent safeguard (CT)
|
||||||
|
In lib/recap/getRecapData.ts ~line 817, add a freshness cap: an "active" mold-change older than 12h is almost always stuck data. Treat as resolved:
|
||||||
|
|
||||||
|
const STALE_ACTIVE_MS = 12 * 60 * 60 * 1000;
|
||||||
|
for (const event of machineMoldEvents) {
|
||||||
|
const key = eventIncidentKey(event.data, "mold-change", event.ts);
|
||||||
|
const status = eventStatus(event.data);
|
||||||
|
if (status === "resolved") { moldActiveByIncident.delete(key); continue; }
|
||||||
|
if (status === "active" || !status) {
|
||||||
|
// ignore if too old to be real
|
||||||
|
if (params.end.getTime() - event.ts.getTime() > STALE_ACTIVE_MS) continue;
|
||||||
|
moldActiveByIncident.set(key, moldStartMs(event.data, event.ts));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Same for the timeline extension logic in lib/recap/timeline.ts line 662 — cap isFreshActive at the same threshold.
|
||||||
|
|
||||||
244
fix4.md
Normal file
244
fix4.md
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
Task: Implement Control Tower changes only (no Node-RED edits), then run full verification with SQL + backfill script.
|
||||||
|
|
||||||
|
Repository context:
|
||||||
|
- Workspace root: Plastic-Dashboard
|
||||||
|
- Target branch assumption: sandbox-main
|
||||||
|
- Database: PostgreSQL via Prisma
|
||||||
|
- Scope strictly limited to Control Tower code and scripts in this repo
|
||||||
|
|
||||||
|
Hard constraints:
|
||||||
|
1. Do NOT edit any Node-RED flow files or Node-RED runtime code.
|
||||||
|
2. Do NOT change behavior outside the requested areas unless required for correctness.
|
||||||
|
3. Preserve existing non-authoritative guard behavior for downtime reasons (PENDIENTE / UNCLASSIFIED).
|
||||||
|
4. Run verification before and after backfill, and report results clearly.
|
||||||
|
5. If lint/test has unrelated pre-existing failures, do not refactor unrelated modules.
|
||||||
|
|
||||||
|
Implementation requirements:
|
||||||
|
|
||||||
|
A) Downtime continuity fallback key fix
|
||||||
|
File:
|
||||||
|
- app/api/ingest/event/route.ts
|
||||||
|
|
||||||
|
Goal:
|
||||||
|
- Ensure fallback downtime reason identity/continuity uses episode continuity key (incidentKey) whenever present.
|
||||||
|
- Use row.id only when incidentKey is truly absent.
|
||||||
|
- Preserve guard that prevents non-authoritative values from overwriting authoritative manual reasons.
|
||||||
|
|
||||||
|
Details:
|
||||||
|
1. In the event ingestion logic where ReasonEntry payload is created for downtime-like events (including fallback UNCLASSIFIED and mold-change):
|
||||||
|
- Derive a fallbackIncidentKey from available payload fields in this preference order:
|
||||||
|
- evData.incidentKey
|
||||||
|
- dataObj.incidentKey
|
||||||
|
- evDowntime?.incidentKey
|
||||||
|
- evReason?.incidentKey (if available)
|
||||||
|
- Only if all are missing, fallback to row.id.
|
||||||
|
|
||||||
|
2. For fallback reasonRaw objects:
|
||||||
|
- For mold-change fallback, set incidentKey to moldIncidentKey ?? fallbackIncidentKey ?? row.id.
|
||||||
|
- For unclassified fallback, set incidentKey to fallbackIncidentKey ?? row.id.
|
||||||
|
|
||||||
|
3. Create one continuityIncidentKey (single source of truth) used consistently for:
|
||||||
|
- downtime reasonId construction (evt:<machineId>:downtime:<continuityIncidentKey>)
|
||||||
|
- ReasonEntry episodeId for downtime
|
||||||
|
- meta.incidentKey in reason entry writes
|
||||||
|
- manual-preservation guard queries by episodeId
|
||||||
|
|
||||||
|
4. Keep non-authoritative guard semantics unchanged:
|
||||||
|
- incoming non-authoritative reason should not overwrite existing authoritative reason for same episode
|
||||||
|
- downtime-acknowledged/manual authoritative path remains preserved
|
||||||
|
|
||||||
|
B) OEE trend from production-only snapshots
|
||||||
|
File:
|
||||||
|
- app/api/reports/route.ts
|
||||||
|
|
||||||
|
Goal:
|
||||||
|
- Build OEE trend from production-only snapshots:
|
||||||
|
- trackingEnabled = true
|
||||||
|
- productionStarted = true
|
||||||
|
- Keep summary metrics behavior explicit and consistent with this filtering decision.
|
||||||
|
|
||||||
|
Details:
|
||||||
|
1. Include trackingEnabled and productionStarted in KPI snapshot select.
|
||||||
|
2. Add helper like isProductionSnapshot(trackingEnabled, productionStarted).
|
||||||
|
3. Compute OEE/Availability/Performance/Quality averages using production-only rows.
|
||||||
|
4. For trend generation:
|
||||||
|
- Iterate timeline in ts order.
|
||||||
|
- For non-production snapshots, emit null points (for OEE and related KPI lines) so chart can render true gaps.
|
||||||
|
- For production snapshots, emit actual numeric values (or null if value is missing).
|
||||||
|
5. Keep downtime/event aggregates and cycle-based totals behavior intact unless explicitly tied to OEE production-only requirement.
|
||||||
|
6. Keep logic explicit in code comments (short, concrete comments only where needed).
|
||||||
|
|
||||||
|
C) Chart rendering behavior: no smoothing across gaps
|
||||||
|
Files:
|
||||||
|
- app/(app)/reports/ReportsCharts.tsx
|
||||||
|
- app/(app)/reports/ReportsPageClient.tsx (if types/downsampling need updates)
|
||||||
|
|
||||||
|
Goal:
|
||||||
|
- OEE line interpolation must be linear.
|
||||||
|
- Gaps must be rendered as gaps (no fake continuity through filtered/non-production windows).
|
||||||
|
|
||||||
|
Details:
|
||||||
|
1. In OEE line chart:
|
||||||
|
- change Line type from monotone to linear
|
||||||
|
- set connectNulls={false}
|
||||||
|
2. Ensure frontend types allow nullable trend values for OEE points.
|
||||||
|
3. If downsampling exists, preserve gap markers so null separators are not removed.
|
||||||
|
- Keep null transition points when reducing point count.
|
||||||
|
4. Ensure tooltip/value formatting handles nulls gracefully.
|
||||||
|
|
||||||
|
Verification and execution steps:
|
||||||
|
|
||||||
|
1) Run targeted checks first
|
||||||
|
- run tests related to downtime guard if available:
|
||||||
|
- npm run test:downtime-reason-guard
|
||||||
|
- run lint at least for changed files (or full lint if practical):
|
||||||
|
- npx eslint app/api/ingest/event/route.ts app/api/reports/route.ts app/(app)/reports/ReportsCharts.tsx app/(app)/reports/ReportsPageClient.tsx
|
||||||
|
|
||||||
|
2) SQL Verification Pack (PRE-BACKFILL)
|
||||||
|
Execute these exactly and capture output snapshots:
|
||||||
|
|
||||||
|
A. Recent downtime reason quality mix
|
||||||
|
SELECT
|
||||||
|
reasonCode,
|
||||||
|
COUNT(*) AS rows
|
||||||
|
FROM "ReasonEntry"
|
||||||
|
WHERE kind = 'downtime'
|
||||||
|
AND "capturedAt" >= NOW() - INTERVAL '7 days'
|
||||||
|
GROUP BY reasonCode
|
||||||
|
ORDER BY rows DESC;
|
||||||
|
|
||||||
|
B. Episodes with conflicting reason codes
|
||||||
|
SELECT
|
||||||
|
"orgId",
|
||||||
|
"machineId",
|
||||||
|
"episodeId",
|
||||||
|
COUNT(DISTINCT "reasonCode") AS distinct_codes,
|
||||||
|
MIN("capturedAt") AS first_seen,
|
||||||
|
MAX("capturedAt") AS last_seen
|
||||||
|
FROM "ReasonEntry"
|
||||||
|
WHERE kind = 'downtime'
|
||||||
|
AND "episodeId" IS NOT NULL
|
||||||
|
AND "capturedAt" >= NOW() - INTERVAL '14 days'
|
||||||
|
GROUP BY "orgId", "machineId", "episodeId"
|
||||||
|
HAVING COUNT(DISTINCT "reasonCode") > 1
|
||||||
|
ORDER BY last_seen DESC
|
||||||
|
LIMIT 200;
|
||||||
|
|
||||||
|
C. Potential manual overwritten by non-authoritative check
|
||||||
|
SELECT
|
||||||
|
re."orgId",
|
||||||
|
re."machineId",
|
||||||
|
re."episodeId",
|
||||||
|
re."reasonCode",
|
||||||
|
re."capturedAt",
|
||||||
|
re.meta
|
||||||
|
FROM "ReasonEntry" re
|
||||||
|
WHERE re.kind = 'downtime'
|
||||||
|
AND re."capturedAt" >= NOW() - INTERVAL '14 days'
|
||||||
|
AND re."reasonCode" IN ('PENDIENTE', 'UNCLASSIFIED')
|
||||||
|
ORDER BY re."capturedAt" DESC
|
||||||
|
LIMIT 200;
|
||||||
|
|
||||||
|
D. Event continuity around downtime + ack
|
||||||
|
SELECT
|
||||||
|
"machineId",
|
||||||
|
"eventType",
|
||||||
|
ts,
|
||||||
|
data->>'incidentKey' AS incident_key,
|
||||||
|
data->>'status' AS status,
|
||||||
|
data->>'is_update' AS is_update,
|
||||||
|
data->>'is_auto_ack' AS is_auto_ack
|
||||||
|
FROM "MachineEvent"
|
||||||
|
WHERE ts >= NOW() - INTERVAL '3 days'
|
||||||
|
AND "eventType" IN ('microstop', 'macrostop', 'downtime-acknowledged')
|
||||||
|
ORDER BY ts DESC
|
||||||
|
LIMIT 500;
|
||||||
|
|
||||||
|
E. KPI production vs non-production counts
|
||||||
|
SELECT
|
||||||
|
COALESCE("trackingEnabled", false) AS tracking_enabled,
|
||||||
|
COALESCE("productionStarted", false) AS production_started,
|
||||||
|
COUNT(*) AS rows
|
||||||
|
FROM "MachineKpiSnapshot"
|
||||||
|
WHERE ts >= NOW() - INTERVAL '7 days'
|
||||||
|
GROUP BY 1,2
|
||||||
|
ORDER BY rows DESC;
|
||||||
|
|
||||||
|
F. Sharp OEE jumps in production snapshots
|
||||||
|
WITH k AS (
|
||||||
|
SELECT
|
||||||
|
"machineId",
|
||||||
|
ts,
|
||||||
|
oee,
|
||||||
|
LAG(oee) OVER (PARTITION BY "machineId" ORDER BY ts) AS prev_oee
|
||||||
|
FROM "MachineKpiSnapshot"
|
||||||
|
WHERE ts >= NOW() - INTERVAL '7 days'
|
||||||
|
AND "trackingEnabled" = true
|
||||||
|
AND "productionStarted" = true
|
||||||
|
AND oee IS NOT NULL
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
"machineId",
|
||||||
|
ts,
|
||||||
|
prev_oee,
|
||||||
|
oee,
|
||||||
|
ABS(oee - prev_oee) AS delta
|
||||||
|
FROM k
|
||||||
|
WHERE prev_oee IS NOT NULL
|
||||||
|
AND ABS(oee - prev_oee) >= 25
|
||||||
|
ORDER BY delta DESC, ts DESC
|
||||||
|
LIMIT 200;
|
||||||
|
|
||||||
|
G. Trend point count comparison
|
||||||
|
SELECT
|
||||||
|
'all' AS series,
|
||||||
|
COUNT(*) AS points
|
||||||
|
FROM "MachineKpiSnapshot"
|
||||||
|
WHERE ts >= NOW() - INTERVAL '24 hours'
|
||||||
|
AND oee IS NOT NULL
|
||||||
|
UNION ALL
|
||||||
|
SELECT
|
||||||
|
'production_only' AS series,
|
||||||
|
COUNT(*) AS points
|
||||||
|
FROM "MachineKpiSnapshot"
|
||||||
|
WHERE ts >= NOW() - INTERVAL '24 hours'
|
||||||
|
AND oee IS NOT NULL
|
||||||
|
AND "trackingEnabled" = true
|
||||||
|
AND "productionStarted" = true;
|
||||||
|
|
||||||
|
3) Backfill run plan (must follow this order)
|
||||||
|
A. Dry-run first:
|
||||||
|
node scripts/backfill-downtime-reasons.mjs --dry-run --since 30d
|
||||||
|
|
||||||
|
B. Review dry-run output:
|
||||||
|
- candidates
|
||||||
|
- sampleUpdates
|
||||||
|
- incident distribution by machine
|
||||||
|
- any suspicious replacements
|
||||||
|
|
||||||
|
C. Apply scoped first (single machine from dry-run sample):
|
||||||
|
node scripts/backfill-downtime-reasons.mjs --since 30d --machine-id <machine_uuid>
|
||||||
|
|
||||||
|
4) SQL Verification Pack (POST-BACKFILL)
|
||||||
|
- Re-run queries A, B, C at minimum.
|
||||||
|
- Optionally rerun D/F/G for confidence.
|
||||||
|
- Confirm reduction in stale PENDIENTE/UNCLASSIFIED rows where authoritative reason exists.
|
||||||
|
- Confirm conflicting episode reason cases reduced or shifted as expected.
|
||||||
|
|
||||||
|
Acceptance criteria checklist:
|
||||||
|
- New downtime episodes retain authoritative manual reason and do not regress to PENDIENTE/UNCLASSIFIED.
|
||||||
|
- Fallback downtime continuity now keys by incidentKey whenever available; row.id only when absent.
|
||||||
|
- OEE trend no longer shows implausible 0/100 jumps from non-production snapshots.
|
||||||
|
- OEE chart is linear and visually shows true gaps (no smoothing continuity across filtered windows).
|
||||||
|
- Backfill dry-run and scoped apply outputs are captured and reasonable.
|
||||||
|
- Post-run SQL confirms expected improvements without obvious regressions.
|
||||||
|
|
||||||
|
Output format required from you:
|
||||||
|
1. Files changed with concise reason per file.
|
||||||
|
2. Exact diff summary for each modified file.
|
||||||
|
3. Test/lint commands run + result.
|
||||||
|
4. Pre-backfill SQL results (compact tables or summarized counts).
|
||||||
|
5. Dry-run output summary (key fields + sample updates).
|
||||||
|
6. Scoped apply command used and output summary.
|
||||||
|
7. Post-backfill SQL delta summary (before vs after).
|
||||||
|
8. Any blockers (env vars, DB auth, migration state, etc.) and exactly what is needed to unblock.
|
||||||
252
fix5.md
Normal file
252
fix5.md
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
Filter context noted: turno actual + ayer both flow through resolveDetailRange → getRecapDataCached → same loadWorkOrderCounterRows lifetime bug. Patch 1 below fixes lifetime numbers across all filter modes. The "Timeline 24h" label is hardcoded i18n key recap.timeline.title regardless of selected range — separate cosmetic bug. Patch list at end.
|
||||||
|
|
||||||
|
PATCH 1 — Window-scoped SKU + production totals
|
||||||
|
File: lib/recap/getRecapData.ts
|
||||||
|
Replaces: lines ~642–678 (the for (const row of machineWorkOrderCounters) loop, the openWorkOrders ensure loop, and the latestTelemetry ensure block)
|
||||||
|
BEFORE:
|
||||||
|
ts 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 target = safeNum(row.targetQty);
|
||||||
|
|
||||||
|
const skuAgg = ensureAuthoritativeSku(row.sku, target, false);
|
||||||
|
skuAgg.good += safeGood;
|
||||||
|
skuAgg.scrap += safeScrap;
|
||||||
|
|
||||||
|
goodParts += safeGood;
|
||||||
|
scrapParts += 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const wo of openWorkOrders) {
|
||||||
|
ensureAuthoritativeSku(normalizeToken(wo.sku) || null);
|
||||||
|
}
|
||||||
|
if (latestTelemetry?.sku) {
|
||||||
|
ensureAuthoritativeSku(latestTelemetry.sku);
|
||||||
|
}
|
||||||
|
AFTER:
|
||||||
|
ts // Step 1: WO-level LIFETIME progress map.
|
||||||
|
// Used downstream for completed-WO totals (goodParts/durationHrs) and active-WO progressPct,
|
||||||
|
// both of which intentionally want lifetime, not window-scoped, values.
|
||||||
|
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 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: WINDOW-SCOPED production totals + per-SKU breakdown from in-window cycle deltas.
|
||||||
|
// dedupedCycles is already filtered by ts >= start && ts <= end at the Prisma query level.
|
||||||
|
// Each cycle row contributes its own goodDelta/scrapDelta to the SKU it belongs to.
|
||||||
|
for (const cycle of dedupedCycles) {
|
||||||
|
const skuRaw = normalizeToken(cycle.sku);
|
||||||
|
const g = Math.max(0, Math.trunc(safeNum(cycle.goodDelta) ?? 0));
|
||||||
|
const s = Math.max(0, Math.trunc(safeNum(cycle.scrapDelta) ?? 0));
|
||||||
|
// Count the cycle row toward total cycles regardless of SKU (timing-only cycles still happened).
|
||||||
|
authoritativeCycleCount += 1;
|
||||||
|
if (g === 0 && s === 0) continue; // no production to attribute
|
||||||
|
goodParts += g;
|
||||||
|
scrapParts += s;
|
||||||
|
if (!skuRaw) continue; // production exists but no SKU tag — count totals, skip SKU table row
|
||||||
|
const skuAgg = ensureAuthoritativeSku(skuRaw, null, true);
|
||||||
|
skuAgg.good += g;
|
||||||
|
skuAgg.scrap += s;
|
||||||
|
}
|
||||||
|
What changes for the user:
|
||||||
|
|
||||||
|
BUENAS / SCRAP / SKU table = in-window only
|
||||||
|
Empty SKUs (open WOs that produced nothing in window, latest telemetry SKU) no longer pad the table
|
||||||
|
Completed WO list, active WO progress%, mold change logic = unchanged (still use lifetime via authoritativeWorkOrderProgress)
|
||||||
|
|
||||||
|
|
||||||
|
PATCH 2 — Unify machine-detail timeline range to 24h
|
||||||
|
File: app/(app)/machines/[machineId]/MachineDetailClient.tsx
|
||||||
|
Change 1 — function rename + range: find getMinuteFlooredOneHourRange (around line 365–373):
|
||||||
|
BEFORE:
|
||||||
|
tsfunction getMinuteFlooredOneHourRange() {
|
||||||
|
const endMs = Math.floor(Date.now() / 60000) * 60000;
|
||||||
|
return {
|
||||||
|
startMs: endMs - 60 * 60 * 1000,
|
||||||
|
endMs,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
AFTER:
|
||||||
|
tsfunction getMinuteFlooredDefaultRange() {
|
||||||
|
const endMs = Math.floor(Date.now() / 60000) * 60000;
|
||||||
|
return {
|
||||||
|
startMs: endMs - 24 * 60 * 60 * 1000,
|
||||||
|
endMs,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
Change 2 — call sites: there are two of them in MachineActivityTimeline (line ~388 inside loadTimeline, line ~427 for the fallback). Replace both:
|
||||||
|
BEFORE:
|
||||||
|
tsconst range = getMinuteFlooredOneHourRange();
|
||||||
|
tsconst fallbackRange = getMinuteFlooredOneHourRange();
|
||||||
|
AFTER:
|
||||||
|
tsconst range = getMinuteFlooredDefaultRange();
|
||||||
|
tsconst fallbackRange = getMinuteFlooredDefaultRange();
|
||||||
|
Change 3 — UI label: line ~447:
|
||||||
|
BEFORE:
|
||||||
|
tsx<div className="text-xs text-zinc-400">1h</div>
|
||||||
|
AFTER:
|
||||||
|
tsx<div className="text-xs text-zinc-400">24h</div>
|
||||||
|
After this, machine detail timeline = same backend, same range, same input as recap detail timeline → identical content (modulo cache age).
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
PATCH 3 — Dynamic timeline title that reflects the active filter
|
||||||
|
Reuses existing recap.range.* translation keys. No i18n file changes needed.
|
||||||
|
File A: components/recap/RecapFullTimeline.tsx
|
||||||
|
Change 1 — imports + type:
|
||||||
|
BEFORE (lines 1–22):
|
||||||
|
tsx"use client";
|
||||||
|
|
||||||
|
import type { RecapTimelineSegment } from "@/lib/recap/types";
|
||||||
|
import {
|
||||||
|
computeWidths,
|
||||||
|
formatDuration,
|
||||||
|
formatTime,
|
||||||
|
LABEL_MIN_WIDTH_PCT,
|
||||||
|
normalizeTimelineSegments,
|
||||||
|
SEGMENT_MIN_WIDTH_PCT,
|
||||||
|
TIMELINE_COLORS,
|
||||||
|
} from "@/components/recap/timelineRender";
|
||||||
|
import { useI18n } from "@/lib/i18n/useI18n";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
rangeStart: string;
|
||||||
|
rangeEnd: string;
|
||||||
|
segments: RecapTimelineSegment[];
|
||||||
|
locale: string;
|
||||||
|
hasData?: boolean;
|
||||||
|
loading?: boolean;
|
||||||
|
};
|
||||||
|
AFTER:
|
||||||
|
tsx"use client";
|
||||||
|
|
||||||
|
import type { RecapRangeMode, RecapTimelineSegment } from "@/lib/recap/types";
|
||||||
|
import {
|
||||||
|
computeWidths,
|
||||||
|
formatDuration,
|
||||||
|
formatTime,
|
||||||
|
LABEL_MIN_WIDTH_PCT,
|
||||||
|
normalizeTimelineSegments,
|
||||||
|
SEGMENT_MIN_WIDTH_PCT,
|
||||||
|
TIMELINE_COLORS,
|
||||||
|
} from "@/components/recap/timelineRender";
|
||||||
|
import { useI18n } from "@/lib/i18n/useI18n";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
rangeStart: string;
|
||||||
|
rangeEnd: string;
|
||||||
|
segments: RecapTimelineSegment[];
|
||||||
|
locale: string;
|
||||||
|
hasData?: boolean;
|
||||||
|
loading?: boolean;
|
||||||
|
rangeMode?: RecapRangeMode;
|
||||||
|
};
|
||||||
|
Change 2 — destructure prop + render dynamic title:
|
||||||
|
BEFORE (lines 24–42):
|
||||||
|
tsxexport default function RecapFullTimeline({
|
||||||
|
rangeStart,
|
||||||
|
rangeEnd,
|
||||||
|
segments,
|
||||||
|
locale,
|
||||||
|
hasData = false,
|
||||||
|
loading = false,
|
||||||
|
}: 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 = hasData ? normalizeTimelineSegments(segments, startMs, endMs) : [];
|
||||||
|
const widths = computeWidths(normalized, totalMs, SEGMENT_MIN_WIDTH_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>
|
||||||
|
AFTER:
|
||||||
|
tsxexport default function RecapFullTimeline({
|
||||||
|
rangeStart,
|
||||||
|
rangeEnd,
|
||||||
|
segments,
|
||||||
|
locale,
|
||||||
|
hasData = false,
|
||||||
|
loading = false,
|
||||||
|
rangeMode,
|
||||||
|
}: 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 = hasData ? normalizeTimelineSegments(segments, startMs, endMs) : [];
|
||||||
|
const widths = computeWidths(normalized, totalMs, SEGMENT_MIN_WIDTH_PCT);
|
||||||
|
|
||||||
|
const rangeSuffix =
|
||||||
|
rangeMode === "shift"
|
||||||
|
? t("recap.range.shiftCurrent")
|
||||||
|
: rangeMode === "yesterday"
|
||||||
|
? t("recap.range.yesterday")
|
||||||
|
: rangeMode === "custom"
|
||||||
|
? t("recap.range.custom")
|
||||||
|
: t("recap.range.24h");
|
||||||
|
const titleText = `${t("recap.timeline.title")} · ${rangeSuffix}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border border-white/10 bg-black/40 p-4">
|
||||||
|
<div className="mb-3 text-sm font-semibold text-white">{titleText}</div>
|
||||||
|
File B: app/(app)/recap/[machineId]/RecapDetailClient.tsx
|
||||||
|
BEFORE (around lines 215–222):
|
||||||
|
tsx <RecapFullTimeline
|
||||||
|
rangeStart={timelineStart}
|
||||||
|
rangeEnd={timelineEnd}
|
||||||
|
segments={timelineSegments}
|
||||||
|
hasData={timelineHasData}
|
||||||
|
loading={timelineLoading}
|
||||||
|
locale={locale}
|
||||||
|
/>
|
||||||
|
AFTER:
|
||||||
|
tsx <RecapFullTimeline
|
||||||
|
rangeStart={timelineStart}
|
||||||
|
rangeEnd={timelineEnd}
|
||||||
|
segments={timelineSegments}
|
||||||
|
hasData={timelineHasData}
|
||||||
|
loading={timelineLoading}
|
||||||
|
locale={locale}
|
||||||
|
rangeMode={initialData.range.mode}
|
||||||
|
/>
|
||||||
|
Optional bonus — change i18n value: lib/i18n/es-MX.json and lib/i18n/en.json, find key recap.timeline.title and change value from "Timeline 24h" (or whatever it currently is) to just "Timeline". The dynamic suffix will append the actual range. If you don't strip the "24h" from the value, the title will read "Timeline 24h · Ayer" when ayer is selected — still better than current, but cleaner if stripped.
|
||||||
107
fix6.md
Normal file
107
fix6.md
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
Patch 1 — Apply settings + update UI function node (PRIMARY)
|
||||||
|
Node: Apply settings + update UI (function node)
|
||||||
|
Action: Replace the entire normalizeCatalogItems definition.
|
||||||
|
FIND this block (lines ~58–76 of the function):
|
||||||
|
javascriptconst normalizeCatalogItems = (list, fallbackLabelPrefix) => {
|
||||||
|
if (!Array.isArray(list)) return [];
|
||||||
|
return list
|
||||||
|
.map((c, idx) => {
|
||||||
|
const categoryId = String(c.id || c.categoryId || ("cat_" + idx));
|
||||||
|
const categoryLabel = String(c.label || c.categoryLabel || (fallbackLabelPrefix + " " + (idx + 1)));
|
||||||
|
const detailsRaw = Array.isArray(c.children) ? c.children : (Array.isArray(c.details) ? c.details : []);
|
||||||
|
const details = detailsRaw.map((d, jdx) => ({
|
||||||
|
id: String(d.id || d.detailId || (categoryId + "_d" + jdx)),
|
||||||
|
label: String(d.label || d.detailLabel || ("Detalle " + (jdx + 1)))
|
||||||
|
}));
|
||||||
|
return {
|
||||||
|
id: categoryId,
|
||||||
|
label: categoryLabel,
|
||||||
|
children: details
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((c) => c.label && c.children.length > 0);
|
||||||
|
};
|
||||||
|
REPLACE with:
|
||||||
|
javascript// ============================================================
|
||||||
|
// CATALOG SANITIZER
|
||||||
|
// Defense against leaked markdown/spec text being stored as
|
||||||
|
// catalog labels in Control Tower. Rejects entries whose label
|
||||||
|
// looks like documentation/notes rather than a real reason.
|
||||||
|
// Tune MAX_LABEL_LEN if your real labels are longer.
|
||||||
|
// ============================================================
|
||||||
|
const MAX_LABEL_LEN = 40;
|
||||||
|
|
||||||
|
const isCleanLabel = (s) => {
|
||||||
|
if (typeof s !== "string") return false;
|
||||||
|
const t = s.trim();
|
||||||
|
if (!t) return false;
|
||||||
|
if (t.length > MAX_LABEL_LEN) return false; // sentence-length text
|
||||||
|
if (/[\r\n\t]/.test(t)) return false; // multi-line content
|
||||||
|
if (/^[-*#>|`\[\]]/.test(t)) return false; // markdown leaders: - * # > | ` [ ]
|
||||||
|
if (/\*\*|__|```|~~~|###/.test(t)) return false; // markdown bold/code/heading
|
||||||
|
if (/[(\[<{][^)\]>}]*$/.test(t)) return false; // unbalanced opening bracket → truncated
|
||||||
|
if (/=/.test(t)) return false; // code-like assignment (e.g. type=event)
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeCatalogItems = (list, fallbackLabelPrefix) => {
|
||||||
|
if (!Array.isArray(list)) return [];
|
||||||
|
|
||||||
|
const dropped = [];
|
||||||
|
|
||||||
|
const cleaned = list
|
||||||
|
.map((c, idx) => {
|
||||||
|
const categoryId = String(c.id || c.categoryId || ("cat_" + idx));
|
||||||
|
const categoryLabel = String(
|
||||||
|
c.label || c.categoryLabel || (fallbackLabelPrefix + " " + (idx + 1))
|
||||||
|
).trim();
|
||||||
|
|
||||||
|
const detailsRaw = Array.isArray(c.children)
|
||||||
|
? c.children
|
||||||
|
: (Array.isArray(c.details) ? c.details : []);
|
||||||
|
|
||||||
|
const details = detailsRaw
|
||||||
|
.map((d, jdx) => ({
|
||||||
|
id: String(d.id || d.detailId || (categoryId + "_d" + jdx)),
|
||||||
|
label: String(d.label || d.detailLabel || ("Detalle " + (jdx + 1))).trim()
|
||||||
|
}))
|
||||||
|
.filter((d) => {
|
||||||
|
if (isCleanLabel(d.label)) return true;
|
||||||
|
dropped.push("detail<" + categoryLabel.slice(0, 20) + ">: " + d.label.slice(0, 50));
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
return { id: categoryId, label: categoryLabel, children: details };
|
||||||
|
})
|
||||||
|
.filter((c) => {
|
||||||
|
if (!isCleanLabel(c.label)) {
|
||||||
|
dropped.push("category: " + c.label.slice(0, 50));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (c.children.length === 0) {
|
||||||
|
dropped.push("empty: " + c.label.slice(0, 50));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (dropped.length > 0) {
|
||||||
|
node.warn(
|
||||||
|
"[CATALOG SANITIZER " + fallbackLabelPrefix + "] Dropped " +
|
||||||
|
dropped.length + " polluted entries:\n - " +
|
||||||
|
dropped.slice(0, 15).join("\n - ") +
|
||||||
|
(dropped.length > 15 ? "\n ... (+" + (dropped.length - 15) + " more)" : "")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return cleaned;
|
||||||
|
};
|
||||||
|
Side effects:
|
||||||
|
|
||||||
|
Function signature unchanged → no other code in this node needs to change.
|
||||||
|
The two call sites (incomingCatalog.downtime, incomingCatalog.scrap) work identically.
|
||||||
|
node.warn will fire on every settings sync that has dirty data — this is intentional so you see when CT pushes garbage.
|
||||||
|
A category whose children are all polluted will be dropped (it'd be useless anyway).
|
||||||
|
dropped only logs first 15 to avoid debug-pane spam.
|
||||||
|
|
||||||
|
Risk on legit data: MAX_LABEL_LEN = 40 will reject labels longer than 40 chars. If your real catalog has labels like "Falla mecánica del extrusor principal con sensor" (49), bump this to 60. The shortest known false-negative in your current data ("Tap Acknowledge on anomaly panel", 32 chars) still slips through — see Patch 2 below or upstream cleanup.
|
||||||
1
flows (61) (1).json
Normal file
1
flows (61) (1).json
Normal file
File diff suppressed because one or more lines are too long
3867
flows (63).json
Normal file
3867
flows (63).json
Normal file
File diff suppressed because one or more lines are too long
4246
flows (64).json
Normal file
4246
flows (64).json
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
4288
flows_may_4_26.json
Normal file
4288
flows_may_4_26.json
Normal file
File diff suppressed because one or more lines are too long
@@ -38,6 +38,7 @@ type AlertsInboxEvent = {
|
|||||||
status?: string | null;
|
status?: string | null;
|
||||||
shift?: string | null;
|
shift?: string | null;
|
||||||
alertId?: string | null;
|
alertId?: string | null;
|
||||||
|
incidentKey?: string | null;
|
||||||
isUpdate?: boolean;
|
isUpdate?: boolean;
|
||||||
isAutoAck?: boolean;
|
isAutoAck?: boolean;
|
||||||
};
|
};
|
||||||
@@ -224,29 +225,34 @@ function resolveShift(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function collapseAlertEvents(events: AlertsInboxEvent[]) {
|
function collapseAlertEvents(events: AlertsInboxEvent[]) {
|
||||||
const byAlert = new Map<string, AlertsInboxEvent>();
|
// Group by incidentKey (preferred — stable across the entire incident lifecycle)
|
||||||
|
// OR alertId (fallback — for older or non-stoppage events).
|
||||||
|
// Per group, keep AT MOST one "active" (oldest = when it first happened) and
|
||||||
|
// one "resolved" (newest = when it actually ended). Result: max 2 entries per incident.
|
||||||
|
const byGroup = new Map<string, AlertsInboxEvent>();
|
||||||
const passthrough: AlertsInboxEvent[] = [];
|
const passthrough: AlertsInboxEvent[] = [];
|
||||||
|
|
||||||
for (const ev of events) {
|
for (const ev of events) {
|
||||||
if (!ev.alertId) {
|
const groupId = ev.incidentKey ?? ev.alertId;
|
||||||
|
if (!groupId) {
|
||||||
passthrough.push(ev);
|
passthrough.push(ev);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const statusKey = ev.status === "resolved" ? "resolved" : "active";
|
const statusKey = ev.status === "resolved" ? "resolved" : "active";
|
||||||
const key = `${ev.alertId}:${statusKey}`;
|
const key = `${groupId}:${statusKey}`;
|
||||||
const existing = byAlert.get(key);
|
const existing = byGroup.get(key);
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
byAlert.set(key, ev);
|
byGroup.set(key, ev);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const pickNewest = statusKey === "resolved";
|
const pickNewest = statusKey === "resolved";
|
||||||
const shouldReplace = pickNewest
|
const shouldReplace = pickNewest
|
||||||
? ev.ts.getTime() > existing.ts.getTime()
|
? ev.ts.getTime() > existing.ts.getTime()
|
||||||
: ev.ts.getTime() < existing.ts.getTime();
|
: ev.ts.getTime() < existing.ts.getTime();
|
||||||
if (shouldReplace) byAlert.set(key, ev);
|
if (shouldReplace) byGroup.set(key, ev);
|
||||||
}
|
}
|
||||||
|
|
||||||
const combined = [...passthrough, ...byAlert.values()];
|
const combined = [...passthrough, ...byGroup.values()];
|
||||||
combined.sort((a, b) => b.ts.getTime() - a.ts.getTime());
|
combined.sort((a, b) => b.ts.getTime() - a.ts.getTime());
|
||||||
return combined;
|
return combined;
|
||||||
}
|
}
|
||||||
@@ -325,7 +331,12 @@ export async function getAlertsInboxData(params: AlertsInboxParams) {
|
|||||||
const rawStatus = safeString(payload?.status ?? inner?.status);
|
const rawStatus = safeString(payload?.status ?? inner?.status);
|
||||||
const isUpdate = safeBool(payload?.is_update ?? inner?.is_update);
|
const isUpdate = safeBool(payload?.is_update ?? inner?.is_update);
|
||||||
const isAutoAck = safeBool(payload?.is_auto_ack ?? inner?.is_auto_ack);
|
const isAutoAck = safeBool(payload?.is_auto_ack ?? inner?.is_auto_ack);
|
||||||
if (!includeUpdates && (isUpdate || isAutoAck)) continue;
|
// Drop only auto-ack pings (every-10s refresh noise).
|
||||||
|
// Keep is_update events: due to a Node-RED spread inheritance pattern,
|
||||||
|
// virtually all events carry is_update=true even legitimate first-emission
|
||||||
|
// and cycle-arrival resolved events. Dedup happens via collapseAlertEvents
|
||||||
|
// grouping by incidentKey below.
|
||||||
|
if (!includeUpdates && isAutoAck) continue;
|
||||||
|
|
||||||
const shiftName = resolveShift(shifts, shiftOverrides, ev.ts, timeZone);
|
const shiftName = resolveShift(shifts, shiftOverrides, ev.ts, timeZone);
|
||||||
if (normalizedShift && shiftName !== normalizedShift) continue;
|
if (normalizedShift && shiftName !== normalizedShift) continue;
|
||||||
@@ -349,6 +360,7 @@ export async function getAlertsInboxData(params: AlertsInboxParams) {
|
|||||||
status: statusLabel,
|
status: statusLabel,
|
||||||
shift: shiftName,
|
shift: shiftName,
|
||||||
alertId: safeString(payload?.alert_id ?? inner?.alert_id),
|
alertId: safeString(payload?.alert_id ?? inner?.alert_id),
|
||||||
|
incidentKey: safeString(payload?.incidentKey ?? payload?.incident_key ?? inner?.incidentKey ?? inner?.incident_key),
|
||||||
isUpdate,
|
isUpdate,
|
||||||
isAutoAck,
|
isAutoAck,
|
||||||
});
|
});
|
||||||
|
|||||||
363
lib/alerts/getAlertsInboxData.ts.bak
Normal file
363
lib/alerts/getAlertsInboxData.ts.bak
Normal file
@@ -0,0 +1,363 @@
|
|||||||
|
import { normalizeShiftOverrides } from "@/lib/settings";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
const RANGE_MS: Record<string, number> = {
|
||||||
|
"24h": 24 * 60 * 60 * 1000,
|
||||||
|
"7d": 7 * 24 * 60 * 60 * 1000,
|
||||||
|
"30d": 30 * 24 * 60 * 60 * 1000,
|
||||||
|
};
|
||||||
|
|
||||||
|
type AlertsInboxParams = {
|
||||||
|
orgId: string;
|
||||||
|
range?: string;
|
||||||
|
start?: Date | null;
|
||||||
|
end?: Date | null;
|
||||||
|
machineId?: string;
|
||||||
|
location?: string;
|
||||||
|
eventType?: string;
|
||||||
|
severity?: string;
|
||||||
|
status?: string;
|
||||||
|
shift?: string;
|
||||||
|
includeUpdates?: boolean;
|
||||||
|
limit?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AlertsInboxEvent = {
|
||||||
|
id: string;
|
||||||
|
ts: Date;
|
||||||
|
eventType: string;
|
||||||
|
severity: string;
|
||||||
|
title: string;
|
||||||
|
description?: string | null;
|
||||||
|
machineId: string;
|
||||||
|
machineName?: string | null;
|
||||||
|
location?: string | null;
|
||||||
|
workOrderId?: string | null;
|
||||||
|
sku?: string | null;
|
||||||
|
durationSec?: number | null;
|
||||||
|
status?: string | null;
|
||||||
|
shift?: string | null;
|
||||||
|
alertId?: string | null;
|
||||||
|
isUpdate?: boolean;
|
||||||
|
isAutoAck?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function pickRange(range: string, start?: Date | null, end?: Date | null) {
|
||||||
|
const now = new Date();
|
||||||
|
if (range === "custom") {
|
||||||
|
const startFallback = new Date(now.getTime() - RANGE_MS["24h"]);
|
||||||
|
return {
|
||||||
|
range,
|
||||||
|
start: start ?? startFallback,
|
||||||
|
end: end ?? now,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const ms = RANGE_MS[range] ?? RANGE_MS["24h"];
|
||||||
|
return { range, start: new Date(now.getTime() - ms), end: now };
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeString(value: unknown) {
|
||||||
|
if (typeof value !== "string") return null;
|
||||||
|
const trimmed = value.trim();
|
||||||
|
return trimmed ? trimmed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeNumber(value: unknown) {
|
||||||
|
const n = typeof value === "number" ? value : Number(value);
|
||||||
|
return Number.isFinite(n) ? n : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeBool(value: unknown) {
|
||||||
|
return value === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeStatus(value?: string | null) {
|
||||||
|
if (!value) return null;
|
||||||
|
const raw = value.trim().toLowerCase();
|
||||||
|
if (!raw) return null;
|
||||||
|
if (raw === "in_progress" || raw === "in-progress" || raw === "open" || raw === "activa" || raw === "activo") {
|
||||||
|
return "active";
|
||||||
|
}
|
||||||
|
if (raw === "resuelta" || raw === "resuelto" || raw === "closed" || raw === "ended" || raw === "done") {
|
||||||
|
return "resolved";
|
||||||
|
}
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePayload(raw: unknown) {
|
||||||
|
let parsed: unknown = raw;
|
||||||
|
if (typeof raw === "string") {
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
parsed = raw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const payload =
|
||||||
|
parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
||||||
|
? (parsed as Record<string, unknown>)
|
||||||
|
: {};
|
||||||
|
const innerCandidate = payload.data;
|
||||||
|
const inner =
|
||||||
|
innerCandidate && typeof innerCandidate === "object" && !Array.isArray(innerCandidate)
|
||||||
|
? (innerCandidate as Record<string, unknown>)
|
||||||
|
: payload;
|
||||||
|
return { payload, inner };
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractDurationSec(raw: unknown) {
|
||||||
|
const { payload, inner } = parsePayload(raw);
|
||||||
|
const candidates = [
|
||||||
|
inner?.duration_seconds,
|
||||||
|
inner?.duration_sec,
|
||||||
|
inner?.stoppage_duration_seconds,
|
||||||
|
inner?.stop_duration_seconds,
|
||||||
|
payload?.duration_seconds,
|
||||||
|
payload?.duration_sec,
|
||||||
|
payload?.stoppage_duration_seconds,
|
||||||
|
payload?.stop_duration_seconds,
|
||||||
|
];
|
||||||
|
for (const val of candidates) {
|
||||||
|
if (typeof val === "number" && Number.isFinite(val) && val >= 0) return val;
|
||||||
|
}
|
||||||
|
const msCandidates = [inner?.duration_ms, inner?.durationMs, payload?.duration_ms, payload?.durationMs];
|
||||||
|
for (const val of msCandidates) {
|
||||||
|
if (typeof val === "number" && Number.isFinite(val) && val >= 0) {
|
||||||
|
return Math.round(val / 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const startMs = inner.start_ts ?? inner.startTs ?? payload.start_ts ?? payload.startTs ?? null;
|
||||||
|
const endMs = inner.end_ts ?? inner.endTs ?? payload.end_ts ?? payload.endTs ?? null;
|
||||||
|
if (typeof startMs === "number" && typeof endMs === "number" && endMs >= startMs) {
|
||||||
|
return Math.round((endMs - startMs) / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
const actual = safeNumber(inner.actual_cycle_time ?? payload.actual_cycle_time);
|
||||||
|
const theoretical = safeNumber(inner.theoretical_cycle_time ?? payload.theoretical_cycle_time);
|
||||||
|
if (actual != null && theoretical != null) {
|
||||||
|
return Math.max(0, actual - theoretical);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTimeMinutes(value?: string | null) {
|
||||||
|
if (!value || !/^\d{2}:\d{2}$/.test(value)) return null;
|
||||||
|
const [hh, mm] = value.split(":").map((n) => Number(n));
|
||||||
|
if (!Number.isFinite(hh) || !Number.isFinite(mm)) return null;
|
||||||
|
return hh * 60 + mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLocalMinutes(ts: Date, timeZone: string) {
|
||||||
|
try {
|
||||||
|
const parts = new Intl.DateTimeFormat("en-US", {
|
||||||
|
timeZone,
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
hourCycle: "h23",
|
||||||
|
}).formatToParts(ts);
|
||||||
|
const hours = Number(parts.find((p) => p.type === "hour")?.value ?? "0");
|
||||||
|
const minutes = Number(parts.find((p) => p.type === "minute")?.value ?? "0");
|
||||||
|
return hours * 60 + minutes;
|
||||||
|
} catch {
|
||||||
|
return ts.getUTCHours() * 60 + ts.getUTCMinutes();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const WEEKDAY_KEY_MAP: Record<string, string> = {
|
||||||
|
Sun: "sun",
|
||||||
|
Mon: "mon",
|
||||||
|
Tue: "tue",
|
||||||
|
Wed: "wed",
|
||||||
|
Thu: "thu",
|
||||||
|
Fri: "fri",
|
||||||
|
Sat: "sat",
|
||||||
|
};
|
||||||
|
|
||||||
|
const WEEKDAY_KEYS = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"] as const;
|
||||||
|
|
||||||
|
function getLocalDayKey(ts: Date, timeZone: string) {
|
||||||
|
try {
|
||||||
|
const weekday = new Intl.DateTimeFormat("en-US", {
|
||||||
|
timeZone,
|
||||||
|
weekday: "short",
|
||||||
|
}).format(ts);
|
||||||
|
return WEEKDAY_KEY_MAP[weekday] ?? WEEKDAY_KEYS[ts.getUTCDay()];
|
||||||
|
} catch {
|
||||||
|
return WEEKDAY_KEYS[ts.getUTCDay()];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type ShiftLike = {
|
||||||
|
name: string;
|
||||||
|
startTime?: string | null;
|
||||||
|
endTime?: string | null;
|
||||||
|
start?: string | null;
|
||||||
|
end?: string | null;
|
||||||
|
enabled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function resolveShift(
|
||||||
|
shifts: ShiftLike[],
|
||||||
|
overrides: Record<string, ShiftLike[]> | undefined,
|
||||||
|
ts: Date,
|
||||||
|
timeZone: string
|
||||||
|
) {
|
||||||
|
const dayKey = getLocalDayKey(ts, timeZone);
|
||||||
|
const dayOverrides = overrides?.[dayKey];
|
||||||
|
const activeShifts = dayOverrides ?? shifts;
|
||||||
|
if (!activeShifts.length) return null;
|
||||||
|
const nowMin = getLocalMinutes(ts, timeZone);
|
||||||
|
for (const shift of activeShifts) {
|
||||||
|
if (shift.enabled === false) continue;
|
||||||
|
const start = parseTimeMinutes(shift.startTime ?? shift.start ?? null);
|
||||||
|
const end = parseTimeMinutes(shift.endTime ?? shift.end ?? null);
|
||||||
|
if (start == null || end == null) continue;
|
||||||
|
if (start <= end) {
|
||||||
|
if (nowMin >= start && nowMin < end) return shift.name;
|
||||||
|
} else {
|
||||||
|
if (nowMin >= start || nowMin < end) return shift.name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collapseAlertEvents(events: AlertsInboxEvent[]) {
|
||||||
|
const byAlert = new Map<string, AlertsInboxEvent>();
|
||||||
|
const passthrough: AlertsInboxEvent[] = [];
|
||||||
|
|
||||||
|
for (const ev of events) {
|
||||||
|
if (!ev.alertId) {
|
||||||
|
passthrough.push(ev);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const statusKey = ev.status === "resolved" ? "resolved" : "active";
|
||||||
|
const key = `${ev.alertId}:${statusKey}`;
|
||||||
|
const existing = byAlert.get(key);
|
||||||
|
if (!existing) {
|
||||||
|
byAlert.set(key, ev);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const pickNewest = statusKey === "resolved";
|
||||||
|
const shouldReplace = pickNewest
|
||||||
|
? ev.ts.getTime() > existing.ts.getTime()
|
||||||
|
: ev.ts.getTime() < existing.ts.getTime();
|
||||||
|
if (shouldReplace) byAlert.set(key, ev);
|
||||||
|
}
|
||||||
|
|
||||||
|
const combined = [...passthrough, ...byAlert.values()];
|
||||||
|
combined.sort((a, b) => b.ts.getTime() - a.ts.getTime());
|
||||||
|
return combined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAlertsInboxData(params: AlertsInboxParams) {
|
||||||
|
const {
|
||||||
|
orgId,
|
||||||
|
range = "24h",
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
machineId,
|
||||||
|
location,
|
||||||
|
eventType,
|
||||||
|
severity,
|
||||||
|
status,
|
||||||
|
shift,
|
||||||
|
includeUpdates = false,
|
||||||
|
limit = 200,
|
||||||
|
} = params;
|
||||||
|
|
||||||
|
const picked = pickRange(range, start, end);
|
||||||
|
const normalizedStatus = safeString(status)?.toLowerCase();
|
||||||
|
const normalizedShift = safeString(shift);
|
||||||
|
const safeLimit = Number.isFinite(limit) ? Math.min(Math.max(limit, 1), 500) : 200;
|
||||||
|
|
||||||
|
const where = {
|
||||||
|
orgId,
|
||||||
|
ts: { gte: picked.start, lte: picked.end },
|
||||||
|
...(machineId ? { machineId } : {}),
|
||||||
|
...(eventType ? { eventType } : {}),
|
||||||
|
...(severity ? { severity } : {}),
|
||||||
|
...(location ? { machine: { location } } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const [events, shifts, settings] = await Promise.all([
|
||||||
|
prisma.machineEvent.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { ts: "desc" },
|
||||||
|
take: safeLimit,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
ts: true,
|
||||||
|
eventType: true,
|
||||||
|
severity: true,
|
||||||
|
title: true,
|
||||||
|
description: true,
|
||||||
|
data: true,
|
||||||
|
machineId: true,
|
||||||
|
workOrderId: true,
|
||||||
|
sku: true,
|
||||||
|
machine: {
|
||||||
|
select: {
|
||||||
|
name: true,
|
||||||
|
location: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.orgShift.findMany({
|
||||||
|
where: { orgId },
|
||||||
|
orderBy: { sortOrder: "asc" },
|
||||||
|
select: { name: true, startTime: true, endTime: true, enabled: true },
|
||||||
|
}),
|
||||||
|
prisma.orgSettings.findUnique({
|
||||||
|
where: { orgId },
|
||||||
|
select: { timezone: true, shiftScheduleOverridesJson: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const timeZone = settings?.timezone || "UTC";
|
||||||
|
const shiftOverrides = normalizeShiftOverrides(settings?.shiftScheduleOverridesJson);
|
||||||
|
const mapped: AlertsInboxEvent[] = [];
|
||||||
|
|
||||||
|
for (const ev of events) {
|
||||||
|
const { payload, inner } = parsePayload(ev.data);
|
||||||
|
const rawStatus = safeString(payload?.status ?? inner?.status);
|
||||||
|
const isUpdate = safeBool(payload?.is_update ?? inner?.is_update);
|
||||||
|
const isAutoAck = safeBool(payload?.is_auto_ack ?? inner?.is_auto_ack);
|
||||||
|
if (!includeUpdates && (isUpdate || isAutoAck)) continue;
|
||||||
|
|
||||||
|
const shiftName = resolveShift(shifts, shiftOverrides, ev.ts, timeZone);
|
||||||
|
if (normalizedShift && shiftName !== normalizedShift) continue;
|
||||||
|
|
||||||
|
const statusLabel = normalizeStatus(rawStatus) ?? "unknown";
|
||||||
|
if (normalizedStatus && statusLabel !== normalizedStatus) continue;
|
||||||
|
|
||||||
|
mapped.push({
|
||||||
|
id: ev.id,
|
||||||
|
ts: ev.ts,
|
||||||
|
eventType: ev.eventType,
|
||||||
|
severity: ev.severity,
|
||||||
|
title: ev.title,
|
||||||
|
description: ev.description,
|
||||||
|
machineId: ev.machineId,
|
||||||
|
machineName: ev.machine?.name ?? null,
|
||||||
|
location: ev.machine?.location ?? null,
|
||||||
|
workOrderId: ev.workOrderId ?? null,
|
||||||
|
sku: ev.sku ?? null,
|
||||||
|
durationSec: extractDurationSec(ev.data),
|
||||||
|
status: statusLabel,
|
||||||
|
shift: shiftName,
|
||||||
|
alertId: safeString(payload?.alert_id ?? inner?.alert_id),
|
||||||
|
isUpdate,
|
||||||
|
isAutoAck,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalEvents = includeUpdates ? mapped : collapseAlertEvents(mapped);
|
||||||
|
|
||||||
|
return {
|
||||||
|
range: { range: picked.range, start: picked.start, end: picked.end },
|
||||||
|
events: finalEvents,
|
||||||
|
};
|
||||||
|
}
|
||||||
204
lib/analytics/downtimeFilters.ts
Normal file
204
lib/analytics/downtimeFilters.ts
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { normalizeShiftOverrides } from "@/lib/settings";
|
||||||
|
|
||||||
|
type PlannedFilter = "all" | "planned" | "unplanned";
|
||||||
|
type ShiftFilter = "all" | "A" | "B" | "C";
|
||||||
|
|
||||||
|
type ShiftLike = {
|
||||||
|
name: string;
|
||||||
|
startTime?: string | null;
|
||||||
|
endTime?: string | null;
|
||||||
|
start?: string | null;
|
||||||
|
end?: string | null;
|
||||||
|
enabled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ShiftContext = {
|
||||||
|
timeZone: string;
|
||||||
|
shifts: ShiftLike[];
|
||||||
|
overrides: Record<string, ShiftLike[]> | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SHIFT_ALIAS: ShiftFilter[] = ["A", "B", "C"];
|
||||||
|
const TIME_RE = /^([01]\d|2[0-3]):([0-5]\d)$/;
|
||||||
|
|
||||||
|
const WEEKDAY_KEY_MAP: Record<string, string> = {
|
||||||
|
Sun: "sun",
|
||||||
|
Mon: "mon",
|
||||||
|
Tue: "tue",
|
||||||
|
Wed: "wed",
|
||||||
|
Thu: "thu",
|
||||||
|
Fri: "fri",
|
||||||
|
Sat: "sat",
|
||||||
|
};
|
||||||
|
|
||||||
|
const WEEKDAY_KEYS = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"] as const;
|
||||||
|
|
||||||
|
function asRecord(value: unknown) {
|
||||||
|
return value && typeof value === "object" && !Array.isArray(value)
|
||||||
|
? (value as Record<string, unknown>)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTimeMinutes(value?: string | null) {
|
||||||
|
if (!value || !TIME_RE.test(value)) return null;
|
||||||
|
const [hh, mm] = value.split(":");
|
||||||
|
return Number(hh) * 60 + Number(mm);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLocalMinutes(ts: Date, timeZone: string) {
|
||||||
|
try {
|
||||||
|
const parts = new Intl.DateTimeFormat("en-US", {
|
||||||
|
timeZone,
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
hourCycle: "h23",
|
||||||
|
}).formatToParts(ts);
|
||||||
|
const hours = Number(parts.find((p) => p.type === "hour")?.value ?? "0");
|
||||||
|
const minutes = Number(parts.find((p) => p.type === "minute")?.value ?? "0");
|
||||||
|
return hours * 60 + minutes;
|
||||||
|
} catch {
|
||||||
|
return ts.getUTCHours() * 60 + ts.getUTCMinutes();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLocalDayKey(ts: Date, timeZone: string) {
|
||||||
|
try {
|
||||||
|
const weekday = new Intl.DateTimeFormat("en-US", {
|
||||||
|
timeZone,
|
||||||
|
weekday: "short",
|
||||||
|
}).format(ts);
|
||||||
|
return WEEKDAY_KEY_MAP[weekday] ?? WEEKDAY_KEYS[ts.getUTCDay()];
|
||||||
|
} catch {
|
||||||
|
return WEEKDAY_KEYS[ts.getUTCDay()];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveShiftAlias(context: ShiftContext, ts: Date): ShiftFilter | null {
|
||||||
|
const dayKey = getLocalDayKey(ts, context.timeZone);
|
||||||
|
const dayOverrides = context.overrides?.[dayKey];
|
||||||
|
const activeShifts = dayOverrides ?? context.shifts;
|
||||||
|
if (!activeShifts.length) return null;
|
||||||
|
|
||||||
|
const nowMin = getLocalMinutes(ts, context.timeZone);
|
||||||
|
let enabledOrdinal = 0;
|
||||||
|
for (const shift of activeShifts) {
|
||||||
|
if (shift.enabled === false) continue;
|
||||||
|
const start = parseTimeMinutes(shift.startTime ?? shift.start ?? null);
|
||||||
|
const end = parseTimeMinutes(shift.endTime ?? shift.end ?? null);
|
||||||
|
if (start == null || end == null) continue;
|
||||||
|
|
||||||
|
const alias = SHIFT_ALIAS[enabledOrdinal] ?? null;
|
||||||
|
enabledOrdinal += 1;
|
||||||
|
if (!alias) continue;
|
||||||
|
|
||||||
|
if (start <= end) {
|
||||||
|
if (nowMin >= start && nowMin < end) return alias;
|
||||||
|
} else if (nowMin >= start || nowMin < end) {
|
||||||
|
return alias;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMicrostopLike(row: {
|
||||||
|
episodeId?: string | null;
|
||||||
|
meta?: unknown;
|
||||||
|
}) {
|
||||||
|
const episodeId = String(row.episodeId ?? "").toLowerCase();
|
||||||
|
if (episodeId.startsWith("microstop:")) return true;
|
||||||
|
|
||||||
|
const meta = asRecord(row.meta);
|
||||||
|
const anomalyType = String(meta?.anomalyType ?? "").toLowerCase();
|
||||||
|
if (anomalyType === "microstop") return true;
|
||||||
|
|
||||||
|
const eventType = String(meta?.eventType ?? "").toLowerCase();
|
||||||
|
return eventType === "microstop";
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePlanned(raw: string | null): PlannedFilter {
|
||||||
|
const v = String(raw ?? "").trim().toLowerCase();
|
||||||
|
if (v === "planned") return "planned";
|
||||||
|
if (v === "unplanned") return "unplanned";
|
||||||
|
return "all";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolvePlannedFilter(raw: string | null, includeMoldChange: boolean): PlannedFilter {
|
||||||
|
const normalized = normalizePlanned(raw);
|
||||||
|
if (raw != null && String(raw).trim() !== "") return normalized;
|
||||||
|
return includeMoldChange ? "all" : "unplanned";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeShiftFilter(raw: string | null): ShiftFilter {
|
||||||
|
const v = String(raw ?? "").trim().toUpperCase();
|
||||||
|
if (v === "A" || v === "B" || v === "C") return v;
|
||||||
|
return "all";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeMicrostopLtMin(raw: string | null) {
|
||||||
|
if (!raw) return null;
|
||||||
|
const n = Number(raw);
|
||||||
|
if (!Number.isFinite(n) || n <= 0) return null;
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
function passesPlannedFilter(reasonCode: string, planned: PlannedFilter) {
|
||||||
|
if (planned === "planned") return reasonCode === "MOLD_CHANGE";
|
||||||
|
if (planned === "unplanned") return reasonCode !== "MOLD_CHANGE";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadDowntimeShiftContext(orgId: string): Promise<ShiftContext> {
|
||||||
|
const [shifts, settings] = await Promise.all([
|
||||||
|
prisma.orgShift.findMany({
|
||||||
|
where: { orgId },
|
||||||
|
orderBy: { sortOrder: "asc" },
|
||||||
|
select: { name: true, startTime: true, endTime: true, enabled: true },
|
||||||
|
}),
|
||||||
|
prisma.orgSettings.findUnique({
|
||||||
|
where: { orgId },
|
||||||
|
select: { timezone: true, shiftScheduleOverridesJson: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
timeZone: settings?.timezone || "UTC",
|
||||||
|
shifts,
|
||||||
|
overrides: normalizeShiftOverrides(settings?.shiftScheduleOverridesJson),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyDowntimeFilters<T extends {
|
||||||
|
reasonCode: string;
|
||||||
|
capturedAt: Date;
|
||||||
|
durationSeconds?: number | null;
|
||||||
|
episodeId?: string | null;
|
||||||
|
meta?: unknown;
|
||||||
|
}>(
|
||||||
|
rows: T[],
|
||||||
|
options: {
|
||||||
|
planned: PlannedFilter;
|
||||||
|
shift: ShiftFilter;
|
||||||
|
microstopLtMin: number | null;
|
||||||
|
shiftContext: ShiftContext | null;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
return rows.filter((row) => {
|
||||||
|
if (!passesPlannedFilter(row.reasonCode, options.planned)) return false;
|
||||||
|
|
||||||
|
if (options.shift !== "all") {
|
||||||
|
if (!options.shiftContext) return false;
|
||||||
|
const alias = resolveShiftAlias(options.shiftContext, row.capturedAt);
|
||||||
|
if (alias !== options.shift) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.microstopLtMin != null && isMicrostopLike(row)) {
|
||||||
|
if (row.durationSeconds == null) return false;
|
||||||
|
const durationMin = row.durationSeconds / 60;
|
||||||
|
if (!(durationMin < options.microstopLtMin)) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
25
lib/auth/requireOrgAdminSession.ts
Normal file
25
lib/auth/requireOrgAdminSession.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { requireSession } from "@/lib/auth/requireSession";
|
||||||
|
|
||||||
|
export type OrgAdminSession = { orgId: string; userId: string };
|
||||||
|
|
||||||
|
export async function requireOrgAdminSession(): Promise<
|
||||||
|
{ ok: true; session: OrgAdminSession } | { ok: false; response: NextResponse }
|
||||||
|
> {
|
||||||
|
const session = await requireSession();
|
||||||
|
if (!session) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
response: NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const membership = await prisma.orgUser.findUnique({
|
||||||
|
where: { orgId_userId: { orgId: session.orgId, userId: session.userId } },
|
||||||
|
select: { role: true },
|
||||||
|
});
|
||||||
|
if (membership?.role !== "OWNER" && membership?.role !== "ADMIN") {
|
||||||
|
return { ok: false, response: NextResponse.json({ ok: false, error: "Forbidden" }, { status: 403 }) };
|
||||||
|
}
|
||||||
|
return { ok: true, session: { orgId: session.orgId, userId: session.userId } };
|
||||||
|
}
|
||||||
@@ -244,6 +244,7 @@ export async function computeFinancialImpact(params: FinancialImpactParams): Pro
|
|||||||
|
|
||||||
for (const ev of events) {
|
for (const ev of events) {
|
||||||
const eventType = String(ev.eventType ?? "").toLowerCase();
|
const eventType = String(ev.eventType ?? "").toLowerCase();
|
||||||
|
if (eventType === "mold-change") continue;
|
||||||
const { blob, inner } = parseBlob(ev.data);
|
const { blob, inner } = parseBlob(ev.data);
|
||||||
const status = String(blob?.status ?? inner?.status ?? "").toLowerCase();
|
const status = String(blob?.status ?? inner?.status ?? "").toLowerCase();
|
||||||
const severity = String(ev.severity ?? "").toLowerCase();
|
const severity = String(ev.severity ?? "").toLowerCase();
|
||||||
|
|||||||
150
lib/i18n/en.json
150
lib/i18n/en.json
@@ -9,6 +9,8 @@
|
|||||||
"common.close": "Close",
|
"common.close": "Close",
|
||||||
"common.save": "Save",
|
"common.save": "Save",
|
||||||
"common.copy": "Copy",
|
"common.copy": "Copy",
|
||||||
|
"common.yes": "Yes",
|
||||||
|
"common.no": "No",
|
||||||
"nav.overview": "Overview",
|
"nav.overview": "Overview",
|
||||||
"nav.machines": "Machines",
|
"nav.machines": "Machines",
|
||||||
"nav.reports": "Reports",
|
"nav.reports": "Reports",
|
||||||
@@ -104,6 +106,96 @@
|
|||||||
"overview.event.slow-cycle": "slow-cycle",
|
"overview.event.slow-cycle": "slow-cycle",
|
||||||
"overview.status.offline": "OFFLINE",
|
"overview.status.offline": "OFFLINE",
|
||||||
"overview.status.online": "ONLINE",
|
"overview.status.online": "ONLINE",
|
||||||
|
"overview.recap.title": "Daily recap",
|
||||||
|
"overview.recap.subtitle": "Production, downtime, and work orders in one glance.",
|
||||||
|
"overview.recap.cta": "Open daily recap",
|
||||||
|
"recap.title": "Recap",
|
||||||
|
"recap.subtitle": "Last 24h",
|
||||||
|
"recap.card.stoppedFor": "Stopped for {min} min",
|
||||||
|
"machines.status.stopped": "STOPPED",
|
||||||
|
"machines.stoppedFor": "Stopped for {min} min",
|
||||||
|
"recap.grid.title": "Machine recap",
|
||||||
|
"recap.status.idle": "Idle",
|
||||||
|
"recap.card.idle": "No active work order",
|
||||||
|
"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.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.shiftCurrent": "Current shift",
|
||||||
|
"recap.range.yesterday": "Yesterday",
|
||||||
|
"recap.range.custom": "Custom",
|
||||||
|
"recap.range.apply": "Apply",
|
||||||
|
"recap.range.shiftUnavailable": "Current shift is unavailable because no shifts are configured.",
|
||||||
|
"recap.range.shiftFallbackUnavailable": "Current shift is unavailable. Showing the last 24h instead.",
|
||||||
|
"recap.range.shiftFallbackInactive": "No active shift right now. Showing the last 24h instead.",
|
||||||
|
"recap.shift.1": "Shift 1",
|
||||||
|
"recap.shift.2": "Shift 2",
|
||||||
|
"recap.shift.3": "Shift 3",
|
||||||
|
"recap.kpi.oee": "OEE Avg 24h",
|
||||||
|
"recap.kpi.oee24h": "OEE Avg 24h",
|
||||||
|
"recap.kpi.oeeShift": "OEE Shift",
|
||||||
|
"recap.kpi.oeeYesterday": "OEE Yesterday",
|
||||||
|
"recap.kpi.oeeCustom": "OEE Custom Range",
|
||||||
|
"recap.kpi.noData": "No KPI data",
|
||||||
|
"recap.kpi.good": "Good parts",
|
||||||
|
"recap.kpi.stops": "Total stops (min)",
|
||||||
|
"recap.kpi.scrap": "Scrap",
|
||||||
|
"recap.card.oee": "OEE Avg 24h",
|
||||||
|
"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.bySku": "Production by SKU",
|
||||||
|
"recap.production.sku": "SKU",
|
||||||
|
"recap.production.good": "Good",
|
||||||
|
"recap.production.scrap": "Scrap",
|
||||||
|
"recap.production.target": "Target",
|
||||||
|
"recap.production.progress": "Progress%",
|
||||||
|
"recap.downtime.title": "Top downtime",
|
||||||
|
"recap.downtime.top": "Top stops",
|
||||||
|
"recap.workOrders.title": "Work orders",
|
||||||
|
"recap.workOrders.active": "Active",
|
||||||
|
"recap.workOrders.completed": "Completed",
|
||||||
|
"recap.workOrders.none": "No production recorded",
|
||||||
|
"recap.workOrders.sku": "SKU",
|
||||||
|
"recap.workOrders.startedAt": "Started",
|
||||||
|
"recap.workOrders.goodParts": "Good parts",
|
||||||
|
"recap.workOrders.duration": "Duration",
|
||||||
|
"recap.machine.title": "Machine status",
|
||||||
|
"recap.machine.running": "Running",
|
||||||
|
"recap.machine.stopped": "Stopped",
|
||||||
|
"recap.machine.mold": "Mold change",
|
||||||
|
"recap.machine.online": "Connected",
|
||||||
|
"recap.machine.offline": "Disconnected",
|
||||||
|
"recap.machine.lastHeartbeat": "Last heartbeat",
|
||||||
|
"recap.machine.uptime": "Uptime",
|
||||||
|
"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.timeline.title": "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",
|
||||||
"machines.title": "Machines",
|
"machines.title": "Machines",
|
||||||
"machines.subtitle": "Select a machine to view live KPIs.",
|
"machines.subtitle": "Select a machine to view live KPIs.",
|
||||||
"machines.cancel": "Cancel",
|
"machines.cancel": "Cancel",
|
||||||
@@ -147,9 +239,10 @@
|
|||||||
"machine.detail.error.network": "Network error",
|
"machine.detail.error.network": "Network error",
|
||||||
"machine.detail.back": "Back",
|
"machine.detail.back": "Back",
|
||||||
"machine.detail.workOrders.upload": "Upload Work Orders",
|
"machine.detail.workOrders.upload": "Upload Work Orders",
|
||||||
|
"machine.detail.workOrders.downloadTemplate": "Download Template",
|
||||||
"machine.detail.workOrders.uploading": "Uploading...",
|
"machine.detail.workOrders.uploading": "Uploading...",
|
||||||
"machine.detail.workOrders.uploadParsing": "Parsing file...",
|
"machine.detail.workOrders.uploadParsing": "Parsing file...",
|
||||||
"machine.detail.workOrders.uploadHint": "CSV or XLSX with Work Order ID, SKU, Theoretical Cycle Time (Seconds), Target Quantity.",
|
"machine.detail.workOrders.uploadHint": "CSV or XLSX: Work Order ID, SKU, Theoretical Cycle Time (Seconds), Target Quantity, Mold, Total Cavities, Active Cavities (first four columns are enough for legacy files).",
|
||||||
"machine.detail.workOrders.uploadSuccess": "Uploaded {count} work orders",
|
"machine.detail.workOrders.uploadSuccess": "Uploaded {count} work orders",
|
||||||
"machine.detail.workOrders.uploadError": "Upload failed",
|
"machine.detail.workOrders.uploadError": "Upload failed",
|
||||||
"machine.detail.workOrders.uploadInvalid": "No valid work orders found",
|
"machine.detail.workOrders.uploadInvalid": "No valid work orders found",
|
||||||
@@ -167,11 +260,15 @@
|
|||||||
"machine.detail.bucket.unknown": "Unknown",
|
"machine.detail.bucket.unknown": "Unknown",
|
||||||
"machine.detail.activity.title": "Machine Activity Timeline",
|
"machine.detail.activity.title": "Machine Activity Timeline",
|
||||||
"machine.detail.activity.subtitle": "Real-time analysis of production cycles",
|
"machine.detail.activity.subtitle": "Real-time analysis of production cycles",
|
||||||
|
"machine.detail.activity.windowBadge": "1h",
|
||||||
|
"machine.detail.activity.windowModalTitle": "Timeline window",
|
||||||
|
"machine.detail.activity.windowModalBody": "This timeline always shows the last 1 hour of machine activity.",
|
||||||
"machine.detail.activity.noData": "No timeline data yet.",
|
"machine.detail.activity.noData": "No timeline data yet.",
|
||||||
"machine.detail.tooltip.cycle": "Cycle: {label}",
|
"machine.detail.tooltip.cycle": "Cycle: {label}",
|
||||||
"machine.detail.tooltip.duration": "Duration",
|
"machine.detail.tooltip.duration": "Duration",
|
||||||
"machine.detail.tooltip.ideal": "Ideal",
|
"machine.detail.tooltip.ideal": "Ideal",
|
||||||
"machine.detail.tooltip.deviation": "Deviation",
|
"machine.detail.tooltip.deviation": "Deviation",
|
||||||
|
"machine.detail.kpi.oeeCurrent": "Current OEE",
|
||||||
"machine.detail.kpi.updated": "Updated {time}",
|
"machine.detail.kpi.updated": "Updated {time}",
|
||||||
"machine.detail.currentWorkOrder": "Current Work Order",
|
"machine.detail.currentWorkOrder": "Current Work Order",
|
||||||
"machine.detail.recentEvents": "Critical Events",
|
"machine.detail.recentEvents": "Critical Events",
|
||||||
@@ -367,6 +464,7 @@
|
|||||||
"settings.tabs.alerts": "Alerts",
|
"settings.tabs.alerts": "Alerts",
|
||||||
"settings.tabs.financial": "Financial",
|
"settings.tabs.financial": "Financial",
|
||||||
"settings.tabs.team": "Team",
|
"settings.tabs.team": "Team",
|
||||||
|
"settings.tabs.reasonCatalog": "Downtime & scrap",
|
||||||
"settings.loading": "Loading settings...",
|
"settings.loading": "Loading settings...",
|
||||||
"settings.loadingTeam": "Loading team...",
|
"settings.loadingTeam": "Loading team...",
|
||||||
"settings.refresh": "Refresh",
|
"settings.refresh": "Refresh",
|
||||||
@@ -422,6 +520,46 @@
|
|||||||
"settings.thresholds.macroStoppage": "Macro stoppage multiplier",
|
"settings.thresholds.macroStoppage": "Macro stoppage multiplier",
|
||||||
"settings.alerts": "Alerts",
|
"settings.alerts": "Alerts",
|
||||||
"settings.alertsSubtitle": "Choose which alerts to notify.",
|
"settings.alertsSubtitle": "Choose which alerts to notify.",
|
||||||
|
"settings.reasonCatalog.title": "Downtime and scrap catalogs",
|
||||||
|
"settings.reasonCatalog.subtitle": "Catalogs are stored in MIS (categories + codes). Changes bump settings version so machines pick them up. Deactivate retired codes instead of deleting them.",
|
||||||
|
"settings.reasonCatalog.version": "Catalog version",
|
||||||
|
"settings.reasonCatalog.hint": "Increase version when you change codes so edge devices can detect updates. Use \"Active\" to hide a code from new selections while keeping history labels.",
|
||||||
|
"settings.reasonCatalog.downtime": "Downtime (stops)",
|
||||||
|
"settings.reasonCatalog.scrap": "Scrap",
|
||||||
|
"settings.reasonCatalog.addCategory": "Add category",
|
||||||
|
"settings.reasonCatalog.emptyKind": "No categories yet.",
|
||||||
|
"settings.reasonCatalog.categoryId": "Category id",
|
||||||
|
"settings.reasonCatalog.categoryLabel": "Category name",
|
||||||
|
"settings.reasonCatalog.reasons": "Reasons",
|
||||||
|
"settings.reasonCatalog.addReason": "Add reason",
|
||||||
|
"settings.reasonCatalog.removeCategory": "Remove category",
|
||||||
|
"settings.reasonCatalog.detailId": "Detail id",
|
||||||
|
"settings.reasonCatalog.reasonCode": "Printed code",
|
||||||
|
"settings.reasonCatalog.detailLabel": "Description",
|
||||||
|
"settings.reasonCatalog.active": "Active",
|
||||||
|
"settings.reasonCatalog.removeRow": "Remove",
|
||||||
|
"settings.reasonCatalog.removeDetailHint": "Prefer deactivating codes that were already used in production.",
|
||||||
|
"settings.reasonCatalog.newCategory": "New category",
|
||||||
|
"settings.reasonCatalog.newReason": "New reason",
|
||||||
|
"settings.reasonCatalog.dbVersionHint": "Settings version (includes catalog): {version}",
|
||||||
|
"settings.reasonCatalog.reload": "Reload",
|
||||||
|
"settings.reasonCatalog.stepKind": "1. Catalog type",
|
||||||
|
"settings.reasonCatalog.stepCategory": "2. Category and prefix",
|
||||||
|
"settings.reasonCatalog.pickCategory": "Category",
|
||||||
|
"settings.reasonCatalog.inactive": "inactive",
|
||||||
|
"settings.reasonCatalog.categoryNameEdit": "Category name",
|
||||||
|
"settings.reasonCatalog.codePrefixEdit": "Code prefix (letters; optional digits/hyphen after first letter)",
|
||||||
|
"settings.reasonCatalog.categoryActive": "Category active",
|
||||||
|
"settings.reasonCatalog.newCategorySection": "New category in this catalog type",
|
||||||
|
"settings.reasonCatalog.codePrefixField": "Prefix (shown before the number)",
|
||||||
|
"settings.reasonCatalog.stepReason": "3. Add reason (numbers only)",
|
||||||
|
"settings.reasonCatalog.digitsOnlyHint": "Enter only the numeric part; the full printed code is prefix + number.",
|
||||||
|
"settings.reasonCatalog.fullCodePreview": "Printed code",
|
||||||
|
"settings.reasonCatalog.numericSuffix": "Number",
|
||||||
|
"settings.reasonCatalog.reasonsInCategory": "Reasons in this category",
|
||||||
|
"settings.reasonCatalog.noItemsYet": "No reasons yet.",
|
||||||
|
"settings.reasonCatalog.prefixInvalid": "Prefix must start with a letter and use letters, digits, or hyphen.",
|
||||||
|
|
||||||
"settings.alerts.oeeDrop": "OEE drop alerts",
|
"settings.alerts.oeeDrop": "OEE drop alerts",
|
||||||
"settings.alerts.oeeDropHelper": "Notify when OEE falls below threshold",
|
"settings.alerts.oeeDropHelper": "Notify when OEE falls below threshold",
|
||||||
"settings.alerts.performanceDegradation": "Performance degradation alerts",
|
"settings.alerts.performanceDegradation": "Performance degradation alerts",
|
||||||
@@ -526,10 +664,18 @@
|
|||||||
"financial.field.scrapCostPerUnit": "Scrap cost / unit",
|
"financial.field.scrapCostPerUnit": "Scrap cost / unit",
|
||||||
"financial.field.rawMaterialCostPerUnit": "Raw material / unit",
|
"financial.field.rawMaterialCostPerUnit": "Raw material / unit",
|
||||||
"nav.downtime": "Downtime",
|
"nav.downtime": "Downtime",
|
||||||
|
"nav.recap": "Daily recap",
|
||||||
"settings.tabs.modules": "Modules",
|
"settings.tabs.modules": "Modules",
|
||||||
"settings.modules.title": "Modules",
|
"settings.modules.title": "Modules",
|
||||||
"settings.modules.subtitle": "Enable/disable UI modules depending on how the plant operates.",
|
"settings.modules.subtitle": "Enable/disable UI modules depending on how the plant operates.",
|
||||||
"settings.modules.screenless.title": "Screenless mode",
|
"settings.modules.screenless.title": "Screenless mode",
|
||||||
"settings.modules.screenless.helper": "Hide the Downtime module from navigation (for plants without Node-RED reason capture).",
|
"settings.modules.screenless.helper": "Hide the Downtime module from navigation (for plants without Node-RED reason capture).",
|
||||||
"settings.modules.note": "This setting is org-wide."
|
"settings.modules.note": "This setting is org-wide.",
|
||||||
|
"overview.attention.offline": "Offline — no heartbeat",
|
||||||
|
"overview.attention.stopped": "Currently stopped",
|
||||||
|
"overview.attention.oeeCritical": "OEE critical: {value}%",
|
||||||
|
"overview.attention.oeeLow": "OEE low: {value}%",
|
||||||
|
"overview.attention.scrapHigh": "Scrap rate high: {value}%",
|
||||||
|
"overview.attention.scrapMod": "Scrap rate elevated: {value}%",
|
||||||
|
"overview.attention.availLow": "Availability low: {value}%"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,8 @@
|
|||||||
"common.close": "Cerrar",
|
"common.close": "Cerrar",
|
||||||
"common.save": "Guardar",
|
"common.save": "Guardar",
|
||||||
"common.copy": "Copiar",
|
"common.copy": "Copiar",
|
||||||
|
"common.yes": "Sí",
|
||||||
|
"common.no": "No",
|
||||||
"nav.overview": "Resumen",
|
"nav.overview": "Resumen",
|
||||||
"nav.machines": "Máquinas",
|
"nav.machines": "Máquinas",
|
||||||
"nav.reports": "Reportes",
|
"nav.reports": "Reportes",
|
||||||
@@ -102,8 +104,105 @@
|
|||||||
"overview.event.macrostop": "macroparo",
|
"overview.event.macrostop": "macroparo",
|
||||||
"overview.event.microstop": "microparo",
|
"overview.event.microstop": "microparo",
|
||||||
"overview.event.slow-cycle": "ciclo lento",
|
"overview.event.slow-cycle": "ciclo lento",
|
||||||
|
"overview.attention.offline": "Sin señal",
|
||||||
|
"overview.attention.stopped": "Detenida ahora",
|
||||||
|
"overview.attention.oeeCritical": "OEE crítica: {value}%",
|
||||||
|
"overview.attention.oeeLow": "OEE baja: {value}%",
|
||||||
|
"overview.attention.scrapHigh": "Scrap alto: {value}%",
|
||||||
|
"overview.attention.scrapMod": "Scrap elevado: {value}%",
|
||||||
|
"overview.attention.availLow": "Disponibilidad baja: {value}%",
|
||||||
"overview.status.offline": "FUERA DE LÍNEA",
|
"overview.status.offline": "FUERA DE LÍNEA",
|
||||||
"overview.status.online": "EN LÍNEA",
|
"overview.status.online": "EN LÍNEA",
|
||||||
|
"overview.recap.title": "Resumen diario de turno",
|
||||||
|
"overview.recap.subtitle": "Consulta producción, paros y órdenes en una sola vista.",
|
||||||
|
"overview.recap.cta": "Abrir resumen diario",
|
||||||
|
"recap.title": "Resumen",
|
||||||
|
"recap.subtitle": "Últimas 24h",
|
||||||
|
"recap.card.stoppedFor": "Detenida hace {min} min",
|
||||||
|
"machines.status.stopped": "DETENIDA",
|
||||||
|
"machines.stoppedFor": "Detenida hace {min} min",
|
||||||
|
"recap.status.idle": "Inactiva",
|
||||||
|
"recap.card.idle": "Sin orden de trabajo activa",
|
||||||
|
"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.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.shiftCurrent": "Turno actual",
|
||||||
|
"recap.range.yesterday": "Ayer",
|
||||||
|
"recap.range.custom": "Personalizado",
|
||||||
|
"recap.range.apply": "Aplicar",
|
||||||
|
"recap.range.shiftUnavailable": "Turno actual no disponible porque no hay turnos configurados.",
|
||||||
|
"recap.range.shiftFallbackUnavailable": "Turno actual no disponible. Mostrando últimas 24h.",
|
||||||
|
"recap.range.shiftFallbackInactive": "No hay turno activo en este momento. Mostrando últimas 24h.",
|
||||||
|
"recap.shift.1": "Turno 1",
|
||||||
|
"recap.shift.2": "Turno 2",
|
||||||
|
"recap.shift.3": "Turno 3",
|
||||||
|
"recap.kpi.oee": "OEE promedio 24h",
|
||||||
|
"recap.kpi.oee24h": "OEE promedio 24h",
|
||||||
|
"recap.kpi.oeeShift": "OEE del turno",
|
||||||
|
"recap.kpi.oeeYesterday": "OEE ayer",
|
||||||
|
"recap.kpi.oeeCustom": "OEE rango personalizado",
|
||||||
|
"recap.kpi.noData": "Sin datos de KPI",
|
||||||
|
"recap.kpi.good": "Buenas",
|
||||||
|
"recap.kpi.stops": "Paros totales (min)",
|
||||||
|
"recap.kpi.scrap": "Scrap",
|
||||||
|
"recap.card.oee": "OEE promedio 24h",
|
||||||
|
"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.bySku": "Producción por SKU",
|
||||||
|
"recap.production.sku": "SKU",
|
||||||
|
"recap.production.good": "Buenas",
|
||||||
|
"recap.production.scrap": "Scrap",
|
||||||
|
"recap.production.target": "Meta",
|
||||||
|
"recap.production.progress": "Avance%",
|
||||||
|
"recap.downtime.title": "Top downtime",
|
||||||
|
"recap.downtime.top": "Top paros",
|
||||||
|
"recap.workOrders.title": "Órdenes de trabajo",
|
||||||
|
"recap.workOrders.active": "Activa",
|
||||||
|
"recap.workOrders.completed": "Completadas",
|
||||||
|
"recap.workOrders.none": "Sin producción registrada",
|
||||||
|
"recap.workOrders.sku": "SKU",
|
||||||
|
"recap.workOrders.startedAt": "Inicio",
|
||||||
|
"recap.workOrders.goodParts": "Buenas",
|
||||||
|
"recap.workOrders.duration": "Duración",
|
||||||
|
"recap.machine.title": "Estado máquina",
|
||||||
|
"recap.machine.running": "En marcha",
|
||||||
|
"recap.machine.stopped": "Detenida",
|
||||||
|
"recap.machine.mold": "Cambio de molde",
|
||||||
|
"recap.machine.online": "Conectada",
|
||||||
|
"recap.machine.offline": "Sin conexión",
|
||||||
|
"recap.machine.lastHeartbeat": "Último heartbeat",
|
||||||
|
"recap.machine.uptime": "Uptime",
|
||||||
|
"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.timeline.title": "Timeline",
|
||||||
|
"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",
|
||||||
"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.",
|
||||||
"machines.cancel": "Cancelar",
|
"machines.cancel": "Cancelar",
|
||||||
@@ -147,9 +246,10 @@
|
|||||||
"machine.detail.error.network": "Error de red",
|
"machine.detail.error.network": "Error de red",
|
||||||
"machine.detail.back": "Volver",
|
"machine.detail.back": "Volver",
|
||||||
"machine.detail.workOrders.upload": "Subir ordenes de trabajo",
|
"machine.detail.workOrders.upload": "Subir ordenes de trabajo",
|
||||||
|
"machine.detail.workOrders.downloadTemplate": "Descargar plantilla",
|
||||||
"machine.detail.workOrders.uploading": "Subiendo...",
|
"machine.detail.workOrders.uploading": "Subiendo...",
|
||||||
"machine.detail.workOrders.uploadParsing": "Leyendo archivo...",
|
"machine.detail.workOrders.uploadParsing": "Leyendo archivo...",
|
||||||
"machine.detail.workOrders.uploadHint": "CSV o XLSX con Work Order ID, SKU, Theoretical Cycle Time (Seconds), Target Quantity.",
|
"machine.detail.workOrders.uploadHint": "CSV o XLSX: Work Order ID, SKU, Theoretical Cycle Time (Seconds), Target Quantity, Molde, Total de cavidades, Cavidades activas (los primeros cuatro campos bastan para archivos antiguos).",
|
||||||
"machine.detail.workOrders.uploadSuccess": "Se cargaron {count} ordenes de trabajo",
|
"machine.detail.workOrders.uploadSuccess": "Se cargaron {count} ordenes de trabajo",
|
||||||
"machine.detail.workOrders.uploadError": "No se pudo cargar",
|
"machine.detail.workOrders.uploadError": "No se pudo cargar",
|
||||||
"machine.detail.workOrders.uploadInvalid": "No se encontraron ordenes de trabajo validas",
|
"machine.detail.workOrders.uploadInvalid": "No se encontraron ordenes de trabajo validas",
|
||||||
@@ -167,11 +267,15 @@
|
|||||||
"machine.detail.bucket.unknown": "Desconocido",
|
"machine.detail.bucket.unknown": "Desconocido",
|
||||||
"machine.detail.activity.title": "Línea de tiempo de actividad",
|
"machine.detail.activity.title": "Línea de tiempo de actividad",
|
||||||
"machine.detail.activity.subtitle": "Análisis en tiempo real de ciclos de producción",
|
"machine.detail.activity.subtitle": "Análisis en tiempo real de ciclos de producción",
|
||||||
|
"machine.detail.activity.windowBadge": "1h",
|
||||||
|
"machine.detail.activity.windowModalTitle": "Ventana de timeline",
|
||||||
|
"machine.detail.activity.windowModalBody": "Este timeline siempre muestra la última 1 hora de actividad de la máquina.",
|
||||||
"machine.detail.activity.noData": "Sin datos de línea de tiempo.",
|
"machine.detail.activity.noData": "Sin datos de línea de tiempo.",
|
||||||
"machine.detail.tooltip.cycle": "Ciclo: {label}",
|
"machine.detail.tooltip.cycle": "Ciclo: {label}",
|
||||||
"machine.detail.tooltip.duration": "Duración",
|
"machine.detail.tooltip.duration": "Duración",
|
||||||
"machine.detail.tooltip.ideal": "Ideal",
|
"machine.detail.tooltip.ideal": "Ideal",
|
||||||
"machine.detail.tooltip.deviation": "Desviación",
|
"machine.detail.tooltip.deviation": "Desviación",
|
||||||
|
"machine.detail.kpi.oeeCurrent": "OEE actual",
|
||||||
"machine.detail.kpi.updated": "Actualizado {time}",
|
"machine.detail.kpi.updated": "Actualizado {time}",
|
||||||
"machine.detail.currentWorkOrder": "Orden de trabajo actual",
|
"machine.detail.currentWorkOrder": "Orden de trabajo actual",
|
||||||
"machine.detail.recentEvents": "Eventos críticos",
|
"machine.detail.recentEvents": "Eventos críticos",
|
||||||
@@ -367,6 +471,7 @@
|
|||||||
"settings.tabs.alerts": "Alertas",
|
"settings.tabs.alerts": "Alertas",
|
||||||
"settings.tabs.financial": "Finanzas",
|
"settings.tabs.financial": "Finanzas",
|
||||||
"settings.tabs.team": "Equipo",
|
"settings.tabs.team": "Equipo",
|
||||||
|
"settings.tabs.reasonCatalog": "Paros y scrap",
|
||||||
"settings.loading": "Cargando configuración...",
|
"settings.loading": "Cargando configuración...",
|
||||||
"settings.loadingTeam": "Cargando equipo...",
|
"settings.loadingTeam": "Cargando equipo...",
|
||||||
"settings.refresh": "Actualizar",
|
"settings.refresh": "Actualizar",
|
||||||
@@ -422,6 +527,46 @@
|
|||||||
"settings.thresholds.macroStoppage": "Multiplicador de macroparo",
|
"settings.thresholds.macroStoppage": "Multiplicador de macroparo",
|
||||||
"settings.alerts": "Alertas",
|
"settings.alerts": "Alertas",
|
||||||
"settings.alertsSubtitle": "Elige qué alertas notificar.",
|
"settings.alertsSubtitle": "Elige qué alertas notificar.",
|
||||||
|
"settings.reasonCatalog.title": "Catálogos de paros y scrap",
|
||||||
|
"settings.reasonCatalog.subtitle": "Los catálogos viven en MIS (categorías y códigos). Los cambios suben la versión de ajustes para que las máquinas los reciban. Desactiva códigos retirados en lugar de borrarlos.",
|
||||||
|
"settings.reasonCatalog.version": "Versión del catálogo",
|
||||||
|
"settings.reasonCatalog.hint": "Sube la versión cuando cambies códigos para que el borde detecte actualizaciones. Usa \"Activo\" para ocultar un código en nuevas capturas sin perder etiquetas en histórico.",
|
||||||
|
"settings.reasonCatalog.downtime": "Tiempo muerto (paros)",
|
||||||
|
"settings.reasonCatalog.scrap": "Scrap",
|
||||||
|
"settings.reasonCatalog.addCategory": "Agregar categoría",
|
||||||
|
"settings.reasonCatalog.emptyKind": "Aún no hay categorías.",
|
||||||
|
"settings.reasonCatalog.categoryId": "Id de categoría",
|
||||||
|
"settings.reasonCatalog.categoryLabel": "Nombre de categoría",
|
||||||
|
"settings.reasonCatalog.reasons": "Razones",
|
||||||
|
"settings.reasonCatalog.addReason": "Agregar razón",
|
||||||
|
"settings.reasonCatalog.removeCategory": "Quitar categoría",
|
||||||
|
"settings.reasonCatalog.detailId": "Id del detalle",
|
||||||
|
"settings.reasonCatalog.reasonCode": "Código impreso",
|
||||||
|
"settings.reasonCatalog.detailLabel": "Descripción",
|
||||||
|
"settings.reasonCatalog.active": "Activo",
|
||||||
|
"settings.reasonCatalog.removeRow": "Quitar",
|
||||||
|
"settings.reasonCatalog.removeDetailHint": "Para códigos ya usados en producción, preferir desactivar en lugar de quitar la fila.",
|
||||||
|
"settings.reasonCatalog.newCategory": "Nueva categoría",
|
||||||
|
"settings.reasonCatalog.newReason": "Nueva razón",
|
||||||
|
"settings.reasonCatalog.dbVersionHint": "Versión de ajustes (incluye catálogo): {version}",
|
||||||
|
"settings.reasonCatalog.reload": "Recargar",
|
||||||
|
"settings.reasonCatalog.stepKind": "1. Tipo de catálogo",
|
||||||
|
"settings.reasonCatalog.stepCategory": "2. Categoría y prefijo",
|
||||||
|
"settings.reasonCatalog.pickCategory": "Categoría",
|
||||||
|
"settings.reasonCatalog.inactive": "inactiva",
|
||||||
|
"settings.reasonCatalog.categoryNameEdit": "Nombre de categoría",
|
||||||
|
"settings.reasonCatalog.codePrefixEdit": "Prefijo de código (letras; opcional dígitos o guión después de la primera letra)",
|
||||||
|
"settings.reasonCatalog.categoryActive": "Categoría activa",
|
||||||
|
"settings.reasonCatalog.newCategorySection": "Nueva categoría en este tipo de catálogo",
|
||||||
|
"settings.reasonCatalog.codePrefixField": "Prefijo (se muestra antes del número)",
|
||||||
|
"settings.reasonCatalog.stepReason": "3. Agregar razón (solo números)",
|
||||||
|
"settings.reasonCatalog.digitsOnlyHint": "Captura solo la parte numérica; el código impreso completo es prefijo + número.",
|
||||||
|
"settings.reasonCatalog.fullCodePreview": "Código impreso",
|
||||||
|
"settings.reasonCatalog.numericSuffix": "Número",
|
||||||
|
"settings.reasonCatalog.reasonsInCategory": "Razones en esta categoría",
|
||||||
|
"settings.reasonCatalog.noItemsYet": "Aún no hay razones.",
|
||||||
|
"settings.reasonCatalog.prefixInvalid": "El prefijo debe empezar con letra y usar letras, dígitos o guión.",
|
||||||
|
|
||||||
"settings.alerts.oeeDrop": "Alertas por caída de OEE",
|
"settings.alerts.oeeDrop": "Alertas por caída de OEE",
|
||||||
"settings.alerts.oeeDropHelper": "Notificar cuando OEE esté por debajo del umbral",
|
"settings.alerts.oeeDropHelper": "Notificar cuando OEE esté por debajo del umbral",
|
||||||
"settings.alerts.performanceDegradation": "Alertas por baja de Performance",
|
"settings.alerts.performanceDegradation": "Alertas por baja de Performance",
|
||||||
@@ -526,6 +671,7 @@
|
|||||||
"financial.field.scrapCostPerUnit": "Costo scrap / unidad",
|
"financial.field.scrapCostPerUnit": "Costo scrap / unidad",
|
||||||
"financial.field.rawMaterialCostPerUnit": "Costo materia prima / unidad",
|
"financial.field.rawMaterialCostPerUnit": "Costo materia prima / unidad",
|
||||||
"nav.downtime": "Downtime",
|
"nav.downtime": "Downtime",
|
||||||
|
"nav.recap": "Resumen diario",
|
||||||
"settings.tabs.modules": "Módulos",
|
"settings.tabs.modules": "Módulos",
|
||||||
"settings.modules.title": "Módulos",
|
"settings.modules.title": "Módulos",
|
||||||
"settings.modules.subtitle": "Activa/desactiva módulos según cómo opera la planta.",
|
"settings.modules.subtitle": "Activa/desactiva módulos según cómo opera la planta.",
|
||||||
|
|||||||
@@ -31,6 +31,15 @@ type LatestKpiRow = {
|
|||||||
cycleTime?: number | null;
|
cycleTime?: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type LatestMacrostopRow = {
|
||||||
|
machineId: string;
|
||||||
|
ts: Date;
|
||||||
|
status: "active" | "resolved" | "unknown";
|
||||||
|
startedAtMs: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MACROSTOP_LOOKBACK_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
export async function fetchMachineBase(orgId: string): Promise<MachineBaseRow[]> {
|
export async function fetchMachineBase(orgId: string): Promise<MachineBaseRow[]> {
|
||||||
return prisma.machine.findMany({
|
return prisma.machine.findMany({
|
||||||
where: { orgId },
|
where: { orgId },
|
||||||
@@ -93,20 +102,75 @@ export async function fetchLatestKpis(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchLatestMacrostops(
|
||||||
|
orgId: string,
|
||||||
|
machineIds: string[]
|
||||||
|
): Promise<LatestMacrostopRow[]> {
|
||||||
|
if (!machineIds.length) return [];
|
||||||
|
|
||||||
|
const rows = await prisma.machineEvent.findMany({
|
||||||
|
where: {
|
||||||
|
orgId,
|
||||||
|
machineId: { in: machineIds },
|
||||||
|
eventType: "macrostop",
|
||||||
|
ts: { gte: new Date(Date.now() - MACROSTOP_LOOKBACK_MS) },
|
||||||
|
},
|
||||||
|
orderBy: [{ machineId: "asc" }, { ts: "desc" }],
|
||||||
|
select: { machineId: true, ts: true, data: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const byMachine = new Map<string, LatestMacrostopRow>();
|
||||||
|
for (const row of rows) {
|
||||||
|
if (byMachine.has(row.machineId)) continue;
|
||||||
|
|
||||||
|
let parsed: unknown = row.data;
|
||||||
|
if (typeof parsed === "string") {
|
||||||
|
try { parsed = JSON.parse(parsed); } catch { parsed = null; }
|
||||||
|
}
|
||||||
|
const data: Record<string, unknown> =
|
||||||
|
parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
||||||
|
? (parsed as Record<string, unknown>)
|
||||||
|
: {};
|
||||||
|
|
||||||
|
const isAutoAck =
|
||||||
|
data.is_auto_ack === true || data.isAutoAck === true ||
|
||||||
|
data.is_auto_ack === "true" || data.isAutoAck === "true";
|
||||||
|
if (isAutoAck) continue;
|
||||||
|
|
||||||
|
const rawStatus = String(data.status ?? "").trim().toLowerCase();
|
||||||
|
const status: LatestMacrostopRow["status"] =
|
||||||
|
rawStatus === "active" ? "active" : rawStatus === "resolved" ? "resolved" : "unknown";
|
||||||
|
|
||||||
|
const lastCycleTs = Number(data.last_cycle_timestamp);
|
||||||
|
const startedAtMs = Number.isFinite(lastCycleTs) && lastCycleTs > 0
|
||||||
|
? lastCycleTs
|
||||||
|
: row.ts.getTime();
|
||||||
|
|
||||||
|
byMachine.set(row.machineId, { machineId: row.machineId, ts: row.ts, status, startedAtMs });
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(byMachine.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
export function mergeMachineOverviewRows(params: {
|
export function mergeMachineOverviewRows(params: {
|
||||||
machines: MachineBaseRow[];
|
machines: MachineBaseRow[];
|
||||||
heartbeats: LatestHeartbeatRow[];
|
heartbeats: LatestHeartbeatRow[];
|
||||||
kpis?: LatestKpiRow[];
|
kpis?: LatestKpiRow[];
|
||||||
|
macrostops?: LatestMacrostopRow[];
|
||||||
includeKpi?: boolean;
|
includeKpi?: boolean;
|
||||||
}): OverviewMachineRow[] {
|
}): OverviewMachineRow[] {
|
||||||
const { machines, heartbeats, kpis = [], includeKpi = false } = params;
|
const { machines, heartbeats, kpis = [], macrostops = [], includeKpi = false } = params;
|
||||||
const heartbeatMap = new Map(heartbeats.map((row) => [row.machineId, row]));
|
const heartbeatMap = new Map(heartbeats.map((row) => [row.machineId, row]));
|
||||||
const kpiMap = new Map(kpis.map((row) => [row.machineId, row]));
|
const kpiMap = new Map(kpis.map((row) => [row.machineId, row]));
|
||||||
|
const macrostopMap = new Map(macrostops.map((row) => [row.machineId, row]));
|
||||||
|
|
||||||
|
|
||||||
return machines.map((machine) => ({
|
return machines.map((machine) => ({
|
||||||
...machine,
|
...machine,
|
||||||
latestHeartbeat: (heartbeatMap.get(machine.id) ?? null) as OverviewMachineRow["latestHeartbeat"],
|
latestHeartbeat: (heartbeatMap.get(machine.id) ?? null) as OverviewMachineRow["latestHeartbeat"],
|
||||||
latestKpi: includeKpi ? (kpiMap.get(machine.id) ?? null) : null,
|
latestKpi: includeKpi ? (kpiMap.get(machine.id) ?? null) : null,
|
||||||
|
latestMacrostop: macrostopMap.get(machine.id) ?? null,
|
||||||
heartbeats: undefined,
|
heartbeats: undefined,
|
||||||
kpiSnapshots: undefined,
|
kpiSnapshots: undefined,
|
||||||
}));
|
}));
|
||||||
|
|||||||
113
lib/machines/withLatest.ts.bak
Normal file
113
lib/machines/withLatest.ts.bak
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import type { OverviewMachineRow } from "@/lib/overview/types";
|
||||||
|
|
||||||
|
type MachineBaseRow = Pick<
|
||||||
|
OverviewMachineRow,
|
||||||
|
"id" | "name" | "code" | "location" | "createdAt" | "updatedAt"
|
||||||
|
>;
|
||||||
|
|
||||||
|
type LatestHeartbeatRow = {
|
||||||
|
machineId: string;
|
||||||
|
ts: Date;
|
||||||
|
tsServer: Date | null;
|
||||||
|
status: string;
|
||||||
|
message?: string | null;
|
||||||
|
ip?: string | null;
|
||||||
|
fwVersion?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type LatestKpiRow = {
|
||||||
|
machineId: string;
|
||||||
|
ts: Date;
|
||||||
|
oee?: number | null;
|
||||||
|
availability?: number | null;
|
||||||
|
performance?: number | null;
|
||||||
|
quality?: number | null;
|
||||||
|
workOrderId?: string | null;
|
||||||
|
sku?: string | null;
|
||||||
|
good?: number | null;
|
||||||
|
scrap?: number | null;
|
||||||
|
target?: number | null;
|
||||||
|
cycleTime?: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function fetchMachineBase(orgId: string): Promise<MachineBaseRow[]> {
|
||||||
|
return prisma.machine.findMany({
|
||||||
|
where: { orgId },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
code: true,
|
||||||
|
location: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchLatestHeartbeats(
|
||||||
|
orgId: string,
|
||||||
|
machineIds: string[]
|
||||||
|
): Promise<LatestHeartbeatRow[]> {
|
||||||
|
if (!machineIds.length) return [];
|
||||||
|
return prisma.machineHeartbeat.findMany({
|
||||||
|
where: { orgId, machineId: { in: machineIds } },
|
||||||
|
orderBy: [{ machineId: "asc" }, { tsServer: "desc" }],
|
||||||
|
distinct: ["machineId"],
|
||||||
|
select: {
|
||||||
|
machineId: true,
|
||||||
|
ts: true,
|
||||||
|
tsServer: true,
|
||||||
|
status: true,
|
||||||
|
message: true,
|
||||||
|
ip: true,
|
||||||
|
fwVersion: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchLatestKpis(
|
||||||
|
orgId: string,
|
||||||
|
machineIds: string[]
|
||||||
|
): Promise<LatestKpiRow[]> {
|
||||||
|
if (!machineIds.length) return [];
|
||||||
|
return prisma.machineKpiSnapshot.findMany({
|
||||||
|
where: { orgId, machineId: { in: machineIds } },
|
||||||
|
orderBy: [{ machineId: "asc" }, { ts: "desc" }],
|
||||||
|
distinct: ["machineId"],
|
||||||
|
select: {
|
||||||
|
machineId: true,
|
||||||
|
ts: true,
|
||||||
|
oee: true,
|
||||||
|
availability: true,
|
||||||
|
performance: true,
|
||||||
|
quality: true,
|
||||||
|
workOrderId: true,
|
||||||
|
sku: true,
|
||||||
|
good: true,
|
||||||
|
scrap: true,
|
||||||
|
target: true,
|
||||||
|
cycleTime: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeMachineOverviewRows(params: {
|
||||||
|
machines: MachineBaseRow[];
|
||||||
|
heartbeats: LatestHeartbeatRow[];
|
||||||
|
kpis?: LatestKpiRow[];
|
||||||
|
includeKpi?: boolean;
|
||||||
|
}): OverviewMachineRow[] {
|
||||||
|
const { machines, heartbeats, kpis = [], includeKpi = false } = params;
|
||||||
|
const heartbeatMap = new Map(heartbeats.map((row) => [row.machineId, row]));
|
||||||
|
const kpiMap = new Map(kpis.map((row) => [row.machineId, row]));
|
||||||
|
|
||||||
|
return machines.map((machine) => ({
|
||||||
|
...machine,
|
||||||
|
latestHeartbeat: (heartbeatMap.get(machine.id) ?? null) as OverviewMachineRow["latestHeartbeat"],
|
||||||
|
latestKpi: includeKpi ? (kpiMap.get(machine.id) ?? null) : null,
|
||||||
|
heartbeats: undefined,
|
||||||
|
kpiSnapshots: undefined,
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -1,6 +1,3 @@
|
|||||||
import { readFile } from "fs/promises";
|
|
||||||
import path from "path";
|
|
||||||
|
|
||||||
type AnyRecord = Record<string, unknown>;
|
type AnyRecord = Record<string, unknown>;
|
||||||
|
|
||||||
export type ReasonCatalogKind = "downtime" | "scrap";
|
export type ReasonCatalogKind = "downtime" | "scrap";
|
||||||
@@ -8,6 +5,10 @@ export type ReasonCatalogKind = "downtime" | "scrap";
|
|||||||
export type ReasonCatalogDetail = {
|
export type ReasonCatalogDetail = {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
|
/** Official code (e.g. DTPRC-01, MX001). When set, used as reasonCode instead of slug. */
|
||||||
|
reasonCode?: string;
|
||||||
|
/** When false, hidden from operator pickers but kept for historical label resolution. Default true. */
|
||||||
|
active?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ReasonCatalogCategory = {
|
export type ReasonCatalogCategory = {
|
||||||
@@ -22,6 +23,11 @@ export type ReasonCatalog = {
|
|||||||
scrap: ReasonCatalogCategory[];
|
scrap: ReasonCatalogCategory[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type FlattenReasonCatalogOptions = {
|
||||||
|
/** If true, omit details with active === false (operator / tactile UI). */
|
||||||
|
activeOnly?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
function isPlainObject(value: unknown): value is AnyRecord {
|
function isPlainObject(value: unknown): value is AnyRecord {
|
||||||
return !!value && typeof value === "object" && !Array.isArray(value);
|
return !!value && typeof value === "object" && !Array.isArray(value);
|
||||||
}
|
}
|
||||||
@@ -40,6 +46,17 @@ function buildReasonCode(categoryId: string, detailId: string) {
|
|||||||
return `${canonicalId(categoryId)}__${canonicalId(detailId)}`.toUpperCase();
|
return `${canonicalId(categoryId)}__${canonicalId(detailId)}`.toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Uppercase official or derived code for this detail row. */
|
||||||
|
export function detailEffectiveReasonCode(category: ReasonCatalogCategory, detail: ReasonCatalogDetail): string {
|
||||||
|
const explicit = String(detail.reasonCode ?? "").trim();
|
||||||
|
if (explicit) return explicit.toUpperCase();
|
||||||
|
return buildReasonCode(category.id, detail.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isDetailActive(detail: ReasonCatalogDetail): boolean {
|
||||||
|
return detail.active !== false;
|
||||||
|
}
|
||||||
|
|
||||||
function toCategory(raw: unknown): ReasonCatalogCategory | null {
|
function toCategory(raw: unknown): ReasonCatalogCategory | null {
|
||||||
if (!isPlainObject(raw)) return null;
|
if (!isPlainObject(raw)) return null;
|
||||||
const labelRaw = String(raw.label ?? "").trim();
|
const labelRaw = String(raw.label ?? "").trim();
|
||||||
@@ -57,7 +74,16 @@ function toCategory(raw: unknown): ReasonCatalogCategory | null {
|
|||||||
const detailLabel = String(detailRaw.label ?? "").trim();
|
const detailLabel = String(detailRaw.label ?? "").trim();
|
||||||
if (!detailLabel) continue;
|
if (!detailLabel) continue;
|
||||||
const detailId = String(detailRaw.id ?? "").trim() || canonicalId(detailLabel, "detail");
|
const detailId = String(detailRaw.id ?? "").trim() || canonicalId(detailLabel, "detail");
|
||||||
details.push({ id: detailId, label: detailLabel });
|
const reasonCodeRaw = detailRaw.reasonCode ?? detailRaw.code;
|
||||||
|
const reasonCode =
|
||||||
|
reasonCodeRaw != null && String(reasonCodeRaw).trim() ? String(reasonCodeRaw).trim() : undefined;
|
||||||
|
const active = detailRaw.active === false ? false : true;
|
||||||
|
details.push({
|
||||||
|
id: detailId,
|
||||||
|
label: detailLabel,
|
||||||
|
...(reasonCode ? { reasonCode } : {}),
|
||||||
|
...(active ? {} : { active: false }),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!details.length) return null;
|
if (!details.length) return null;
|
||||||
@@ -131,7 +157,7 @@ export function parseReasonCatalogMarkdown(markdown: string): ReasonCatalog {
|
|||||||
details: [] as ReasonCatalogDetail[],
|
details: [] as ReasonCatalogDetail[],
|
||||||
};
|
};
|
||||||
if (!existing.details.some((d) => d.id === detailId)) {
|
if (!existing.details.some((d) => d.id === detailId)) {
|
||||||
existing.details.push({ id: detailId, label: detailLabel });
|
existing.details.push({ id: detailId, label: detailLabel, active: true });
|
||||||
}
|
}
|
||||||
buckets[activeKind].set(categoryId, existing);
|
buckets[activeKind].set(categoryId, existing);
|
||||||
}
|
}
|
||||||
@@ -143,31 +169,37 @@ export function parseReasonCatalogMarkdown(markdown: string): ReasonCatalog {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
let catalogPromise: Promise<ReasonCatalog> | null = null;
|
export function flattenReasonCatalog(
|
||||||
|
catalog: ReasonCatalog,
|
||||||
export async function loadFallbackReasonCatalog() {
|
kind: ReasonCatalogKind,
|
||||||
if (!catalogPromise) {
|
options?: FlattenReasonCatalogOptions
|
||||||
catalogPromise = readFile(path.join(process.cwd(), "downtime_menu.md"), "utf8")
|
) {
|
||||||
.then((raw) => parseReasonCatalogMarkdown(raw))
|
const activeOnly = options?.activeOnly === true;
|
||||||
.catch(() => ({ version: 1, downtime: [], scrap: [] }));
|
|
||||||
}
|
|
||||||
return catalogPromise;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function flattenReasonCatalog(catalog: ReasonCatalog, kind: ReasonCatalogKind) {
|
|
||||||
return (catalog[kind] ?? []).flatMap((category) =>
|
return (catalog[kind] ?? []).flatMap((category) =>
|
||||||
category.details.map((detail) => ({
|
category.details
|
||||||
|
.filter((d) => !activeOnly || isDetailActive(d))
|
||||||
|
.map((detail) => ({
|
||||||
kind,
|
kind,
|
||||||
categoryId: category.id,
|
categoryId: category.id,
|
||||||
categoryLabel: category.label,
|
categoryLabel: category.label,
|
||||||
detailId: detail.id,
|
detailId: detail.id,
|
||||||
detailLabel: detail.label,
|
detailLabel: detail.label,
|
||||||
reasonCode: buildReasonCode(category.id, detail.id),
|
reasonCode: detailEffectiveReasonCode(category, detail),
|
||||||
reasonLabel: `${category.label} > ${detail.label}`,
|
reasonLabel: `${category.label} > ${detail.label}`,
|
||||||
|
active: isDetailActive(detail),
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function canonicalText(value: unknown) {
|
||||||
|
return String(value ?? "")
|
||||||
|
.normalize("NFD")
|
||||||
|
.replace(/[\u0300-\u036f]/g, "")
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/^-+|-+$/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
export function findCatalogReason(
|
export function findCatalogReason(
|
||||||
catalog: ReasonCatalog | null | undefined,
|
catalog: ReasonCatalog | null | undefined,
|
||||||
kind: ReasonCatalogKind,
|
kind: ReasonCatalogKind,
|
||||||
@@ -187,11 +219,38 @@ export function findCatalogReason(
|
|||||||
categoryLabel: category.label,
|
categoryLabel: category.label,
|
||||||
detailId: detail.id,
|
detailId: detail.id,
|
||||||
detailLabel: detail.label,
|
detailLabel: detail.label,
|
||||||
reasonCode: buildReasonCode(category.id, detail.id),
|
reasonCode: detailEffectiveReasonCode(category, detail),
|
||||||
reasonLabel: `${category.label} > ${detail.label}`,
|
reasonLabel: `${category.label} > ${detail.label}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Resolve category/detail + labels by official or derived reasonCode (includes inactive details). */
|
||||||
|
export function findCatalogReasonByReasonCode(
|
||||||
|
catalog: ReasonCatalog | null | undefined,
|
||||||
|
kind: ReasonCatalogKind,
|
||||||
|
reasonCode: string | null | undefined
|
||||||
|
) {
|
||||||
|
if (!catalog) return null;
|
||||||
|
const needle = String(reasonCode ?? "").trim().toUpperCase();
|
||||||
|
if (!needle) return null;
|
||||||
|
for (const category of catalog[kind] ?? []) {
|
||||||
|
for (const detail of category.details) {
|
||||||
|
const rc = detailEffectiveReasonCode(category, detail);
|
||||||
|
if (rc === needle) {
|
||||||
|
return {
|
||||||
|
categoryId: category.id,
|
||||||
|
categoryLabel: category.label,
|
||||||
|
detailId: detail.id,
|
||||||
|
detailLabel: detail.label,
|
||||||
|
reasonCode: rc,
|
||||||
|
reasonLabel: `${category.label} > ${detail.label}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export function toReasonCode(categoryId: unknown, detailId: unknown) {
|
export function toReasonCode(categoryId: unknown, detailId: unknown) {
|
||||||
const cat = canonicalId(categoryId, "");
|
const cat = canonicalId(categoryId, "");
|
||||||
const det = canonicalId(detailId, "");
|
const det = canonicalId(detailId, "");
|
||||||
|
|||||||
98
lib/reasonCatalogDb.ts
Normal file
98
lib/reasonCatalogDb.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import type { Prisma } from "@prisma/client";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import type { ReasonCatalog, ReasonCatalogCategory, ReasonCatalogDetail } from "@/lib/reasonCatalog";
|
||||||
|
import { normalizeReasonCatalog } from "@/lib/reasonCatalog";
|
||||||
|
import { loadFallbackReasonCatalog } from "@/lib/reasonCatalogFallback";
|
||||||
|
|
||||||
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||||
|
return !!value && typeof value === "object" && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full printed code from category prefix + operator numeric suffix (or suffix digits from seed).
|
||||||
|
* Downtime-style keys use a hyphen before the numeric part (e.g. DTPRC-01); short scrap-style
|
||||||
|
* prefixes (e.g. MX) concatenate without hyphen (MX001).
|
||||||
|
*/
|
||||||
|
export function composeReasonCode(prefix: string, suffix: string): string {
|
||||||
|
const p = String(prefix ?? "").trim().toUpperCase();
|
||||||
|
const s = String(suffix ?? "").trim();
|
||||||
|
if (/^\d+$/.test(s) && p.length >= 3) {
|
||||||
|
return `${p}-${s}`.toUpperCase();
|
||||||
|
}
|
||||||
|
return `${p}${s}`.toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isNumericSuffix(value: string): boolean {
|
||||||
|
return /^\d+$/.test(String(value ?? "").trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapKind(kind: string): "downtime" | "scrap" | null {
|
||||||
|
const k = String(kind).toLowerCase();
|
||||||
|
if (k === "downtime" || k === "scrap") return k;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load catalog from Postgres tables. Returns null if org has no catalog rows yet.
|
||||||
|
* Includes inactive rows for historical label resolution (same as prior JSON behavior).
|
||||||
|
*/
|
||||||
|
export async function loadReasonCatalogFromDb(
|
||||||
|
orgId: string,
|
||||||
|
catalogVersion: number
|
||||||
|
): Promise<ReasonCatalog | null> {
|
||||||
|
const rows = await prisma.reasonCatalogCategory.findMany({
|
||||||
|
where: { orgId },
|
||||||
|
include: {
|
||||||
|
items: { orderBy: { sortOrder: "asc" } },
|
||||||
|
},
|
||||||
|
orderBy: [{ kind: "asc" }, { sortOrder: "asc" }],
|
||||||
|
});
|
||||||
|
if (!rows.length) return null;
|
||||||
|
|
||||||
|
const downtime: ReasonCatalogCategory[] = [];
|
||||||
|
const scrap: ReasonCatalogCategory[] = [];
|
||||||
|
|
||||||
|
for (const cat of rows) {
|
||||||
|
const k = mapKind(cat.kind);
|
||||||
|
if (!k) continue;
|
||||||
|
const details: ReasonCatalogDetail[] = cat.items.map((it) => ({
|
||||||
|
id: it.id,
|
||||||
|
label: it.name,
|
||||||
|
reasonCode: it.reasonCode,
|
||||||
|
active: it.active,
|
||||||
|
}));
|
||||||
|
const bucket: ReasonCatalogCategory = {
|
||||||
|
id: cat.id,
|
||||||
|
label: cat.name,
|
||||||
|
details,
|
||||||
|
};
|
||||||
|
if (k === "downtime") downtime.push(bucket);
|
||||||
|
else scrap.push(bucket);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!downtime.length && !scrap.length) return null;
|
||||||
|
return { version: Math.max(1, catalogVersion), downtime, scrap };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** DB first, then legacy JSON in defaults, then file fallback. */
|
||||||
|
export async function effectiveReasonCatalogForOrg(
|
||||||
|
orgId: string,
|
||||||
|
defaultsJson: unknown,
|
||||||
|
settingsVersion: number
|
||||||
|
): Promise<ReasonCatalog> {
|
||||||
|
const fromDb = await loadReasonCatalogFromDb(orgId, settingsVersion);
|
||||||
|
if (fromDb) return fromDb;
|
||||||
|
|
||||||
|
const defs = isPlainObject(defaultsJson) ? defaultsJson : {};
|
||||||
|
const fromJson = normalizeReasonCatalog(defs.reasonCatalog ?? defs.reasonCatalogData);
|
||||||
|
if (fromJson) return fromJson;
|
||||||
|
|
||||||
|
return loadFallbackReasonCatalog();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function bumpOrgSettingsVersion(tx: Prisma.TransactionClient, orgId: string, userId: string) {
|
||||||
|
await tx.orgSettings.update({
|
||||||
|
where: { orgId },
|
||||||
|
data: { version: { increment: 1 }, updatedBy: userId },
|
||||||
|
});
|
||||||
|
}
|
||||||
15
lib/reasonCatalogFallback.ts
Normal file
15
lib/reasonCatalogFallback.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { readFile } from "fs/promises";
|
||||||
|
import path from "path";
|
||||||
|
import { parseReasonCatalogMarkdown, type ReasonCatalog } from "@/lib/reasonCatalog";
|
||||||
|
|
||||||
|
let catalogPromise: Promise<ReasonCatalog> | null = null;
|
||||||
|
|
||||||
|
/** Server-only: reads downtime_menu.md from the repo root. */
|
||||||
|
export async function loadFallbackReasonCatalog() {
|
||||||
|
if (!catalogPromise) {
|
||||||
|
catalogPromise = readFile(path.join(process.cwd(), "downtime_menu.md"), "utf8")
|
||||||
|
.then((raw) => parseReasonCatalogMarkdown(raw))
|
||||||
|
.catch(() => ({ version: 1, downtime: [], scrap: [] }));
|
||||||
|
}
|
||||||
|
return catalogPromise;
|
||||||
|
}
|
||||||
908
lib/recap/getRecapData.ts
Normal file
908
lib/recap/getRecapData.ts
Normal file
@@ -0,0 +1,908 @@
|
|||||||
|
import { unstable_cache } from "next/cache";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { normalizeShiftOverrides, type ShiftOverrideDay } from "@/lib/settings";
|
||||||
|
import type { RecapMachine, RecapQuery, RecapResponse } from "@/lib/recap/types";
|
||||||
|
|
||||||
|
type ShiftLike = {
|
||||||
|
name: string;
|
||||||
|
startTime?: string | null;
|
||||||
|
endTime?: string | null;
|
||||||
|
start?: string | null;
|
||||||
|
end?: string | null;
|
||||||
|
enabled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
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",
|
||||||
|
};
|
||||||
|
|
||||||
|
const STOP_TYPES = new Set(["microstop", "macrostop"]);
|
||||||
|
const STOP_STATUS = new Set(["STOP", "DOWN", "OFFLINE"]);
|
||||||
|
const CACHE_TTL_SEC = 60;
|
||||||
|
const MOLD_LOOKBACK_MS = 14 * 24 * 60 * 60 * 1000;
|
||||||
|
const MOLD_ACTIVE_STALE_MS = 12 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
function safeNum(value: unknown) {
|
||||||
|
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||||
|
if (typeof value === "string") {
|
||||||
|
const n = Number(value);
|
||||||
|
if (Number.isFinite(n)) return n;
|
||||||
|
}
|
||||||
|
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 workOrderKey(value: unknown) {
|
||||||
|
const token = normalizeToken(value);
|
||||||
|
return token ? token.toUpperCase() : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function skuKey(value: unknown) {
|
||||||
|
const token = normalizeToken(value);
|
||||||
|
return token ? token.toUpperCase() : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
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 toIso(value?: Date | null) {
|
||||||
|
return value ? value.toISOString() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
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.isNaN(n)) return new Date(n);
|
||||||
|
const d = new Date(input);
|
||||||
|
return Number.isNaN(d.getTime()) ? null : d;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRange(start?: Date, end?: Date) {
|
||||||
|
const now = new Date();
|
||||||
|
const safeEnd = end && Number.isFinite(end.getTime()) ? end : now;
|
||||||
|
const defaultStart = new Date(safeEnd.getTime() - 24 * 60 * 60 * 1000);
|
||||||
|
const safeStart = start && Number.isFinite(start.getTime()) ? start : defaultStart;
|
||||||
|
if (safeStart.getTime() > safeEnd.getTime()) {
|
||||||
|
return { start: new Date(safeEnd.getTime() - 24 * 60 * 60 * 1000), end: safeEnd };
|
||||||
|
}
|
||||||
|
return { start: safeStart, end: safeEnd };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTimeMinutes(input?: string | null) {
|
||||||
|
if (!input) return null;
|
||||||
|
const match = /^(\d{2}):(\d{2})$/.exec(input.trim());
|
||||||
|
if (!match) return null;
|
||||||
|
const h = Number(match[1]);
|
||||||
|
const m = Number(match[2]);
|
||||||
|
if (!Number.isInteger(h) || !Number.isInteger(m) || h < 0 || h > 23 || m < 0 || m > 59) return null;
|
||||||
|
return h * 60 + m;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLocalMinutes(ts: Date, timeZone: string) {
|
||||||
|
try {
|
||||||
|
const parts = new Intl.DateTimeFormat("en-US", {
|
||||||
|
timeZone,
|
||||||
|
hour12: false,
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
}).formatToParts(ts);
|
||||||
|
const h = Number(parts.find((p) => p.type === "hour")?.value ?? "0");
|
||||||
|
const m = Number(parts.find((p) => p.type === "minute")?.value ?? "0");
|
||||||
|
return h * 60 + m;
|
||||||
|
} catch {
|
||||||
|
return ts.getUTCHours() * 60 + ts.getUTCMinutes();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLocalDayKey(ts: Date, timeZone: string): ShiftOverrideDay {
|
||||||
|
try {
|
||||||
|
const weekday = new Intl.DateTimeFormat("en-US", { timeZone, weekday: "short" }).format(ts);
|
||||||
|
return WEEKDAY_KEY_MAP[weekday] ?? WEEKDAY_KEYS[ts.getUTCDay()];
|
||||||
|
} catch {
|
||||||
|
return WEEKDAY_KEYS[ts.getUTCDay()];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveShiftName(
|
||||||
|
shifts: ShiftLike[],
|
||||||
|
overrides: Record<string, ShiftLike[]> | undefined,
|
||||||
|
ts: Date,
|
||||||
|
timeZone: string
|
||||||
|
) {
|
||||||
|
const dayKey = getLocalDayKey(ts, timeZone);
|
||||||
|
const dayOverrides = overrides?.[dayKey];
|
||||||
|
const activeShifts = dayOverrides ?? shifts;
|
||||||
|
if (!activeShifts.length) return null;
|
||||||
|
|
||||||
|
const nowMin = getLocalMinutes(ts, timeZone);
|
||||||
|
for (const shift of activeShifts) {
|
||||||
|
if (shift.enabled === false) continue;
|
||||||
|
const start = parseTimeMinutes(shift.startTime ?? shift.start ?? null);
|
||||||
|
const end = parseTimeMinutes(shift.endTime ?? shift.end ?? null);
|
||||||
|
if (start == null || end == null) continue;
|
||||||
|
if (start <= end) {
|
||||||
|
if (nowMin >= start && nowMin < end) return shift.name;
|
||||||
|
} else if (nowMin >= start || nowMin < end) {
|
||||||
|
return shift.name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeShiftAlias(shift?: string | null) {
|
||||||
|
const normalized = String(shift ?? "").trim().toLowerCase();
|
||||||
|
if (!normalized) return null;
|
||||||
|
if (normalized === "shift1" || normalized === "shift2" || normalized === "shift3") return normalized;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventDurationSec(data: unknown) {
|
||||||
|
const inner = extractEventData(data);
|
||||||
|
return (
|
||||||
|
safeNum(inner.stoppage_duration_seconds) ??
|
||||||
|
safeNum(inner.stop_duration_seconds) ??
|
||||||
|
safeNum(inner.duration_seconds) ??
|
||||||
|
safeNum(inner.duration_sec) ??
|
||||||
|
safeNum(inner.durationSeconds) ??
|
||||||
|
0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractEventData(data: unknown) {
|
||||||
|
let blob = data;
|
||||||
|
if (typeof blob === "string") {
|
||||||
|
try {
|
||||||
|
blob = JSON.parse(blob);
|
||||||
|
} catch {
|
||||||
|
blob = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const record = typeof blob === "object" && blob ? (blob as Record<string, unknown>) : null;
|
||||||
|
const innerCandidate = record?.data ?? record ?? {};
|
||||||
|
const inner =
|
||||||
|
typeof innerCandidate === "object" && innerCandidate !== null
|
||||||
|
? (innerCandidate as Record<string, unknown>)
|
||||||
|
: {};
|
||||||
|
return inner;
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventStatus(data: unknown) {
|
||||||
|
const inner = extractEventData(data);
|
||||||
|
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) {
|
||||||
|
const inner = extractEventData(data);
|
||||||
|
const direct = String(inner.incidentKey ?? inner.incident_key ?? "").trim();
|
||||||
|
if (direct) return direct;
|
||||||
|
const alertId = String(inner.alert_id ?? inner.alertId ?? "").trim();
|
||||||
|
if (alertId) return `${eventType}:${alertId}`;
|
||||||
|
const startMs = safeNum(inner.start_ms) ?? safeNum(inner.startMs);
|
||||||
|
if (startMs != null) return `${eventType}:${Math.trunc(startMs)}`;
|
||||||
|
return `${eventType}:${ts.getTime()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function moldStartMs(data: unknown, fallbackTs: Date) {
|
||||||
|
const inner = extractEventData(data);
|
||||||
|
return Math.trunc(safeNum(inner.start_ms) ?? safeNum(inner.startMs) ?? fallbackTs.getTime());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseRecapQuery(input: {
|
||||||
|
machineId?: string | null;
|
||||||
|
start?: string | null;
|
||||||
|
end?: string | null;
|
||||||
|
shift?: string | null;
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
machineId: input.machineId ? String(input.machineId).trim() : undefined,
|
||||||
|
start: parseDate(input.start),
|
||||||
|
end: parseDate(input.end),
|
||||||
|
shift: normalizeShiftAlias(input.shift),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function computeRecap(params: Required<Pick<RecapQuery, "orgId">> & {
|
||||||
|
machineId?: string;
|
||||||
|
start: Date;
|
||||||
|
end: Date;
|
||||||
|
shift?: string;
|
||||||
|
}): Promise<RecapResponse> {
|
||||||
|
const machineFilter = params.machineId ? { id: params.machineId } : {};
|
||||||
|
const machines = await prisma.machine.findMany({
|
||||||
|
where: { orgId: params.orgId, ...machineFilter },
|
||||||
|
orderBy: { name: "asc" },
|
||||||
|
select: { id: true, name: true, location: true, tsServer: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!machines.length) {
|
||||||
|
return {
|
||||||
|
range: { start: params.start.toISOString(), end: params.end.toISOString() },
|
||||||
|
availableShifts: [],
|
||||||
|
machines: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const machineIds = machines.map((m) => m.id);
|
||||||
|
const moldStartLookback = new Date(params.end.getTime() - MOLD_LOOKBACK_MS);
|
||||||
|
const [settings, shifts, cyclesRaw, kpisRaw, eventsRaw, reasonsRaw, workOrdersRaw, hbRangeRaw, hbLatestRaw, moldEventsRaw] =
|
||||||
|
await Promise.all([
|
||||||
|
prisma.orgSettings.findUnique({
|
||||||
|
where: { orgId: params.orgId },
|
||||||
|
select: { timezone: true, shiftScheduleOverridesJson: true },
|
||||||
|
}),
|
||||||
|
prisma.orgShift.findMany({
|
||||||
|
where: { orgId: params.orgId },
|
||||||
|
orderBy: { sortOrder: "asc" },
|
||||||
|
select: { name: true, startTime: true, endTime: true, enabled: true, sortOrder: true },
|
||||||
|
}),
|
||||||
|
prisma.machineCycle.findMany({
|
||||||
|
where: {
|
||||||
|
orgId: params.orgId,
|
||||||
|
machineId: { in: machineIds },
|
||||||
|
ts: { gte: params.start, lte: params.end },
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
machineId: true,
|
||||||
|
ts: true,
|
||||||
|
cycleCount: true,
|
||||||
|
workOrderId: true,
|
||||||
|
theoreticalCycleTime: true,
|
||||||
|
sku: true,
|
||||||
|
goodDelta: true,
|
||||||
|
scrapDelta: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.machineKpiSnapshot.findMany({
|
||||||
|
where: {
|
||||||
|
orgId: params.orgId,
|
||||||
|
machineId: { in: machineIds },
|
||||||
|
ts: { gte: params.start, lte: params.end },
|
||||||
|
},
|
||||||
|
orderBy: [{ machineId: "asc" }, { ts: "asc" }],
|
||||||
|
select: {
|
||||||
|
machineId: true,
|
||||||
|
ts: true,
|
||||||
|
workOrderId: true,
|
||||||
|
sku: true,
|
||||||
|
good: true,
|
||||||
|
scrap: true,
|
||||||
|
goodParts: true,
|
||||||
|
scrapParts: true,
|
||||||
|
cycleCount: true,
|
||||||
|
oee: true,
|
||||||
|
availability: true,
|
||||||
|
performance: true,
|
||||||
|
quality: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.machineEvent.findMany({
|
||||||
|
where: {
|
||||||
|
orgId: params.orgId,
|
||||||
|
machineId: { in: machineIds },
|
||||||
|
ts: { gte: params.start, lte: params.end },
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
machineId: true,
|
||||||
|
ts: true,
|
||||||
|
eventType: true,
|
||||||
|
data: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.reasonEntry.findMany({
|
||||||
|
where: {
|
||||||
|
orgId: params.orgId,
|
||||||
|
machineId: { in: machineIds },
|
||||||
|
kind: "downtime",
|
||||||
|
reasonCode: { not: "MOLD_CHANGE" },
|
||||||
|
capturedAt: { gte: params.start, lte: params.end },
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
machineId: true,
|
||||||
|
capturedAt: true,
|
||||||
|
reasonCode: true,
|
||||||
|
reasonLabel: true,
|
||||||
|
durationSeconds: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.machineWorkOrder.findMany({
|
||||||
|
where: {
|
||||||
|
orgId: params.orgId,
|
||||||
|
machineId: { in: machineIds },
|
||||||
|
},
|
||||||
|
orderBy: { updatedAt: "desc" },
|
||||||
|
select: {
|
||||||
|
machineId: true,
|
||||||
|
workOrderId: true,
|
||||||
|
sku: true,
|
||||||
|
targetQty: true,
|
||||||
|
status: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.machineHeartbeat.findMany({
|
||||||
|
where: {
|
||||||
|
orgId: params.orgId,
|
||||||
|
machineId: { in: machineIds },
|
||||||
|
ts: { gte: params.start, lte: params.end },
|
||||||
|
},
|
||||||
|
orderBy: [{ machineId: "asc" }, { ts: "asc" }],
|
||||||
|
select: {
|
||||||
|
machineId: true,
|
||||||
|
ts: true,
|
||||||
|
tsServer: true,
|
||||||
|
status: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.machineHeartbeat.findMany({
|
||||||
|
where: {
|
||||||
|
orgId: params.orgId,
|
||||||
|
machineId: { in: machineIds },
|
||||||
|
tsServer: { lte: params.end },
|
||||||
|
},
|
||||||
|
orderBy: [{ machineId: "asc" }, { tsServer: "desc" }],
|
||||||
|
distinct: ["machineId"],
|
||||||
|
select: {
|
||||||
|
machineId: true,
|
||||||
|
ts: true,
|
||||||
|
tsServer: true,
|
||||||
|
status: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.machineEvent.findMany({
|
||||||
|
where: {
|
||||||
|
orgId: params.orgId,
|
||||||
|
machineId: { in: machineIds },
|
||||||
|
eventType: "mold-change",
|
||||||
|
ts: { gte: moldStartLookback, lte: params.end },
|
||||||
|
},
|
||||||
|
orderBy: [{ machineId: "asc" }, { ts: "asc" }],
|
||||||
|
select: {
|
||||||
|
machineId: true,
|
||||||
|
ts: true,
|
||||||
|
data: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const timeZone = settings?.timezone || "UTC";
|
||||||
|
const shiftOverrides = normalizeShiftOverrides(settings?.shiftScheduleOverridesJson);
|
||||||
|
const orderedEnabledShifts = shifts.filter((s) => s.enabled !== false).sort((a, b) => a.sortOrder - b.sortOrder);
|
||||||
|
const shiftIndex = params.shift ? Number(params.shift.replace("shift", "")) - 1 : -1;
|
||||||
|
const targetShiftName = shiftIndex >= 0 ? orderedEnabledShifts[shiftIndex]?.name ?? "__missing_shift__" : null;
|
||||||
|
|
||||||
|
const inTargetShift = (ts: Date) => {
|
||||||
|
if (!targetShiftName) return true;
|
||||||
|
const resolved = resolveShiftName(shifts, shiftOverrides, ts, timeZone);
|
||||||
|
return resolved === targetShiftName;
|
||||||
|
};
|
||||||
|
|
||||||
|
const cycles = targetShiftName ? cyclesRaw.filter((row) => inTargetShift(row.ts)) : cyclesRaw;
|
||||||
|
const kpis = targetShiftName ? kpisRaw.filter((row) => inTargetShift(row.ts)) : kpisRaw;
|
||||||
|
const events = targetShiftName ? eventsRaw.filter((row) => inTargetShift(row.ts)) : eventsRaw;
|
||||||
|
const reasons = targetShiftName ? reasonsRaw.filter((row) => inTargetShift(row.capturedAt)) : reasonsRaw;
|
||||||
|
const hbRange = targetShiftName ? hbRangeRaw.filter((row) => inTargetShift(row.ts)) : hbRangeRaw;
|
||||||
|
|
||||||
|
const cyclesByMachine = new Map<string, typeof cycles>();
|
||||||
|
const kpisByMachine = new Map<string, typeof kpis>();
|
||||||
|
const eventsByMachine = new Map<string, typeof events>();
|
||||||
|
const reasonsByMachine = new Map<string, typeof reasons>();
|
||||||
|
const workOrdersByMachine = new Map<string, typeof workOrdersRaw>();
|
||||||
|
const hbRangeByMachine = new Map<string, typeof hbRange>();
|
||||||
|
const hbLatestByMachine = new Map(hbLatestRaw.map((row) => [row.machineId, row]));
|
||||||
|
const moldEventsByMachine = new Map<string, typeof moldEventsRaw>();
|
||||||
|
|
||||||
|
for (const row of cycles) {
|
||||||
|
const list = cyclesByMachine.get(row.machineId) ?? [];
|
||||||
|
list.push(row);
|
||||||
|
cyclesByMachine.set(row.machineId, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const row of kpis) {
|
||||||
|
const list = kpisByMachine.get(row.machineId) ?? [];
|
||||||
|
list.push(row);
|
||||||
|
kpisByMachine.set(row.machineId, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const row of events) {
|
||||||
|
const list = eventsByMachine.get(row.machineId) ?? [];
|
||||||
|
list.push(row);
|
||||||
|
eventsByMachine.set(row.machineId, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const row of reasons) {
|
||||||
|
const list = reasonsByMachine.get(row.machineId) ?? [];
|
||||||
|
list.push(row);
|
||||||
|
reasonsByMachine.set(row.machineId, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const row of workOrdersRaw) {
|
||||||
|
const list = workOrdersByMachine.get(row.machineId) ?? [];
|
||||||
|
list.push(row);
|
||||||
|
workOrdersByMachine.set(row.machineId, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const row of hbRange) {
|
||||||
|
const list = hbRangeByMachine.get(row.machineId) ?? [];
|
||||||
|
list.push(row);
|
||||||
|
hbRangeByMachine.set(row.machineId, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const row of moldEventsRaw) {
|
||||||
|
const list = moldEventsByMachine.get(row.machineId) ?? [];
|
||||||
|
list.push(row);
|
||||||
|
moldEventsByMachine.set(row.machineId, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
const machineRows: RecapMachine[] = machines.map((machine) => {
|
||||||
|
const machineCycles = cyclesByMachine.get(machine.id) ?? [];
|
||||||
|
const machineKpis = kpisByMachine.get(machine.id) ?? [];
|
||||||
|
const machineEvents = eventsByMachine.get(machine.id) ?? [];
|
||||||
|
const machineReasons = reasonsByMachine.get(machine.id) ?? [];
|
||||||
|
const machineWorkOrders = workOrdersByMachine.get(machine.id) ?? [];
|
||||||
|
const machineHbRange = hbRangeByMachine.get(machine.id) ?? [];
|
||||||
|
const latestHb = hbLatestByMachine.get(machine.id) ?? null;
|
||||||
|
const machineMoldEvents = moldEventsByMachine.get(machine.id) ?? [];
|
||||||
|
|
||||||
|
const dedupedCycles = dedupeByKey(
|
||||||
|
machineCycles,
|
||||||
|
(cycle) =>
|
||||||
|
`${cycle.ts.getTime()}:${safeNum(cycle.cycleCount) ?? "na"}:${workOrderKey(cycle.workOrderId)}:${skuKey(cycle.sku)}:${safeNum(cycle.goodDelta) ?? "na"}:${safeNum(cycle.scrapDelta) ?? "na"}`
|
||||||
|
);
|
||||||
|
const dedupedKpis = dedupeByKey(
|
||||||
|
machineKpis,
|
||||||
|
(kpi) =>
|
||||||
|
`${kpi.ts.getTime()}:${workOrderKey(kpi.workOrderId)}:${skuKey(kpi.sku)}:${safeNum(kpi.goodParts) ?? safeNum(kpi.good) ?? "na"}:${safeNum(kpi.scrapParts) ?? safeNum(kpi.scrap) ?? "na"}:${safeNum(kpi.cycleCount) ?? "na"}`
|
||||||
|
);
|
||||||
|
const machineWorkOrdersSorted = [...machineWorkOrders].sort(
|
||||||
|
(a, b) => b.updatedAt.getTime() - a.updatedAt.getTime()
|
||||||
|
);
|
||||||
|
|
||||||
|
const targetBySku = new Map<string, { sku: string; target: number }>();
|
||||||
|
for (const wo of machineWorkOrdersSorted) {
|
||||||
|
const sku = normalizeToken(wo.sku);
|
||||||
|
const target = safeNum(wo.targetQty);
|
||||||
|
if (!sku || target == null || target <= 0) continue;
|
||||||
|
const key = skuKey(sku);
|
||||||
|
const current = targetBySku.get(key);
|
||||||
|
if (current) {
|
||||||
|
current.target += Math.max(0, Math.trunc(target));
|
||||||
|
} else {
|
||||||
|
targetBySku.set(key, { sku, target: Math.max(0, Math.trunc(target)) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type SkuAggregate = {
|
||||||
|
machineName: string;
|
||||||
|
sku: string;
|
||||||
|
good: number;
|
||||||
|
scrap: number;
|
||||||
|
target: number | null;
|
||||||
|
};
|
||||||
|
let latestTelemetry: { ts: Date; workOrderId: string | null; sku: string | null } | null = null;
|
||||||
|
|
||||||
|
for (const kpi of dedupedKpis) {
|
||||||
|
if (!latestTelemetry || kpi.ts > latestTelemetry.ts) {
|
||||||
|
latestTelemetry = {
|
||||||
|
ts: kpi.ts,
|
||||||
|
workOrderId: normalizeToken(kpi.workOrderId) || null,
|
||||||
|
sku: normalizeToken(kpi.sku) || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!latestTelemetry) {
|
||||||
|
for (const cycle of dedupedCycles) {
|
||||||
|
if (!latestTelemetry || cycle.ts > latestTelemetry.ts) {
|
||||||
|
latestTelemetry = {
|
||||||
|
ts: cycle.ts,
|
||||||
|
workOrderId: normalizeToken(cycle.workOrderId) || null,
|
||||||
|
sku: normalizeToken(cycle.sku) || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const openWorkOrders = machineWorkOrdersSorted.filter(
|
||||||
|
(wo) => String(wo.status).toUpperCase() !== "COMPLETED"
|
||||||
|
);
|
||||||
|
const rangeWorkOrderProgress = new Map<
|
||||||
|
string,
|
||||||
|
{ goodParts: number; scrapParts: number; cycleCount: number; firstTs: Date | null; lastTs: Date | null }
|
||||||
|
>();
|
||||||
|
const authoritativeSkuMap = new Map<string, SkuAggregate>();
|
||||||
|
let goodParts = 0;
|
||||||
|
let scrapParts = 0;
|
||||||
|
let authoritativeCycleCount = 0;
|
||||||
|
|
||||||
|
const ensureAuthoritativeSku = (
|
||||||
|
skuInput: string | null,
|
||||||
|
targetInput?: number | null,
|
||||||
|
useFallbackTarget = true
|
||||||
|
) => {
|
||||||
|
const skuToken = normalizeToken(skuInput) || "N/A";
|
||||||
|
const skuTokenKey = skuKey(skuToken);
|
||||||
|
const targetFallback = useFallbackTarget ? targetBySku.get(skuTokenKey)?.target ?? null : null;
|
||||||
|
const explicitTarget =
|
||||||
|
targetInput != null && targetInput > 0 ? Math.max(0, Math.trunc(targetInput)) : null;
|
||||||
|
const normalizedTarget = explicitTarget ?? targetFallback;
|
||||||
|
const existing = authoritativeSkuMap.get(skuTokenKey);
|
||||||
|
if (existing) {
|
||||||
|
if (explicitTarget != null) {
|
||||||
|
existing.target = (existing.target ?? 0) + explicitTarget;
|
||||||
|
} else if (normalizedTarget != null && existing.target == null) {
|
||||||
|
existing.target = normalizedTarget;
|
||||||
|
}
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
const created: SkuAggregate = {
|
||||||
|
machineName: machine.name,
|
||||||
|
sku: skuToken,
|
||||||
|
good: 0,
|
||||||
|
scrap: 0,
|
||||||
|
target: normalizedTarget,
|
||||||
|
};
|
||||||
|
authoritativeSkuMap.set(skuTokenKey, created);
|
||||||
|
return created;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const cycle of dedupedCycles) {
|
||||||
|
const skuRaw = normalizeToken(cycle.sku);
|
||||||
|
const g = Math.max(0, Math.trunc(safeNum(cycle.goodDelta) ?? 0));
|
||||||
|
const s = Math.max(0, Math.trunc(safeNum(cycle.scrapDelta) ?? 0));
|
||||||
|
const woKey = workOrderKey(cycle.workOrderId);
|
||||||
|
authoritativeCycleCount += 1;
|
||||||
|
if (g === 0 && s === 0) continue;
|
||||||
|
goodParts += g;
|
||||||
|
scrapParts += s;
|
||||||
|
if (woKey) {
|
||||||
|
const progress = rangeWorkOrderProgress.get(woKey) ?? {
|
||||||
|
goodParts: 0,
|
||||||
|
scrapParts: 0,
|
||||||
|
cycleCount: 0,
|
||||||
|
firstTs: null,
|
||||||
|
lastTs: null,
|
||||||
|
};
|
||||||
|
progress.goodParts += g;
|
||||||
|
progress.scrapParts += s;
|
||||||
|
progress.cycleCount += 1;
|
||||||
|
if (!progress.firstTs || cycle.ts < progress.firstTs) progress.firstTs = cycle.ts;
|
||||||
|
if (!progress.lastTs || cycle.ts > progress.lastTs) progress.lastTs = cycle.ts;
|
||||||
|
rangeWorkOrderProgress.set(woKey, progress);
|
||||||
|
}
|
||||||
|
if (!skuRaw) continue;
|
||||||
|
const skuAgg = ensureAuthoritativeSku(skuRaw, null, true);
|
||||||
|
skuAgg.good += g;
|
||||||
|
skuAgg.scrap += s;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bySku = [...authoritativeSkuMap.values()]
|
||||||
|
.map((row) => ({
|
||||||
|
machineName: row.machineName,
|
||||||
|
sku: row.sku,
|
||||||
|
good: row.good,
|
||||||
|
scrap: row.scrap,
|
||||||
|
target: null as number | null,
|
||||||
|
progressPct: null as number | null,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.good - a.good);
|
||||||
|
|
||||||
|
const sortedKpis = [...dedupedKpis].sort((a, b) => a.ts.getTime() - b.ts.getTime());
|
||||||
|
const weightedAvg = (field: "oee" | "availability" | "performance" | "quality") => {
|
||||||
|
if (!sortedKpis.length) return null;
|
||||||
|
let totalMs = 0;
|
||||||
|
let weightedSum = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < sortedKpis.length; i += 1) {
|
||||||
|
const current = sortedKpis[i];
|
||||||
|
const nextTsMs = (sortedKpis[i + 1]?.ts ?? params.end).getTime();
|
||||||
|
const dt = Math.max(0, nextTsMs - current.ts.getTime());
|
||||||
|
if (dt <= 0) continue;
|
||||||
|
weightedSum += (safeNum(current[field]) ?? 0) * dt;
|
||||||
|
totalMs += dt;
|
||||||
|
}
|
||||||
|
|
||||||
|
return totalMs > 0 ? round2(weightedSum / totalMs) : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
let stopDurSecFromEvents = 0;
|
||||||
|
let stopsCount = 0;
|
||||||
|
for (const event of machineEvents) {
|
||||||
|
const type = String(event.eventType || "").toLowerCase();
|
||||||
|
if (!STOP_TYPES.has(type)) continue;
|
||||||
|
if (!isRealStopEvent(event.data)) continue;
|
||||||
|
stopsCount += 1;
|
||||||
|
stopDurSecFromEvents += eventDurationSec(event.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
const reasonAgg = new Map<string, { reasonLabel: string; seconds: number; count: number }>();
|
||||||
|
let stopDurSecFromReasons = 0;
|
||||||
|
for (const reason of machineReasons) {
|
||||||
|
const label = reason.reasonLabel?.trim() || reason.reasonCode || "Sin razón";
|
||||||
|
const seconds = Math.max(0, safeNum(reason.durationSeconds) ?? 0);
|
||||||
|
stopDurSecFromReasons += seconds;
|
||||||
|
const agg = reasonAgg.get(label) ?? { reasonLabel: label, seconds: 0, count: 0 };
|
||||||
|
agg.seconds += seconds;
|
||||||
|
agg.count += 1;
|
||||||
|
reasonAgg.set(label, agg);
|
||||||
|
}
|
||||||
|
|
||||||
|
const topReasons = [...reasonAgg.values()]
|
||||||
|
.sort((a, b) => b.seconds - a.seconds)
|
||||||
|
.slice(0, 3)
|
||||||
|
.map((row) => ({
|
||||||
|
reasonLabel: row.reasonLabel,
|
||||||
|
minutes: round2(row.seconds / 60),
|
||||||
|
count: row.count,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const totalMin = round2(Math.max(stopDurSecFromEvents, stopDurSecFromReasons) / 60);
|
||||||
|
|
||||||
|
let ongoingStopMin: number | null = null;
|
||||||
|
const latestStatus = String(latestHb?.status ?? "").toUpperCase();
|
||||||
|
const latestTs = latestHb?.tsServer ?? latestHb?.ts ?? null;
|
||||||
|
if (latestTs && STOP_STATUS.has(latestStatus)) {
|
||||||
|
let downStart = latestTs;
|
||||||
|
for (let i = machineHbRange.length - 1; i >= 0; i -= 1) {
|
||||||
|
const hb = machineHbRange[i];
|
||||||
|
const hbStatus = String(hb.status ?? "").toUpperCase();
|
||||||
|
if (!STOP_STATUS.has(hbStatus)) break;
|
||||||
|
downStart = hb.tsServer ?? hb.ts;
|
||||||
|
}
|
||||||
|
ongoingStopMin = round2(Math.max(0, (params.end.getTime() - downStart.getTime()) / 60000));
|
||||||
|
}
|
||||||
|
|
||||||
|
const completed = machineWorkOrdersSorted
|
||||||
|
.filter((wo) => String(wo.status).toUpperCase() === "COMPLETED")
|
||||||
|
.filter((wo) => wo.updatedAt >= params.start && wo.updatedAt <= params.end)
|
||||||
|
.map((wo) => {
|
||||||
|
const progress = rangeWorkOrderProgress.get(workOrderKey(wo.workOrderId)) ?? {
|
||||||
|
goodParts: 0,
|
||||||
|
scrapParts: 0,
|
||||||
|
cycleCount: 0,
|
||||||
|
firstTs: null,
|
||||||
|
lastTs: null,
|
||||||
|
};
|
||||||
|
const durationHrs =
|
||||||
|
progress.firstTs && progress.lastTs
|
||||||
|
? round2((progress.lastTs.getTime() - progress.firstTs.getTime()) / 3600000)
|
||||||
|
: 0;
|
||||||
|
return {
|
||||||
|
id: wo.workOrderId,
|
||||||
|
sku: wo.sku,
|
||||||
|
goodParts: progress.goodParts,
|
||||||
|
durationHrs,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort((a, b) => b.goodParts - a.goodParts);
|
||||||
|
|
||||||
|
const telemetryWorkOrderKey = workOrderKey(latestTelemetry?.workOrderId);
|
||||||
|
const matchedTelemetryWo = telemetryWorkOrderKey
|
||||||
|
? openWorkOrders.find((wo) => workOrderKey(wo.workOrderId) === telemetryWorkOrderKey) ?? null
|
||||||
|
: null;
|
||||||
|
const activeWo = matchedTelemetryWo ?? openWorkOrders[0] ?? null;
|
||||||
|
const activeWorkOrderId =
|
||||||
|
normalizeToken(latestTelemetry?.workOrderId) || normalizeToken(activeWo?.workOrderId) || null;
|
||||||
|
const activeWorkOrderSku =
|
||||||
|
normalizeToken(latestTelemetry?.sku) || normalizeToken(activeWo?.sku) || null;
|
||||||
|
const activeWorkOrderKey = workOrderKey(activeWorkOrderId);
|
||||||
|
const activeTargetSource =
|
||||||
|
activeWorkOrderKey
|
||||||
|
? machineWorkOrdersSorted.find((wo) => workOrderKey(wo.workOrderId) === activeWorkOrderKey) ??
|
||||||
|
activeWo
|
||||||
|
: activeWo;
|
||||||
|
|
||||||
|
let activeProgressPct: number | null = null;
|
||||||
|
let activeStartedAt: string | null = null;
|
||||||
|
if (activeWorkOrderId) {
|
||||||
|
const rangeProgress = activeWorkOrderKey ? rangeWorkOrderProgress.get(activeWorkOrderKey) ?? null : null;
|
||||||
|
const producedForProgress = rangeProgress
|
||||||
|
? rangeProgress.goodParts + rangeProgress.scrapParts
|
||||||
|
: 0;
|
||||||
|
const targetQty = safeNum(activeTargetSource?.targetQty);
|
||||||
|
if (targetQty && targetQty > 0) {
|
||||||
|
activeProgressPct = round2((producedForProgress / targetQty) * 100);
|
||||||
|
}
|
||||||
|
activeStartedAt = toIso(rangeProgress?.firstTs ?? latestTelemetry?.ts ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstProductionMsAfterMoldStart = (startMs: number) => {
|
||||||
|
let best: number | null = null;
|
||||||
|
for (const cycle of dedupedCycles) {
|
||||||
|
const t = cycle.ts.getTime();
|
||||||
|
if (t <= startMs) continue;
|
||||||
|
const g = safeNum(cycle.goodDelta) ?? 0;
|
||||||
|
const s = safeNum(cycle.scrapDelta) ?? 0;
|
||||||
|
if (g > 0 || s > 0) {
|
||||||
|
if (best == null || t < best) best = t;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const kpi of dedupedKpis) {
|
||||||
|
const t = kpi.ts.getTime();
|
||||||
|
if (t <= startMs) continue;
|
||||||
|
const g = safeNum(kpi.good) ?? safeNum(kpi.goodParts) ?? 0;
|
||||||
|
const s = safeNum(kpi.scrap) ?? safeNum(kpi.scrapParts) ?? 0;
|
||||||
|
if (g > 0 || s > 0) {
|
||||||
|
if (best == null || t < best) best = t;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
};
|
||||||
|
|
||||||
|
const moldActiveByIncident = new Map<string, number>();
|
||||||
|
for (const event of machineMoldEvents) {
|
||||||
|
const inner = extractEventData(event.data);
|
||||||
|
const isUpdate = safeBool(inner.is_update ?? inner.isUpdate);
|
||||||
|
const isAutoAck = safeBool(inner.is_auto_ack ?? inner.isAutoAck);
|
||||||
|
if (isUpdate || isAutoAck) continue;
|
||||||
|
|
||||||
|
const key = eventIncidentKey(event.data, "mold-change", event.ts);
|
||||||
|
const status = eventStatus(event.data);
|
||||||
|
if (status === "resolved") {
|
||||||
|
moldActiveByIncident.delete(key);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (status === "active" || !status) {
|
||||||
|
if (params.end.getTime() - event.ts.getTime() > MOLD_ACTIVE_STALE_MS) continue;
|
||||||
|
moldActiveByIncident.set(key, moldStartMs(event.data, event.ts));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [k, startMs] of [...moldActiveByIncident.entries()]) {
|
||||||
|
const resumeMs = firstProductionMsAfterMoldStart(startMs);
|
||||||
|
if (resumeMs != null && resumeMs <= params.end.getTime()) {
|
||||||
|
moldActiveByIncident.delete(k);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let moldChangeStartMs: number | null = null;
|
||||||
|
for (const startMs of moldActiveByIncident.values()) {
|
||||||
|
if (moldChangeStartMs == null || startMs > moldChangeStartMs) moldChangeStartMs = startMs;
|
||||||
|
}
|
||||||
|
const moldChangeInProgress = moldChangeStartMs != null;
|
||||||
|
|
||||||
|
let uptimePct: number | null = null;
|
||||||
|
if (machineHbRange.length) {
|
||||||
|
let onlineCount = 0;
|
||||||
|
for (const hb of machineHbRange) {
|
||||||
|
const status = String(hb.status ?? "").toUpperCase();
|
||||||
|
if (!STOP_STATUS.has(status)) onlineCount += 1;
|
||||||
|
}
|
||||||
|
uptimePct = round2((onlineCount / machineHbRange.length) * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
machineId: machine.id,
|
||||||
|
machineName: machine.name,
|
||||||
|
location: machine.location,
|
||||||
|
production: {
|
||||||
|
goodParts,
|
||||||
|
scrapParts,
|
||||||
|
totalCycles: authoritativeCycleCount,
|
||||||
|
bySku,
|
||||||
|
},
|
||||||
|
oee: {
|
||||||
|
avg: weightedAvg("oee"),
|
||||||
|
availability: weightedAvg("availability"),
|
||||||
|
performance: weightedAvg("performance"),
|
||||||
|
quality: weightedAvg("quality"),
|
||||||
|
},
|
||||||
|
downtime: {
|
||||||
|
totalMin,
|
||||||
|
stopsCount,
|
||||||
|
topReasons,
|
||||||
|
ongoingStopMin,
|
||||||
|
},
|
||||||
|
workOrders: {
|
||||||
|
completed,
|
||||||
|
active: activeWorkOrderId
|
||||||
|
? {
|
||||||
|
id: activeWorkOrderId,
|
||||||
|
sku: activeWorkOrderSku,
|
||||||
|
progressPct: activeProgressPct,
|
||||||
|
startedAt: activeStartedAt,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
moldChangeInProgress,
|
||||||
|
moldChangeStartMs,
|
||||||
|
},
|
||||||
|
heartbeat: {
|
||||||
|
lastSeenAt: toIso(
|
||||||
|
(() => {
|
||||||
|
const hbMs = latestHb ? (latestHb.tsServer ?? latestHb.ts).getTime() : null;
|
||||||
|
const machineMs = machine.tsServer.getTime();
|
||||||
|
if (hbMs != null) return new Date(Math.max(hbMs, machineMs));
|
||||||
|
return machine.tsServer;
|
||||||
|
})()
|
||||||
|
),
|
||||||
|
uptimePct,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
range: {
|
||||||
|
start: params.start.toISOString(),
|
||||||
|
end: params.end.toISOString(),
|
||||||
|
},
|
||||||
|
availableShifts: orderedEnabledShifts.map((shift, idx) => ({
|
||||||
|
id: `shift${idx + 1}`,
|
||||||
|
name: shift.name,
|
||||||
|
})),
|
||||||
|
machines: machineRows,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getRecapDataCached(params: RecapQuery): Promise<RecapResponse> {
|
||||||
|
const { start, end } = normalizeRange(params.start, params.end);
|
||||||
|
const machineId = params.machineId?.trim() || undefined;
|
||||||
|
const shift = normalizeShiftAlias(params.shift) ?? undefined;
|
||||||
|
|
||||||
|
const cacheKey = [
|
||||||
|
"recap",
|
||||||
|
params.orgId,
|
||||||
|
machineId ?? "all",
|
||||||
|
String(start.getTime()),
|
||||||
|
String(end.getTime()),
|
||||||
|
shift ?? "all",
|
||||||
|
];
|
||||||
|
|
||||||
|
const cached = unstable_cache(
|
||||||
|
() =>
|
||||||
|
computeRecap({
|
||||||
|
orgId: params.orgId,
|
||||||
|
machineId,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
shift,
|
||||||
|
}),
|
||||||
|
cacheKey,
|
||||||
|
{
|
||||||
|
revalidate: CACHE_TTL_SEC,
|
||||||
|
tags: [`recap:${params.orgId}`],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return cached();
|
||||||
|
}
|
||||||
131
lib/recap/machineState.ts
Normal file
131
lib/recap/machineState.ts
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import type { TimelineEventRow } from "@/lib/recap/timeline";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared classifier for machine state across /recap, /machines, /overview.
|
||||||
|
*
|
||||||
|
* State precedence (top wins):
|
||||||
|
* 1. OFFLINE — heartbeat dead
|
||||||
|
* 2. MOLD_CHANGE — operator initiated mold swap
|
||||||
|
* 3. STOPPED — should be producing, isn't
|
||||||
|
* 4. DATA_LOSS — producing but tracking off (operator forgot START)
|
||||||
|
* 5. IDLE — nothing loaded, nothing running, nothing expected
|
||||||
|
* 6. RUNNING — healthy
|
||||||
|
*
|
||||||
|
* Inputs are intentionally raw and computed by the caller, not fetched here,
|
||||||
|
* so this module stays pure (testable, no DB/Prisma dependency).
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type MachineStateName =
|
||||||
|
| "offline"
|
||||||
|
| "mold-change"
|
||||||
|
| "stopped"
|
||||||
|
| "data-loss"
|
||||||
|
| "idle"
|
||||||
|
| "running";
|
||||||
|
|
||||||
|
export type MachineStateResult =
|
||||||
|
| { state: "offline"; lastSeenMs: number | null; offlineForMin: number }
|
||||||
|
| {
|
||||||
|
state: "mold-change";
|
||||||
|
moldChangeStartMs: number | null;
|
||||||
|
moldChangeMin: number;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
state: "stopped";
|
||||||
|
ongoingStopMin: number;
|
||||||
|
stopStartedAtMs: number | null;
|
||||||
|
}
|
||||||
|
| { state: "idle" }
|
||||||
|
| { state: "running" };
|
||||||
|
|
||||||
|
export type MachineStateInputs = {
|
||||||
|
/** Heartbeat freshness — true if the Pi has been seen within the offline threshold */
|
||||||
|
heartbeatAlive: boolean;
|
||||||
|
/** Last heartbeat timestamp in ms (or null if never seen) */
|
||||||
|
lastSeenMs: number | null;
|
||||||
|
/** Computed offline duration in ms — used when heartbeatAlive is false */
|
||||||
|
offlineForMs: number;
|
||||||
|
|
||||||
|
/** Operator pressed START — true if latest KPI snapshot has trackingEnabled=true */
|
||||||
|
trackingEnabled: boolean;
|
||||||
|
|
||||||
|
/** A work order with status RUNNING or PENDING is currently assigned */
|
||||||
|
hasActiveWorkOrder: boolean;
|
||||||
|
|
||||||
|
/** Active mold-change event (from timeline events) */
|
||||||
|
activeMoldChange: { startedAtMs: number } | null;
|
||||||
|
|
||||||
|
/** Active macrostop event (from timeline events) — fires when tracking on + no cycles */
|
||||||
|
activeMacrostop: { startedAtMs: number } | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Untracked cycles arriving while tracking is OFF.
|
||||||
|
* Caller computes by counting MachineCycle rows in the last UNTRACKED_WINDOW_MS
|
||||||
|
* where ts > latestKpi.ts (so they're "after" the tracking-off snapshot).
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* Most recent cycle timestamp regardless of tracking — used as a sanity check
|
||||||
|
* for IDLE classification.
|
||||||
|
*/
|
||||||
|
lastCycleTsMs: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Trigger thresholds — tunable
|
||||||
|
|
||||||
|
const RECENT_CYCLE_MS = 15 * 60 * 1000; // for IDLE check — "no cycles in 15 min"
|
||||||
|
|
||||||
|
export function classifyMachineState(
|
||||||
|
inputs: MachineStateInputs,
|
||||||
|
nowMs: number
|
||||||
|
): MachineStateResult {
|
||||||
|
// 1. OFFLINE — wins over everything. If we can't see the Pi, nothing else is reliable.
|
||||||
|
if (!inputs.heartbeatAlive) {
|
||||||
|
return {
|
||||||
|
state: "offline",
|
||||||
|
lastSeenMs: inputs.lastSeenMs,
|
||||||
|
offlineForMin: Math.max(0, Math.floor(inputs.offlineForMs / 60000)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. MOLD_CHANGE — operator-initiated, suppresses STOPPED/ATTENTION even if cycles missing
|
||||||
|
if (inputs.activeMoldChange) {
|
||||||
|
return {
|
||||||
|
state: "mold-change",
|
||||||
|
moldChangeStartMs: inputs.activeMoldChange.startedAtMs,
|
||||||
|
moldChangeMin: Math.max(
|
||||||
|
0,
|
||||||
|
Math.floor((nowMs - inputs.activeMoldChange.startedAtMs) / 60000)
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. DATA_LOSS — tracking off but cycles arriving. Operator forgot START.
|
||||||
|
// Check this BEFORE STOPPED because cycles ARE arriving (so the "no cycles" branch
|
||||||
|
// would never fire), but we still want to flag it.
|
||||||
|
|
||||||
|
// 4. STOPPED — should be producing, isn't. Two reasons:
|
||||||
|
// a) machine_fault: operator pressed START, macrostop event active → mechanical issue
|
||||||
|
// b) not_started: operator never pressed START but a WO is loaded
|
||||||
|
// 4. STOPPED — machine should be producing, isn't.
|
||||||
|
// The Pi only emits macrostop events when tracking is on AND a WO is active,
|
||||||
|
// so the presence of an active macrostop event is sufficient.
|
||||||
|
if (inputs.activeMacrostop) {
|
||||||
|
const startedAt = inputs.activeMacrostop.startedAtMs;
|
||||||
|
return {
|
||||||
|
state: "stopped",
|
||||||
|
ongoingStopMin: Math.max(0, Math.floor((nowMs - startedAt) / 60000)),
|
||||||
|
stopStartedAtMs: startedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. IDLE — no one expects this machine to be doing anything right now.
|
||||||
|
// No tracking, no WO, no recent cycles. Calm gray.
|
||||||
|
const cycledRecently =
|
||||||
|
inputs.lastCycleTsMs != null && nowMs - inputs.lastCycleTsMs <= RECENT_CYCLE_MS;
|
||||||
|
if (!inputs.trackingEnabled && !inputs.hasActiveWorkOrder && !cycledRecently) {
|
||||||
|
return { state: "idle" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. RUNNING — default. Tracking on, WO loaded, cycles flowing.
|
||||||
|
return { state: "running" };
|
||||||
|
}
|
||||||
27
lib/recap/progressDisplay.ts
Normal file
27
lib/recap/progressDisplay.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
/**
|
||||||
|
* Recap & work-order progress: large targets (e.g. 301k) make raw % < 1.
|
||||||
|
* Rounding to integer shows 0%; bar width 0.17% is invisible. Use decimals + a visual floor for the bar.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** "0.17%" with enough precision when needed; "—" for null. */
|
||||||
|
export function formatRecapProgressPercent(
|
||||||
|
pct: number | null | undefined,
|
||||||
|
locale: string
|
||||||
|
): string {
|
||||||
|
if (pct == null || Number.isNaN(pct)) return "—";
|
||||||
|
if (pct <= 0) return "0%";
|
||||||
|
if (pct < 10) {
|
||||||
|
return `${pct.toLocaleString(locale, { maximumFractionDigits: 2, minimumFractionDigits: 0 })}%`;
|
||||||
|
}
|
||||||
|
return `${Math.round(pct).toLocaleString(locale)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* For CSS width %: keep proportional when ≥2%; below that, any positive progress
|
||||||
|
* needs a minimum or the bar looks like a single pixel.
|
||||||
|
*/
|
||||||
|
export function progressBarWidthPercent(pct: number | null | undefined): number {
|
||||||
|
if (pct == null || Number.isNaN(pct) || pct <= 0) return 0;
|
||||||
|
if (pct < 2) return Math.max(2, Math.min(100, pct));
|
||||||
|
return Math.min(100, pct);
|
||||||
|
}
|
||||||
4
lib/recap/recapUiConstants.ts
Normal file
4
lib/recap/recapUiConstants.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
/**
|
||||||
|
* Client-safe recap thresholds. Kept in sync with OFFLINE logic in lib/recap/redesign.ts.
|
||||||
|
*/
|
||||||
|
export const RECAP_HEARTBEAT_STALE_MS = 5 * 60 * 1000;
|
||||||
915
lib/recap/redesign.ts
Normal file
915
lib/recap/redesign.ts
Normal file
@@ -0,0 +1,915 @@
|
|||||||
|
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 { classifyMachineState, type MachineStateResult } from "@/lib/recap/machineState";
|
||||||
|
import { RECAP_HEARTBEAT_STALE_MS } from "@/lib/recap/recapUiConstants";
|
||||||
|
import type {
|
||||||
|
RecapDetailResponse,
|
||||||
|
RecapMachine,
|
||||||
|
RecapMachineDetail,
|
||||||
|
RecapMachineStatus,
|
||||||
|
RecapRangeMode,
|
||||||
|
RecapStateContext,
|
||||||
|
RecapSummaryMachine,
|
||||||
|
RecapSummaryResponse,
|
||||||
|
} from "@/lib/recap/types";
|
||||||
|
|
||||||
|
type DetailRangeInput = {
|
||||||
|
mode?: string | null;
|
||||||
|
start?: string | null;
|
||||||
|
end?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const OFFLINE_THRESHOLD_MS = RECAP_HEARTBEAT_STALE_MS;
|
||||||
|
const TIMELINE_EVENT_LOOKBACK_MS = 24 * 60 * 60 * 1000;
|
||||||
|
const TIMELINE_CYCLE_LOOKBACK_MS = 15 * 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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect active episodes (macrostop, mold-change) from event rows.
|
||||||
|
// Returns the latest non-auto-ack episode whose final status is "active"
|
||||||
|
// and that's been refreshed within ACTIVE_STALE_MS.
|
||||||
|
const ACTIVE_STALE_MS = 2 * 60 * 1000;
|
||||||
|
|
||||||
|
type ActiveEpisode = { startedAtMs: number; lastTsMs: number };
|
||||||
|
|
||||||
|
function detectActiveEpisode(
|
||||||
|
events: TimelineEventRow[] | undefined,
|
||||||
|
eventType: "macrostop" | "mold-change",
|
||||||
|
endMs: number
|
||||||
|
): ActiveEpisode | null {
|
||||||
|
if (!events || events.length === 0) return null;
|
||||||
|
|
||||||
|
type Episode = { firstTsMs: number; lastTsMs: number; lastStatus: string; lastCycleTs: number | null };
|
||||||
|
const episodes = new Map<string, Episode>();
|
||||||
|
|
||||||
|
for (const event of events) {
|
||||||
|
if (String(event.eventType || "").toLowerCase() !== eventType) continue;
|
||||||
|
|
||||||
|
let parsed: unknown = event.data;
|
||||||
|
if (typeof parsed === "string") {
|
||||||
|
try { parsed = JSON.parse(parsed); } catch { parsed = null; }
|
||||||
|
}
|
||||||
|
const data: Record<string, unknown> =
|
||||||
|
parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
||||||
|
? (parsed as Record<string, unknown>)
|
||||||
|
: {};
|
||||||
|
|
||||||
|
const isAutoAck =
|
||||||
|
data.is_auto_ack === true ||
|
||||||
|
data.isAutoAck === true ||
|
||||||
|
data.is_auto_ack === "true" ||
|
||||||
|
data.isAutoAck === "true";
|
||||||
|
if (isAutoAck) continue;
|
||||||
|
|
||||||
|
const status = String(data.status ?? "").trim().toLowerCase();
|
||||||
|
const incidentKey = String(data.incidentKey ?? data.incident_key ?? "").trim()
|
||||||
|
|| `${eventType}:${event.ts.getTime()}`;
|
||||||
|
const tsMs = event.ts.getTime();
|
||||||
|
const lastCycleTs = Number(data.last_cycle_timestamp);
|
||||||
|
|
||||||
|
const existing = episodes.get(incidentKey);
|
||||||
|
if (!existing) {
|
||||||
|
episodes.set(incidentKey, {
|
||||||
|
firstTsMs: tsMs,
|
||||||
|
lastTsMs: tsMs,
|
||||||
|
lastStatus: status,
|
||||||
|
lastCycleTs: Number.isFinite(lastCycleTs) && lastCycleTs > 0 ? lastCycleTs : null,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
existing.firstTsMs = Math.min(existing.firstTsMs, tsMs);
|
||||||
|
if (tsMs >= existing.lastTsMs) {
|
||||||
|
existing.lastTsMs = tsMs;
|
||||||
|
existing.lastStatus = status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let best: ActiveEpisode | null = null;
|
||||||
|
for (const ep of episodes.values()) {
|
||||||
|
if (ep.lastStatus !== "active") continue;
|
||||||
|
if (endMs - ep.lastTsMs > ACTIVE_STALE_MS) continue;
|
||||||
|
// Prefer the freshest active episode (highest lastTsMs)
|
||||||
|
if (!best || ep.lastTsMs > best.lastTsMs) {
|
||||||
|
best = {
|
||||||
|
startedAtMs: ep.lastCycleTs ?? ep.firstTsMs,
|
||||||
|
lastTsMs: ep.lastTsMs,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusFromMachine(
|
||||||
|
machine: RecapMachine,
|
||||||
|
endMs: number,
|
||||||
|
events?: TimelineEventRow[]
|
||||||
|
): {
|
||||||
|
status: RecapMachineStatus;
|
||||||
|
result: MachineStateResult;
|
||||||
|
stateContext: RecapStateContext;
|
||||||
|
lastSeenMs: number | null;
|
||||||
|
offlineForMin: number | null;
|
||||||
|
ongoingStopMin: number | null;
|
||||||
|
} {
|
||||||
|
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 heartbeatAlive = Number.isFinite(lastSeenMs ?? Number.NaN) && offlineForMs <= OFFLINE_THRESHOLD_MS;
|
||||||
|
|
||||||
|
const activeMacrostop = detectActiveEpisode(events, "macrostop", endMs);
|
||||||
|
const activeMoldChange = detectActiveEpisode(events, "mold-change", endMs);
|
||||||
|
|
||||||
|
// Round 1 limitation: trackingEnabled and untrackedCycles inputs require KPI/cycle queries
|
||||||
|
// we don't yet plumb here. We approximate from the legacy fields:
|
||||||
|
// - trackingEnabled: true when there's an active macrostop (Pi only fires those when tracking on)
|
||||||
|
// OR when an active WO exists and machine.workOrders.moldChangeInProgress is false.
|
||||||
|
// This is a SIMPLIFICATION; Round 3 will replace with real KPI snapshot read.
|
||||||
|
// - untrackedCycles: 0 (Round 3 will compute from MachineCycle vs latest KPI)
|
||||||
|
//
|
||||||
|
// Effect for Round 1: STOPPED `not_started` reason cannot trigger yet (we always assume tracking
|
||||||
|
// is on when a WO exists). Only `machine_fault` STOPPED fires. DATA_LOSS cannot fire yet.
|
||||||
|
// IDLE fires correctly when there's no WO and no recent activity.
|
||||||
|
const hasActiveWorkOrder = machine.workOrders.active != null;
|
||||||
|
const trackingEnabledApprox = hasActiveWorkOrder; // see comment above
|
||||||
|
|
||||||
|
const lastCycleTsMs = (() => {
|
||||||
|
// Best-effort: use the machine's heartbeat as a "recent activity" proxy.
|
||||||
|
// The Pi only heartbeats every minute regardless of cycles, so this is a weak signal.
|
||||||
|
// Round 3 will pass the actual latest cycle ts.
|
||||||
|
return lastSeenMs;
|
||||||
|
})();
|
||||||
|
|
||||||
|
const result = classifyMachineState(
|
||||||
|
{
|
||||||
|
heartbeatAlive,
|
||||||
|
lastSeenMs,
|
||||||
|
offlineForMs,
|
||||||
|
trackingEnabled: trackingEnabledApprox,
|
||||||
|
hasActiveWorkOrder,
|
||||||
|
activeMoldChange,
|
||||||
|
activeMacrostop,
|
||||||
|
lastCycleTsMs,
|
||||||
|
},
|
||||||
|
endMs
|
||||||
|
);
|
||||||
|
|
||||||
|
// Map the rich classifier result back to the existing RecapMachineStatus union
|
||||||
|
const status: RecapMachineStatus = result.state;
|
||||||
|
|
||||||
|
// Pull common fields out for the caller's convenience
|
||||||
|
let ongoingStopMin: number | null = null;
|
||||||
|
if (result.state === "stopped") ongoingStopMin = result.ongoingStopMin;
|
||||||
|
|
||||||
|
const stateContext: RecapStateContext = {};
|
||||||
|
|
||||||
|
|
||||||
|
return {
|
||||||
|
status,
|
||||||
|
result,
|
||||||
|
stateContext,
|
||||||
|
lastSeenMs,
|
||||||
|
offlineForMin: result.state === "offline" ? result.offlineForMin : null,
|
||||||
|
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: new Date(params.start.getTime() - TIMELINE_CYCLE_LOOKBACK_MS),
|
||||||
|
lte: params.end,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: [{ machineId: "asc" }, { ts: "asc" }],
|
||||||
|
select: {
|
||||||
|
machineId: true,
|
||||||
|
ts: true,
|
||||||
|
cycleCount: true,
|
||||||
|
actualCycleTime: true,
|
||||||
|
theoreticalCycleTime: 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,
|
||||||
|
theoreticalCycleTime: row.theoreticalCycleTime ?? null,
|
||||||
|
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;
|
||||||
|
events?: TimelineEventRow[];
|
||||||
|
}): RecapSummaryMachine {
|
||||||
|
const { machine, miniTimeline, rangeEndMs, events } = params;
|
||||||
|
const status = statusFromMachine(machine, rangeEndMs, events);
|
||||||
|
|
||||||
|
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,
|
||||||
|
stateContext: status.stateContext,
|
||||||
|
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: 60,
|
||||||
|
});
|
||||||
|
|
||||||
|
return toSummaryMachine({
|
||||||
|
machine,
|
||||||
|
miniTimeline,
|
||||||
|
rangeEndMs: end.getTime(),
|
||||||
|
events: timelineRows.eventsByMachine.get(machine.machineId),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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 {
|
||||||
|
hasEnabledShifts: false,
|
||||||
|
range: null,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 shiftEndUtc = zonedToUtcDate({
|
||||||
|
...endDate,
|
||||||
|
hours: Math.floor(endMin / 60),
|
||||||
|
minutes: endMin % 60,
|
||||||
|
timeZone,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (shiftEndUtc <= start) continue;
|
||||||
|
|
||||||
|
// Cap end at "now" so we render shift-so-far, not shift-as-planned.
|
||||||
|
// Without cap:
|
||||||
|
// - timeline fills future minutes with idle (visual lie)
|
||||||
|
// - offline calc = (shift_end_future - last_seen) = looks 5h offline
|
||||||
|
// even on a machine producing right now
|
||||||
|
const end = params.now < shiftEndUtc ? params.now : shiftEndUtc;
|
||||||
|
|
||||||
|
return {
|
||||||
|
hasEnabledShifts: true,
|
||||||
|
range: { start, end },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
hasEnabledShifts: true,
|
||||||
|
range: null,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveDetailRange(params: { orgId: string; input: DetailRangeInput }) {
|
||||||
|
const now = new Date(Math.floor(Date.now() / 60000) * 60000);
|
||||||
|
const requestedMode = normalizedRangeMode(params.input.mode);
|
||||||
|
const shiftEnabledCount = await prisma.orgShift.count({
|
||||||
|
where: {
|
||||||
|
orgId: params.orgId,
|
||||||
|
enabled: { not: false },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const shiftAvailable = shiftEnabledCount > 0;
|
||||||
|
|
||||||
|
if (requestedMode === "custom") {
|
||||||
|
const start = parseDate(params.input.start);
|
||||||
|
const end = parseDate(params.input.end);
|
||||||
|
if (start && end && end > start) {
|
||||||
|
return {
|
||||||
|
requestedMode,
|
||||||
|
mode: requestedMode,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
shiftAvailable,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requestedMode === "yesterday") {
|
||||||
|
const settings = await prisma.orgSettings.findUnique({
|
||||||
|
where: { orgId: params.orgId },
|
||||||
|
select: { timezone: true },
|
||||||
|
});
|
||||||
|
const timeZone = settings?.timezone || "America/Mexico_City";
|
||||||
|
const localNow = getLocalParts(now, timeZone);
|
||||||
|
const today = { year: localNow.year, month: localNow.month, day: localNow.day };
|
||||||
|
const yesterday = addDays(today, -1);
|
||||||
|
const start = zonedToUtcDate({
|
||||||
|
...yesterday,
|
||||||
|
hours: 0,
|
||||||
|
minutes: 0,
|
||||||
|
timeZone,
|
||||||
|
});
|
||||||
|
const end = zonedToUtcDate({
|
||||||
|
...today,
|
||||||
|
hours: 0,
|
||||||
|
minutes: 0,
|
||||||
|
timeZone,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
requestedMode,
|
||||||
|
mode: requestedMode,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
shiftAvailable,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requestedMode === "shift") {
|
||||||
|
const shiftRange = await resolveCurrentShiftRange({ orgId: params.orgId, now });
|
||||||
|
if (shiftRange.range) {
|
||||||
|
return {
|
||||||
|
requestedMode,
|
||||||
|
mode: requestedMode,
|
||||||
|
start: shiftRange.range.start,
|
||||||
|
end: shiftRange.range.end,
|
||||||
|
shiftAvailable,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
if (!shiftRange.hasEnabledShifts) {
|
||||||
|
return {
|
||||||
|
requestedMode,
|
||||||
|
mode: "24h" as const,
|
||||||
|
start: new Date(now.getTime() - 24 * 60 * 60 * 1000),
|
||||||
|
end: now,
|
||||||
|
shiftAvailable,
|
||||||
|
fallbackReason: "shift-unavailable" as const,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
requestedMode,
|
||||||
|
mode: "24h" as const,
|
||||||
|
start: new Date(now.getTime() - 24 * 60 * 60 * 1000),
|
||||||
|
end: now,
|
||||||
|
shiftAvailable,
|
||||||
|
fallbackReason: "shift-inactive" as const,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
requestedMode,
|
||||||
|
mode: "24h" as const,
|
||||||
|
start: new Date(now.getTime() - 24 * 60 * 60 * 1000),
|
||||||
|
end: now,
|
||||||
|
shiftAvailable,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function computeRecapMachineDetail(params: {
|
||||||
|
orgId: string;
|
||||||
|
machineId: string;
|
||||||
|
range: {
|
||||||
|
requestedMode: RecapRangeMode;
|
||||||
|
mode: RecapRangeMode;
|
||||||
|
start: Date;
|
||||||
|
end: Date;
|
||||||
|
shiftAvailable: boolean;
|
||||||
|
fallbackReason?: "shift-unavailable" | "shift-inactive";
|
||||||
|
};
|
||||||
|
}) {
|
||||||
|
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(),
|
||||||
|
timelineRows.eventsByMachine.get(params.machineId)
|
||||||
|
);
|
||||||
|
|
||||||
|
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,
|
||||||
|
stateContext: status.stateContext,
|
||||||
|
|
||||||
|
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: {
|
||||||
|
requestedMode: range.requestedMode,
|
||||||
|
mode: range.mode,
|
||||||
|
start: range.start.toISOString(),
|
||||||
|
end: range.end.toISOString(),
|
||||||
|
shiftAvailable: range.shiftAvailable,
|
||||||
|
fallbackReason: range.fallbackReason,
|
||||||
|
},
|
||||||
|
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;
|
||||||
|
requestedMode: RecapRangeMode;
|
||||||
|
mode: RecapRangeMode;
|
||||||
|
shiftAvailable: boolean;
|
||||||
|
fallbackReason?: "shift-unavailable" | "shift-inactive";
|
||||||
|
startMs: number;
|
||||||
|
endMs: number;
|
||||||
|
}) {
|
||||||
|
return [
|
||||||
|
"recap-detail-v1",
|
||||||
|
params.orgId,
|
||||||
|
params.machineId,
|
||||||
|
params.requestedMode,
|
||||||
|
params.mode,
|
||||||
|
params.shiftAvailable ? "shift-on" : "shift-off",
|
||||||
|
params.fallbackReason ?? "",
|
||||||
|
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: {
|
||||||
|
requestedMode: resolved.requestedMode,
|
||||||
|
mode: resolved.mode,
|
||||||
|
start: resolved.start,
|
||||||
|
end: resolved.end,
|
||||||
|
shiftAvailable: resolved.shiftAvailable,
|
||||||
|
fallbackReason: resolved.fallbackReason,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
detailCacheKey({
|
||||||
|
orgId: params.orgId,
|
||||||
|
machineId: params.machineId,
|
||||||
|
requestedMode: resolved.requestedMode,
|
||||||
|
mode: resolved.mode,
|
||||||
|
shiftAvailable: resolved.shiftAvailable,
|
||||||
|
fallbackReason: resolved.fallbackReason,
|
||||||
|
startMs: resolved.start.getTime(),
|
||||||
|
endMs: resolved.end.getTime(),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
revalidate: RECAP_CACHE_TTL_SEC,
|
||||||
|
tags: [`recap:${params.orgId}`, `recap:${params.orgId}:${params.machineId}`],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return cache();
|
||||||
|
}
|
||||||
776
lib/recap/redesign.ts.bak
Normal file
776
lib/recap/redesign.ts.bak
Normal file
@@ -0,0 +1,776 @@
|
|||||||
|
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 { RECAP_HEARTBEAT_STALE_MS } from "@/lib/recap/recapUiConstants";
|
||||||
|
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 = RECAP_HEARTBEAT_STALE_MS;
|
||||||
|
const TIMELINE_EVENT_LOOKBACK_MS = 24 * 60 * 60 * 1000;
|
||||||
|
const TIMELINE_CYCLE_LOOKBACK_MS = 15 * 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: new Date(params.start.getTime() - TIMELINE_CYCLE_LOOKBACK_MS),
|
||||||
|
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: 60,
|
||||||
|
});
|
||||||
|
|
||||||
|
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 {
|
||||||
|
hasEnabledShifts: false,
|
||||||
|
range: null,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 shiftEndUtc = zonedToUtcDate({
|
||||||
|
...endDate,
|
||||||
|
hours: Math.floor(endMin / 60),
|
||||||
|
minutes: endMin % 60,
|
||||||
|
timeZone,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (shiftEndUtc <= start) continue;
|
||||||
|
|
||||||
|
// Cap end at "now" so we render shift-so-far, not shift-as-planned.
|
||||||
|
// Without cap:
|
||||||
|
// - timeline fills future minutes with idle (visual lie)
|
||||||
|
// - offline calc = (shift_end_future - last_seen) = looks 5h offline
|
||||||
|
// even on a machine producing right now
|
||||||
|
const end = params.now < shiftEndUtc ? params.now : shiftEndUtc;
|
||||||
|
|
||||||
|
return {
|
||||||
|
hasEnabledShifts: true,
|
||||||
|
range: { start, end },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
hasEnabledShifts: true,
|
||||||
|
range: null,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveDetailRange(params: { orgId: string; input: DetailRangeInput }) {
|
||||||
|
const now = new Date(Math.floor(Date.now() / 60000) * 60000);
|
||||||
|
const requestedMode = normalizedRangeMode(params.input.mode);
|
||||||
|
const shiftEnabledCount = await prisma.orgShift.count({
|
||||||
|
where: {
|
||||||
|
orgId: params.orgId,
|
||||||
|
enabled: { not: false },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const shiftAvailable = shiftEnabledCount > 0;
|
||||||
|
|
||||||
|
if (requestedMode === "custom") {
|
||||||
|
const start = parseDate(params.input.start);
|
||||||
|
const end = parseDate(params.input.end);
|
||||||
|
if (start && end && end > start) {
|
||||||
|
return {
|
||||||
|
requestedMode,
|
||||||
|
mode: requestedMode,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
shiftAvailable,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requestedMode === "yesterday") {
|
||||||
|
const settings = await prisma.orgSettings.findUnique({
|
||||||
|
where: { orgId: params.orgId },
|
||||||
|
select: { timezone: true },
|
||||||
|
});
|
||||||
|
const timeZone = settings?.timezone || "America/Mexico_City";
|
||||||
|
const localNow = getLocalParts(now, timeZone);
|
||||||
|
const today = { year: localNow.year, month: localNow.month, day: localNow.day };
|
||||||
|
const yesterday = addDays(today, -1);
|
||||||
|
const start = zonedToUtcDate({
|
||||||
|
...yesterday,
|
||||||
|
hours: 0,
|
||||||
|
minutes: 0,
|
||||||
|
timeZone,
|
||||||
|
});
|
||||||
|
const end = zonedToUtcDate({
|
||||||
|
...today,
|
||||||
|
hours: 0,
|
||||||
|
minutes: 0,
|
||||||
|
timeZone,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
requestedMode,
|
||||||
|
mode: requestedMode,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
shiftAvailable,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requestedMode === "shift") {
|
||||||
|
const shiftRange = await resolveCurrentShiftRange({ orgId: params.orgId, now });
|
||||||
|
if (shiftRange.range) {
|
||||||
|
return {
|
||||||
|
requestedMode,
|
||||||
|
mode: requestedMode,
|
||||||
|
start: shiftRange.range.start,
|
||||||
|
end: shiftRange.range.end,
|
||||||
|
shiftAvailable,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
if (!shiftRange.hasEnabledShifts) {
|
||||||
|
return {
|
||||||
|
requestedMode,
|
||||||
|
mode: "24h" as const,
|
||||||
|
start: new Date(now.getTime() - 24 * 60 * 60 * 1000),
|
||||||
|
end: now,
|
||||||
|
shiftAvailable,
|
||||||
|
fallbackReason: "shift-unavailable" as const,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
requestedMode,
|
||||||
|
mode: "24h" as const,
|
||||||
|
start: new Date(now.getTime() - 24 * 60 * 60 * 1000),
|
||||||
|
end: now,
|
||||||
|
shiftAvailable,
|
||||||
|
fallbackReason: "shift-inactive" as const,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
requestedMode,
|
||||||
|
mode: "24h" as const,
|
||||||
|
start: new Date(now.getTime() - 24 * 60 * 60 * 1000),
|
||||||
|
end: now,
|
||||||
|
shiftAvailable,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function computeRecapMachineDetail(params: {
|
||||||
|
orgId: string;
|
||||||
|
machineId: string;
|
||||||
|
range: {
|
||||||
|
requestedMode: RecapRangeMode;
|
||||||
|
mode: RecapRangeMode;
|
||||||
|
start: Date;
|
||||||
|
end: Date;
|
||||||
|
shiftAvailable: boolean;
|
||||||
|
fallbackReason?: "shift-unavailable" | "shift-inactive";
|
||||||
|
};
|
||||||
|
}) {
|
||||||
|
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: {
|
||||||
|
requestedMode: range.requestedMode,
|
||||||
|
mode: range.mode,
|
||||||
|
start: range.start.toISOString(),
|
||||||
|
end: range.end.toISOString(),
|
||||||
|
shiftAvailable: range.shiftAvailable,
|
||||||
|
fallbackReason: range.fallbackReason,
|
||||||
|
},
|
||||||
|
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;
|
||||||
|
requestedMode: RecapRangeMode;
|
||||||
|
mode: RecapRangeMode;
|
||||||
|
shiftAvailable: boolean;
|
||||||
|
fallbackReason?: "shift-unavailable" | "shift-inactive";
|
||||||
|
startMs: number;
|
||||||
|
endMs: number;
|
||||||
|
}) {
|
||||||
|
return [
|
||||||
|
"recap-detail-v1",
|
||||||
|
params.orgId,
|
||||||
|
params.machineId,
|
||||||
|
params.requestedMode,
|
||||||
|
params.mode,
|
||||||
|
params.shiftAvailable ? "shift-on" : "shift-off",
|
||||||
|
params.fallbackReason ?? "",
|
||||||
|
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: {
|
||||||
|
requestedMode: resolved.requestedMode,
|
||||||
|
mode: resolved.mode,
|
||||||
|
start: resolved.start,
|
||||||
|
end: resolved.end,
|
||||||
|
shiftAvailable: resolved.shiftAvailable,
|
||||||
|
fallbackReason: resolved.fallbackReason,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
detailCacheKey({
|
||||||
|
orgId: params.orgId,
|
||||||
|
machineId: params.machineId,
|
||||||
|
requestedMode: resolved.requestedMode,
|
||||||
|
mode: resolved.mode,
|
||||||
|
shiftAvailable: resolved.shiftAvailable,
|
||||||
|
fallbackReason: resolved.fallbackReason,
|
||||||
|
startMs: resolved.start.getTime(),
|
||||||
|
endMs: resolved.end.getTime(),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
revalidate: RECAP_CACHE_TTL_SEC,
|
||||||
|
tags: [`recap:${params.orgId}`, `recap:${params.orgId}:${params.machineId}`],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return cache();
|
||||||
|
}
|
||||||
848
lib/recap/redesign.ts.bak.step3
Normal file
848
lib/recap/redesign.ts.bak.step3
Normal file
@@ -0,0 +1,848 @@
|
|||||||
|
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 { RECAP_HEARTBEAT_STALE_MS } from "@/lib/recap/recapUiConstants";
|
||||||
|
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 = RECAP_HEARTBEAT_STALE_MS;
|
||||||
|
const TIMELINE_EVENT_LOOKBACK_MS = 24 * 60 * 60 * 1000;
|
||||||
|
const TIMELINE_CYCLE_LOOKBACK_MS = 15 * 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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Active stoppage = freshest macrostop episode whose latest event is "active"
|
||||||
|
// and whose latest event timestamp is within ACTIVE_STALE_MS of rangeEnd.
|
||||||
|
// Mirrors the same rules used by lib/recap/timeline.ts so the card status
|
||||||
|
// agrees with the timeline rendering.
|
||||||
|
const STOPPAGE_ACTIVE_STALE_MS = 2 * 60 * 1000;
|
||||||
|
|
||||||
|
function detectActiveMacrostop(events: TimelineEventRow[] | undefined, endMs: number) {
|
||||||
|
if (!events || events.length === 0) return null;
|
||||||
|
|
||||||
|
type Episode = { firstTsMs: number; lastTsMs: number; lastStatus: string };
|
||||||
|
const episodes = new Map<string, Episode>();
|
||||||
|
|
||||||
|
for (const event of events) {
|
||||||
|
if (String(event.eventType || "").toLowerCase() !== "macrostop") continue;
|
||||||
|
|
||||||
|
// Defensive: parse data the same way timeline.ts does.
|
||||||
|
let parsed: unknown = event.data;
|
||||||
|
if (typeof parsed === "string") {
|
||||||
|
try { parsed = JSON.parse(parsed); } catch { parsed = null; }
|
||||||
|
}
|
||||||
|
const data: Record<string, unknown> =
|
||||||
|
parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
||||||
|
? (parsed as Record<string, unknown>)
|
||||||
|
: {};
|
||||||
|
|
||||||
|
// Drop only the auto-ack pings (same rule as timeline.ts Fix B).
|
||||||
|
const isAutoAck =
|
||||||
|
data.is_auto_ack === true ||
|
||||||
|
data.isAutoAck === true ||
|
||||||
|
data.is_auto_ack === "true" ||
|
||||||
|
data.isAutoAck === "true";
|
||||||
|
if (isAutoAck) continue;
|
||||||
|
|
||||||
|
const status = String(data.status ?? "").trim().toLowerCase();
|
||||||
|
const incidentKey = String(data.incidentKey ?? data.incident_key ?? "").trim()
|
||||||
|
|| `macrostop:${event.ts.getTime()}`;
|
||||||
|
const tsMs = event.ts.getTime();
|
||||||
|
|
||||||
|
const existing = episodes.get(incidentKey);
|
||||||
|
if (!existing) {
|
||||||
|
episodes.set(incidentKey, { firstTsMs: tsMs, lastTsMs: tsMs, lastStatus: status });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
existing.firstTsMs = Math.min(existing.firstTsMs, tsMs);
|
||||||
|
if (tsMs >= existing.lastTsMs) {
|
||||||
|
existing.lastTsMs = tsMs;
|
||||||
|
existing.lastStatus = status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let activeOngoingMin = 0;
|
||||||
|
for (const ep of episodes.values()) {
|
||||||
|
if (ep.lastStatus !== "active") continue;
|
||||||
|
if (endMs - ep.lastTsMs > STOPPAGE_ACTIVE_STALE_MS) continue;
|
||||||
|
const ongoingMin = Math.max(0, Math.floor((endMs - ep.firstTsMs) / 60000));
|
||||||
|
if (ongoingMin > activeOngoingMin) activeOngoingMin = ongoingMin;
|
||||||
|
}
|
||||||
|
|
||||||
|
return activeOngoingMin > 0 ? activeOngoingMin : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusFromMachine(machine: RecapMachine, endMs: number, events?: TimelineEventRow[]) {
|
||||||
|
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;
|
||||||
|
|
||||||
|
// ongoingStopMin from the legacy heartbeat-based path (typically null) OR
|
||||||
|
// from the macrostop event detection (preferred — accurate)
|
||||||
|
const macrostopOngoingMin = detectActiveMacrostop(events, endMs);
|
||||||
|
const legacyOngoingStopMin = machine.downtime.ongoingStopMin ?? 0;
|
||||||
|
const ongoingStopMin = macrostopOngoingMin ?? (legacyOngoingStopMin > 0 ? legacyOngoingStopMin : null);
|
||||||
|
|
||||||
|
const moldActive = machine.workOrders.moldChangeInProgress;
|
||||||
|
|
||||||
|
let status: RecapMachineStatus = "running";
|
||||||
|
if (offline) status = "offline";
|
||||||
|
else if (moldActive) status = "mold-change";
|
||||||
|
else if (ongoingStopMin != null && ongoingStopMin > 0) status = "stopped";
|
||||||
|
|
||||||
|
return {
|
||||||
|
status,
|
||||||
|
lastSeenMs,
|
||||||
|
offlineForMin: offline ? Math.max(0, Math.floor(offlineForMs / 60000)) : null,
|
||||||
|
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: new Date(params.start.getTime() - TIMELINE_CYCLE_LOOKBACK_MS),
|
||||||
|
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;
|
||||||
|
events?: TimelineEventRow[];
|
||||||
|
}): RecapSummaryMachine {
|
||||||
|
const { machine, miniTimeline, rangeEndMs, events } = params;
|
||||||
|
const status = statusFromMachine(machine, rangeEndMs, events);
|
||||||
|
|
||||||
|
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: 60,
|
||||||
|
});
|
||||||
|
|
||||||
|
return toSummaryMachine({
|
||||||
|
machine,
|
||||||
|
miniTimeline,
|
||||||
|
rangeEndMs: end.getTime(),
|
||||||
|
events: timelineRows.eventsByMachine.get(machine.machineId),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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 {
|
||||||
|
hasEnabledShifts: false,
|
||||||
|
range: null,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 shiftEndUtc = zonedToUtcDate({
|
||||||
|
...endDate,
|
||||||
|
hours: Math.floor(endMin / 60),
|
||||||
|
minutes: endMin % 60,
|
||||||
|
timeZone,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (shiftEndUtc <= start) continue;
|
||||||
|
|
||||||
|
// Cap end at "now" so we render shift-so-far, not shift-as-planned.
|
||||||
|
// Without cap:
|
||||||
|
// - timeline fills future minutes with idle (visual lie)
|
||||||
|
// - offline calc = (shift_end_future - last_seen) = looks 5h offline
|
||||||
|
// even on a machine producing right now
|
||||||
|
const end = params.now < shiftEndUtc ? params.now : shiftEndUtc;
|
||||||
|
|
||||||
|
return {
|
||||||
|
hasEnabledShifts: true,
|
||||||
|
range: { start, end },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
hasEnabledShifts: true,
|
||||||
|
range: null,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveDetailRange(params: { orgId: string; input: DetailRangeInput }) {
|
||||||
|
const now = new Date(Math.floor(Date.now() / 60000) * 60000);
|
||||||
|
const requestedMode = normalizedRangeMode(params.input.mode);
|
||||||
|
const shiftEnabledCount = await prisma.orgShift.count({
|
||||||
|
where: {
|
||||||
|
orgId: params.orgId,
|
||||||
|
enabled: { not: false },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const shiftAvailable = shiftEnabledCount > 0;
|
||||||
|
|
||||||
|
if (requestedMode === "custom") {
|
||||||
|
const start = parseDate(params.input.start);
|
||||||
|
const end = parseDate(params.input.end);
|
||||||
|
if (start && end && end > start) {
|
||||||
|
return {
|
||||||
|
requestedMode,
|
||||||
|
mode: requestedMode,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
shiftAvailable,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requestedMode === "yesterday") {
|
||||||
|
const settings = await prisma.orgSettings.findUnique({
|
||||||
|
where: { orgId: params.orgId },
|
||||||
|
select: { timezone: true },
|
||||||
|
});
|
||||||
|
const timeZone = settings?.timezone || "America/Mexico_City";
|
||||||
|
const localNow = getLocalParts(now, timeZone);
|
||||||
|
const today = { year: localNow.year, month: localNow.month, day: localNow.day };
|
||||||
|
const yesterday = addDays(today, -1);
|
||||||
|
const start = zonedToUtcDate({
|
||||||
|
...yesterday,
|
||||||
|
hours: 0,
|
||||||
|
minutes: 0,
|
||||||
|
timeZone,
|
||||||
|
});
|
||||||
|
const end = zonedToUtcDate({
|
||||||
|
...today,
|
||||||
|
hours: 0,
|
||||||
|
minutes: 0,
|
||||||
|
timeZone,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
requestedMode,
|
||||||
|
mode: requestedMode,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
shiftAvailable,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requestedMode === "shift") {
|
||||||
|
const shiftRange = await resolveCurrentShiftRange({ orgId: params.orgId, now });
|
||||||
|
if (shiftRange.range) {
|
||||||
|
return {
|
||||||
|
requestedMode,
|
||||||
|
mode: requestedMode,
|
||||||
|
start: shiftRange.range.start,
|
||||||
|
end: shiftRange.range.end,
|
||||||
|
shiftAvailable,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
if (!shiftRange.hasEnabledShifts) {
|
||||||
|
return {
|
||||||
|
requestedMode,
|
||||||
|
mode: "24h" as const,
|
||||||
|
start: new Date(now.getTime() - 24 * 60 * 60 * 1000),
|
||||||
|
end: now,
|
||||||
|
shiftAvailable,
|
||||||
|
fallbackReason: "shift-unavailable" as const,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
requestedMode,
|
||||||
|
mode: "24h" as const,
|
||||||
|
start: new Date(now.getTime() - 24 * 60 * 60 * 1000),
|
||||||
|
end: now,
|
||||||
|
shiftAvailable,
|
||||||
|
fallbackReason: "shift-inactive" as const,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
requestedMode,
|
||||||
|
mode: "24h" as const,
|
||||||
|
start: new Date(now.getTime() - 24 * 60 * 60 * 1000),
|
||||||
|
end: now,
|
||||||
|
shiftAvailable,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function computeRecapMachineDetail(params: {
|
||||||
|
orgId: string;
|
||||||
|
machineId: string;
|
||||||
|
range: {
|
||||||
|
requestedMode: RecapRangeMode;
|
||||||
|
mode: RecapRangeMode;
|
||||||
|
start: Date;
|
||||||
|
end: Date;
|
||||||
|
shiftAvailable: boolean;
|
||||||
|
fallbackReason?: "shift-unavailable" | "shift-inactive";
|
||||||
|
};
|
||||||
|
}) {
|
||||||
|
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(),
|
||||||
|
timelineRows.eventsByMachine.get(params.machineId)
|
||||||
|
);
|
||||||
|
|
||||||
|
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: {
|
||||||
|
requestedMode: range.requestedMode,
|
||||||
|
mode: range.mode,
|
||||||
|
start: range.start.toISOString(),
|
||||||
|
end: range.end.toISOString(),
|
||||||
|
shiftAvailable: range.shiftAvailable,
|
||||||
|
fallbackReason: range.fallbackReason,
|
||||||
|
},
|
||||||
|
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;
|
||||||
|
requestedMode: RecapRangeMode;
|
||||||
|
mode: RecapRangeMode;
|
||||||
|
shiftAvailable: boolean;
|
||||||
|
fallbackReason?: "shift-unavailable" | "shift-inactive";
|
||||||
|
startMs: number;
|
||||||
|
endMs: number;
|
||||||
|
}) {
|
||||||
|
return [
|
||||||
|
"recap-detail-v1",
|
||||||
|
params.orgId,
|
||||||
|
params.machineId,
|
||||||
|
params.requestedMode,
|
||||||
|
params.mode,
|
||||||
|
params.shiftAvailable ? "shift-on" : "shift-off",
|
||||||
|
params.fallbackReason ?? "",
|
||||||
|
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: {
|
||||||
|
requestedMode: resolved.requestedMode,
|
||||||
|
mode: resolved.mode,
|
||||||
|
start: resolved.start,
|
||||||
|
end: resolved.end,
|
||||||
|
shiftAvailable: resolved.shiftAvailable,
|
||||||
|
fallbackReason: resolved.fallbackReason,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
detailCacheKey({
|
||||||
|
orgId: params.orgId,
|
||||||
|
machineId: params.machineId,
|
||||||
|
requestedMode: resolved.requestedMode,
|
||||||
|
mode: resolved.mode,
|
||||||
|
shiftAvailable: resolved.shiftAvailable,
|
||||||
|
fallbackReason: resolved.fallbackReason,
|
||||||
|
startMs: resolved.start.getTime(),
|
||||||
|
endMs: resolved.end.getTime(),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
revalidate: RECAP_CACHE_TTL_SEC,
|
||||||
|
tags: [`recap:${params.orgId}`, `recap:${params.orgId}:${params.machineId}`],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return cache();
|
||||||
|
}
|
||||||
905
lib/recap/redesign.ts.bak.step4
Normal file
905
lib/recap/redesign.ts.bak.step4
Normal file
@@ -0,0 +1,905 @@
|
|||||||
|
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 { classifyMachineState, type MachineStateResult } from "@/lib/recap/machineState";
|
||||||
|
import { RECAP_HEARTBEAT_STALE_MS } from "@/lib/recap/recapUiConstants";
|
||||||
|
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 = RECAP_HEARTBEAT_STALE_MS;
|
||||||
|
const TIMELINE_EVENT_LOOKBACK_MS = 24 * 60 * 60 * 1000;
|
||||||
|
const TIMELINE_CYCLE_LOOKBACK_MS = 15 * 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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect active episodes (macrostop, mold-change) from event rows.
|
||||||
|
// Returns the latest non-auto-ack episode whose final status is "active"
|
||||||
|
// and that's been refreshed within ACTIVE_STALE_MS.
|
||||||
|
const ACTIVE_STALE_MS = 2 * 60 * 1000;
|
||||||
|
|
||||||
|
type ActiveEpisode = { startedAtMs: number; lastTsMs: number };
|
||||||
|
|
||||||
|
function detectActiveEpisode(
|
||||||
|
events: TimelineEventRow[] | undefined,
|
||||||
|
eventType: "macrostop" | "mold-change",
|
||||||
|
endMs: number
|
||||||
|
): ActiveEpisode | null {
|
||||||
|
if (!events || events.length === 0) return null;
|
||||||
|
|
||||||
|
type Episode = { firstTsMs: number; lastTsMs: number; lastStatus: string; lastCycleTs: number | null };
|
||||||
|
const episodes = new Map<string, Episode>();
|
||||||
|
|
||||||
|
for (const event of events) {
|
||||||
|
if (String(event.eventType || "").toLowerCase() !== eventType) continue;
|
||||||
|
|
||||||
|
let parsed: unknown = event.data;
|
||||||
|
if (typeof parsed === "string") {
|
||||||
|
try { parsed = JSON.parse(parsed); } catch { parsed = null; }
|
||||||
|
}
|
||||||
|
const data: Record<string, unknown> =
|
||||||
|
parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
||||||
|
? (parsed as Record<string, unknown>)
|
||||||
|
: {};
|
||||||
|
|
||||||
|
const isAutoAck =
|
||||||
|
data.is_auto_ack === true ||
|
||||||
|
data.isAutoAck === true ||
|
||||||
|
data.is_auto_ack === "true" ||
|
||||||
|
data.isAutoAck === "true";
|
||||||
|
if (isAutoAck) continue;
|
||||||
|
|
||||||
|
const status = String(data.status ?? "").trim().toLowerCase();
|
||||||
|
const incidentKey = String(data.incidentKey ?? data.incident_key ?? "").trim()
|
||||||
|
|| `${eventType}:${event.ts.getTime()}`;
|
||||||
|
const tsMs = event.ts.getTime();
|
||||||
|
const lastCycleTs = Number(data.last_cycle_timestamp);
|
||||||
|
|
||||||
|
const existing = episodes.get(incidentKey);
|
||||||
|
if (!existing) {
|
||||||
|
episodes.set(incidentKey, {
|
||||||
|
firstTsMs: tsMs,
|
||||||
|
lastTsMs: tsMs,
|
||||||
|
lastStatus: status,
|
||||||
|
lastCycleTs: Number.isFinite(lastCycleTs) && lastCycleTs > 0 ? lastCycleTs : null,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
existing.firstTsMs = Math.min(existing.firstTsMs, tsMs);
|
||||||
|
if (tsMs >= existing.lastTsMs) {
|
||||||
|
existing.lastTsMs = tsMs;
|
||||||
|
existing.lastStatus = status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let best: ActiveEpisode | null = null;
|
||||||
|
for (const ep of episodes.values()) {
|
||||||
|
if (ep.lastStatus !== "active") continue;
|
||||||
|
if (endMs - ep.lastTsMs > ACTIVE_STALE_MS) continue;
|
||||||
|
// Prefer the freshest active episode (highest lastTsMs)
|
||||||
|
if (!best || ep.lastTsMs > best.lastTsMs) {
|
||||||
|
best = {
|
||||||
|
startedAtMs: ep.lastCycleTs ?? ep.firstTsMs,
|
||||||
|
lastTsMs: ep.lastTsMs,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusFromMachine(
|
||||||
|
machine: RecapMachine,
|
||||||
|
endMs: number,
|
||||||
|
events?: TimelineEventRow[]
|
||||||
|
): {
|
||||||
|
status: RecapMachineStatus;
|
||||||
|
result: MachineStateResult;
|
||||||
|
lastSeenMs: number | null;
|
||||||
|
offlineForMin: number | null;
|
||||||
|
ongoingStopMin: number | null;
|
||||||
|
} {
|
||||||
|
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 heartbeatAlive = Number.isFinite(lastSeenMs ?? Number.NaN) && offlineForMs <= OFFLINE_THRESHOLD_MS;
|
||||||
|
|
||||||
|
const activeMacrostop = detectActiveEpisode(events, "macrostop", endMs);
|
||||||
|
const activeMoldChange = detectActiveEpisode(events, "mold-change", endMs);
|
||||||
|
|
||||||
|
// Round 1 limitation: trackingEnabled and untrackedCycles inputs require KPI/cycle queries
|
||||||
|
// we don't yet plumb here. We approximate from the legacy fields:
|
||||||
|
// - trackingEnabled: true when there's an active macrostop (Pi only fires those when tracking on)
|
||||||
|
// OR when an active WO exists and machine.workOrders.moldChangeInProgress is false.
|
||||||
|
// This is a SIMPLIFICATION; Round 3 will replace with real KPI snapshot read.
|
||||||
|
// - untrackedCycles: 0 (Round 3 will compute from MachineCycle vs latest KPI)
|
||||||
|
//
|
||||||
|
// Effect for Round 1: STOPPED `not_started` reason cannot trigger yet (we always assume tracking
|
||||||
|
// is on when a WO exists). Only `machine_fault` STOPPED fires. DATA_LOSS cannot fire yet.
|
||||||
|
// IDLE fires correctly when there's no WO and no recent activity.
|
||||||
|
const hasActiveWorkOrder = machine.workOrders.active != null;
|
||||||
|
const trackingEnabledApprox = hasActiveWorkOrder; // see comment above
|
||||||
|
|
||||||
|
const lastCycleTsMs = (() => {
|
||||||
|
// Best-effort: use the machine's heartbeat as a "recent activity" proxy.
|
||||||
|
// The Pi only heartbeats every minute regardless of cycles, so this is a weak signal.
|
||||||
|
// Round 3 will pass the actual latest cycle ts.
|
||||||
|
return lastSeenMs;
|
||||||
|
})();
|
||||||
|
|
||||||
|
const result = classifyMachineState(
|
||||||
|
{
|
||||||
|
heartbeatAlive,
|
||||||
|
lastSeenMs,
|
||||||
|
offlineForMs,
|
||||||
|
trackingEnabled: trackingEnabledApprox,
|
||||||
|
hasActiveWorkOrder,
|
||||||
|
activeMoldChange,
|
||||||
|
activeMacrostop,
|
||||||
|
untrackedCycles: { count: 0, oldestTsMs: null },
|
||||||
|
lastCycleTsMs,
|
||||||
|
},
|
||||||
|
endMs
|
||||||
|
);
|
||||||
|
|
||||||
|
// Map the rich classifier result back to the existing RecapMachineStatus union
|
||||||
|
const status: RecapMachineStatus = result.state;
|
||||||
|
|
||||||
|
// Pull common fields out for the caller's convenience
|
||||||
|
let ongoingStopMin: number | null = null;
|
||||||
|
if (result.state === "stopped") ongoingStopMin = result.ongoingStopMin;
|
||||||
|
|
||||||
|
return {
|
||||||
|
status,
|
||||||
|
result,
|
||||||
|
lastSeenMs,
|
||||||
|
offlineForMin: result.state === "offline" ? result.offlineForMin : null,
|
||||||
|
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: new Date(params.start.getTime() - TIMELINE_CYCLE_LOOKBACK_MS),
|
||||||
|
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;
|
||||||
|
events?: TimelineEventRow[];
|
||||||
|
}): RecapSummaryMachine {
|
||||||
|
const { machine, miniTimeline, rangeEndMs, events } = params;
|
||||||
|
const status = statusFromMachine(machine, rangeEndMs, events);
|
||||||
|
|
||||||
|
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: 60,
|
||||||
|
});
|
||||||
|
|
||||||
|
return toSummaryMachine({
|
||||||
|
machine,
|
||||||
|
miniTimeline,
|
||||||
|
rangeEndMs: end.getTime(),
|
||||||
|
events: timelineRows.eventsByMachine.get(machine.machineId),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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 {
|
||||||
|
hasEnabledShifts: false,
|
||||||
|
range: null,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 shiftEndUtc = zonedToUtcDate({
|
||||||
|
...endDate,
|
||||||
|
hours: Math.floor(endMin / 60),
|
||||||
|
minutes: endMin % 60,
|
||||||
|
timeZone,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (shiftEndUtc <= start) continue;
|
||||||
|
|
||||||
|
// Cap end at "now" so we render shift-so-far, not shift-as-planned.
|
||||||
|
// Without cap:
|
||||||
|
// - timeline fills future minutes with idle (visual lie)
|
||||||
|
// - offline calc = (shift_end_future - last_seen) = looks 5h offline
|
||||||
|
// even on a machine producing right now
|
||||||
|
const end = params.now < shiftEndUtc ? params.now : shiftEndUtc;
|
||||||
|
|
||||||
|
return {
|
||||||
|
hasEnabledShifts: true,
|
||||||
|
range: { start, end },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
hasEnabledShifts: true,
|
||||||
|
range: null,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveDetailRange(params: { orgId: string; input: DetailRangeInput }) {
|
||||||
|
const now = new Date(Math.floor(Date.now() / 60000) * 60000);
|
||||||
|
const requestedMode = normalizedRangeMode(params.input.mode);
|
||||||
|
const shiftEnabledCount = await prisma.orgShift.count({
|
||||||
|
where: {
|
||||||
|
orgId: params.orgId,
|
||||||
|
enabled: { not: false },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const shiftAvailable = shiftEnabledCount > 0;
|
||||||
|
|
||||||
|
if (requestedMode === "custom") {
|
||||||
|
const start = parseDate(params.input.start);
|
||||||
|
const end = parseDate(params.input.end);
|
||||||
|
if (start && end && end > start) {
|
||||||
|
return {
|
||||||
|
requestedMode,
|
||||||
|
mode: requestedMode,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
shiftAvailable,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requestedMode === "yesterday") {
|
||||||
|
const settings = await prisma.orgSettings.findUnique({
|
||||||
|
where: { orgId: params.orgId },
|
||||||
|
select: { timezone: true },
|
||||||
|
});
|
||||||
|
const timeZone = settings?.timezone || "America/Mexico_City";
|
||||||
|
const localNow = getLocalParts(now, timeZone);
|
||||||
|
const today = { year: localNow.year, month: localNow.month, day: localNow.day };
|
||||||
|
const yesterday = addDays(today, -1);
|
||||||
|
const start = zonedToUtcDate({
|
||||||
|
...yesterday,
|
||||||
|
hours: 0,
|
||||||
|
minutes: 0,
|
||||||
|
timeZone,
|
||||||
|
});
|
||||||
|
const end = zonedToUtcDate({
|
||||||
|
...today,
|
||||||
|
hours: 0,
|
||||||
|
minutes: 0,
|
||||||
|
timeZone,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
requestedMode,
|
||||||
|
mode: requestedMode,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
shiftAvailable,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requestedMode === "shift") {
|
||||||
|
const shiftRange = await resolveCurrentShiftRange({ orgId: params.orgId, now });
|
||||||
|
if (shiftRange.range) {
|
||||||
|
return {
|
||||||
|
requestedMode,
|
||||||
|
mode: requestedMode,
|
||||||
|
start: shiftRange.range.start,
|
||||||
|
end: shiftRange.range.end,
|
||||||
|
shiftAvailable,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
if (!shiftRange.hasEnabledShifts) {
|
||||||
|
return {
|
||||||
|
requestedMode,
|
||||||
|
mode: "24h" as const,
|
||||||
|
start: new Date(now.getTime() - 24 * 60 * 60 * 1000),
|
||||||
|
end: now,
|
||||||
|
shiftAvailable,
|
||||||
|
fallbackReason: "shift-unavailable" as const,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
requestedMode,
|
||||||
|
mode: "24h" as const,
|
||||||
|
start: new Date(now.getTime() - 24 * 60 * 60 * 1000),
|
||||||
|
end: now,
|
||||||
|
shiftAvailable,
|
||||||
|
fallbackReason: "shift-inactive" as const,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
requestedMode,
|
||||||
|
mode: "24h" as const,
|
||||||
|
start: new Date(now.getTime() - 24 * 60 * 60 * 1000),
|
||||||
|
end: now,
|
||||||
|
shiftAvailable,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function computeRecapMachineDetail(params: {
|
||||||
|
orgId: string;
|
||||||
|
machineId: string;
|
||||||
|
range: {
|
||||||
|
requestedMode: RecapRangeMode;
|
||||||
|
mode: RecapRangeMode;
|
||||||
|
start: Date;
|
||||||
|
end: Date;
|
||||||
|
shiftAvailable: boolean;
|
||||||
|
fallbackReason?: "shift-unavailable" | "shift-inactive";
|
||||||
|
};
|
||||||
|
}) {
|
||||||
|
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(),
|
||||||
|
timelineRows.eventsByMachine.get(params.machineId)
|
||||||
|
);
|
||||||
|
|
||||||
|
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: {
|
||||||
|
requestedMode: range.requestedMode,
|
||||||
|
mode: range.mode,
|
||||||
|
start: range.start.toISOString(),
|
||||||
|
end: range.end.toISOString(),
|
||||||
|
shiftAvailable: range.shiftAvailable,
|
||||||
|
fallbackReason: range.fallbackReason,
|
||||||
|
},
|
||||||
|
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;
|
||||||
|
requestedMode: RecapRangeMode;
|
||||||
|
mode: RecapRangeMode;
|
||||||
|
shiftAvailable: boolean;
|
||||||
|
fallbackReason?: "shift-unavailable" | "shift-inactive";
|
||||||
|
startMs: number;
|
||||||
|
endMs: number;
|
||||||
|
}) {
|
||||||
|
return [
|
||||||
|
"recap-detail-v1",
|
||||||
|
params.orgId,
|
||||||
|
params.machineId,
|
||||||
|
params.requestedMode,
|
||||||
|
params.mode,
|
||||||
|
params.shiftAvailable ? "shift-on" : "shift-off",
|
||||||
|
params.fallbackReason ?? "",
|
||||||
|
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: {
|
||||||
|
requestedMode: resolved.requestedMode,
|
||||||
|
mode: resolved.mode,
|
||||||
|
start: resolved.start,
|
||||||
|
end: resolved.end,
|
||||||
|
shiftAvailable: resolved.shiftAvailable,
|
||||||
|
fallbackReason: resolved.fallbackReason,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
detailCacheKey({
|
||||||
|
orgId: params.orgId,
|
||||||
|
machineId: params.machineId,
|
||||||
|
requestedMode: resolved.requestedMode,
|
||||||
|
mode: resolved.mode,
|
||||||
|
shiftAvailable: resolved.shiftAvailable,
|
||||||
|
fallbackReason: resolved.fallbackReason,
|
||||||
|
startMs: resolved.start.getTime(),
|
||||||
|
endMs: resolved.end.getTime(),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
revalidate: RECAP_CACHE_TTL_SEC,
|
||||||
|
tags: [`recap:${params.orgId}`, `recap:${params.orgId}:${params.machineId}`],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return cache();
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user