diff --git a/LOGGING.md b/LOGGING.md new file mode 100644 index 0000000..152f1de --- /dev/null +++ b/LOGGING.md @@ -0,0 +1,84 @@ +# Logging & debugging errors + +## Where errors are logged + +### 1. **Log file** (JSON lines) + +- **Path:** `LOG_FILE` env var, or **`/tmp/mis-control-tower.log`** if unset. +- **Contents:** JSON lines for `requireSession.error`, `getOverviewData.error`, `OverviewPage.getOverviewData.error`, plus any `logLine(...)` usage (e.g. health, signup). + +**View recent entries:** +```bash +tail -f /tmp/mis-control-tower.log +``` + +**Or with a custom path:** +```bashls -la +export LOG_FILE=/var/log/mis-control-tower.log +# then start the app; tail that path +tail -f /var/log/mis-control-tower.log +``` + +### 2. **Process stdout / stderr** + +- **`console.error`** and **`console.log`** go to the process that runs Next.js. +- **Dev:** terminal where you run `npm run dev`. +- **Production:** PM2 logs (`pm2 logs`), Docker (`docker logs ...`), systemd (`journalctl -u your-service -f`), etc. + +### 3. **Debug logs API** (optional) + +- **URL:** `GET /api/debug/logs?key=YOUR_DEBUG_LOGS_KEY` +- **Purpose:** Returns the last 100 lines of the log file as JSON. +- **Setup:** Add to `.env`: + ``` + DEBUG_LOGS_KEY=your-secret-string + ``` +- **Usage:** + `curl "https://mis.maliountech.com.mx/api/debug/logs?key=your-secret-string"` +- If `DEBUG_LOGS_KEY` is unset or the `key` param is wrong, the route returns 401. + +## Error events we log + +| Event | When | +|-------|------| +| `requireSession.error` | Session lookup (cookies / DB) fails | +| `getOverviewData.error` | Overview data fetch (DB) fails | +| `OverviewPage.getOverviewData.error` | Overview page catch-around fetch fails | + +Each includes `message` and `stack` when available. + +## Quick checks when you see "Internal Server Error" + +1. **Tail the log file:** + `tail -f /tmp/mis-control-tower.log` + (or `$LOG_FILE` if you set it.) + +2. **Check process logs:** + Wherever `next start` or `npm run dev` runs (PM2, Docker, systemd). Look for `[requireSession]`, `[getOverviewData]`, `[OverviewPage]`, or `[middleware]`. + +3. **Call the debug API** (if configured): + `curl "https://your-domain/api/debug/logs?key=YOUR_DEBUG_LOGS_KEY"` + and inspect the `entries` array for recent errors. + +## KPI quality trace (Node-RED vs processing) + +Use this when `Quality` is shown as `0` and you need to see exactly what was received and saved. + +1. Enable trace logging: + `TRACE_KPI_INGEST=1` + +2. Send KPI payloads as usual from Node-RED. + +3. Inspect logs: + `tail -f /tmp/mis-control-tower.log` + or: + `curl "https://your-domain/api/debug/logs?key=YOUR_DEBUG_LOGS_KEY"` + +4. Look for event `ingest.kpi.trace`, which includes: + `trace.rawQualityCandidates` (raw payload values found at multiple paths), + `trace.normalizedQuality` (post-normalization), + `trace.persistedQuality` (value written to DB). + +Optional one-shot trace without env var: +- Send header `x-debug-ingest: 1` on a KPI request. +- The response will include a `trace` object with the same quality details. diff --git a/README.md b/README.md index 957d7cf..5341114 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,24 @@ sudo systemctl daemon-reload sudo systemctl enable --now mis-control-tower-reminders.timer ``` -## Learn More +## 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: + +- `npm run dev` → `next dev --turbopack` (fast dev) +- `npm run build` → `next build --webpack` (stable production build) + +**When deploying** (e.g. for `https://mis.maliountech.com.mx`): + +1. **Build:** Run `npm run build` (Webpack). +2. **Start:** Run `npm run start` (or your process manager) to serve the built app. +3. If you previously built with Turbopack, run `rm -rf .next` then `npm run build` for a clean Webpack build. +4. Hard-refresh the browser (or clear site data) after redeploying so clients don’t load old Turbopack chunks. + +## Logging and debugging + +See **[LOGGING.md](./LOGGING.md)** for where errors are logged (log file, process stdout, optional `/api/debug/logs`), how to tail them, and how to debug "Internal Server Error". + To learn more about Next.js, take a look at the following resources: diff --git a/app/(app)/error.tsx b/app/(app)/error.tsx new file mode 100644 index 0000000..39fc929 --- /dev/null +++ b/app/(app)/error.tsx @@ -0,0 +1,31 @@ +"use client"; + +import { useEffect } from "react"; + +export default function AppError({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + useEffect(() => { + console.error("[App Error]", error); + }, [error]); + + return ( +
+

Something went wrong

+

+ An error occurred while loading this page. Please try again. +

+ +
+ ); +} diff --git a/app/(app)/financial/FinancialClient.tsx b/app/(app)/financial/FinancialClient.tsx index 6e88898..80df453 100644 --- a/app/(app)/financial/FinancialClient.tsx +++ b/app/(app)/financial/FinancialClient.tsx @@ -76,6 +76,8 @@ export default function FinancialClient({ const [currencyFilter, setCurrencyFilter] = useState(""); const [loading, setLoading] = useState(() => initialMachines.length === 0); const skipInitialImpactRef = useRef(true); + const forceRefreshRef = useRef(false); + const [refreshSeed, setRefreshSeed] = useState(0); const locations = useMemo(() => { const seen = new Set(); @@ -158,6 +160,8 @@ export default function FinancialClient({ if (locationFilter) params.set("location", locationFilter); if (skuFilter) params.set("sku", skuFilter); if (currencyFilter) params.set("currency", currencyFilter); + const forceRefresh = forceRefreshRef.current; + if (forceRefresh) params.set("refresh", "1"); try { const res = await fetch(`/api/financial/impact?${params.toString()}`, { @@ -169,6 +173,8 @@ export default function FinancialClient({ setImpact(json); } catch { if (alive) setImpact(null); + } finally { + if (forceRefresh) forceRefreshRef.current = false; } } @@ -177,7 +183,7 @@ export default function FinancialClient({ alive = false; controller.abort(); }; - }, [currencyFilter, initialImpact, locationFilter, machineFilter, range, role, skuFilter]); + }, [currencyFilter, initialImpact, locationFilter, machineFilter, range, refreshSeed, role, skuFilter]); const selectedSummary = impact?.currencySummaries?.[0] ?? null; const chartData = selectedSummary?.byDay ?? []; @@ -193,6 +199,10 @@ export default function FinancialClient({ const htmlHref = `/api/financial/export/pdf?${exportQuery}`; const csvHref = `/api/financial/export/excel?${exportQuery}`; + const handleRefresh = () => { + forceRefreshRef.current = true; + setRefreshSeed((prev) => prev + 1); + }; if (role && role !== "OWNER") { return ( @@ -212,6 +222,13 @@ export default function FinancialClient({

{t("financial.subtitle")}

+
- + diff --git a/app/(app)/financial/page.tsx b/app/(app)/financial/page.tsx index 9b94612..5b8fbaa 100644 --- a/app/(app)/financial/page.tsx +++ b/app/(app)/financial/page.tsx @@ -1,7 +1,7 @@ import { redirect } from "next/navigation"; import { prisma } from "@/lib/prisma"; import { requireSession } from "@/lib/auth/requireSession"; -import { computeFinancialImpact } from "@/lib/financial/impact"; +import { getFinancialImpactCached } from "@/lib/financial/cache"; import FinancialClient from "./FinancialClient"; const RANGE_MS = 7 * 24 * 60 * 60 * 1000; @@ -28,7 +28,7 @@ export default async function FinancialPage() { const end = new Date(); const start = new Date(end.getTime() - RANGE_MS); - const impact = await computeFinancialImpact({ + const impact = await getFinancialImpactCached({ orgId: session.orgId, start, end, diff --git a/app/(app)/layout.tsx b/app/(app)/layout.tsx index 010dade..89a70ee 100644 --- a/app/(app)/layout.tsx +++ b/app/(app)/layout.tsx @@ -1,31 +1,22 @@ import { AppShell } from "@/components/layout/AppShell"; import { cookies } from "next/headers"; import { redirect } from "next/navigation"; -import { prisma } from "@/lib/prisma"; const COOKIE_NAME = "mis_session"; -export default async function AppLayout({ children }: { children: React.ReactNode }) { +export const dynamic = "force-dynamic"; + +export default async function AppLayout({ + children, +}: { + children: React.ReactNode; +}) { const cookieJar = await cookies(); const sessionId = cookieJar.get(COOKIE_NAME)?.value; const themeCookie = cookieJar.get("mis_theme")?.value; const initialTheme = themeCookie === "light" ? "light" : "dark"; - if (!sessionId) redirect("/login?next=/machines"); - - // validate session in DB (don’t trust cookie existence) - const session = await prisma.session.findFirst({ - where: { - id: sessionId, - revokedAt: null, - expiresAt: { gt: new Date() }, - }, - include: { user: true, org: true }, - }); - - if (!session || !session.user?.isActive || !session.user?.emailVerifiedAt) { - redirect("/login?next=/machines"); - } + if (!sessionId) redirect("/login"); return {children}; } diff --git a/app/(app)/loading.tsx b/app/(app)/loading.tsx new file mode 100644 index 0000000..de6bee7 --- /dev/null +++ b/app/(app)/loading.tsx @@ -0,0 +1,13 @@ +export default function AppLoading() { + return ( +
+
+
+ {Array.from({ length: 3 }).map((_, idx) => ( +
+ ))} +
+
+
+ ); +} diff --git a/app/(app)/machines/MachinesClient.tsx b/app/(app)/machines/MachinesClient.tsx index b150815..4ea2c7e 100644 --- a/app/(app)/machines/MachinesClient.tsx +++ b/app/(app)/machines/MachinesClient.tsx @@ -19,6 +19,7 @@ type MachineRow = { fwVersion?: string | null; }; }; +const LIVE_REFRESH_MS = 5000; function secondsAgo(ts: string | undefined, locale: string, fallback: string) { if (!ts) return fallback; @@ -52,7 +53,7 @@ export default function MachinesClient({ initialMachines = [] }: { initialMachin const { t, locale } = useI18n(); const router = useRouter(); const [machines, setMachines] = useState(() => initialMachines); - const [loading, setLoading] = useState(false); + const [loading, setLoading] = useState(() => initialMachines.length === 0); const [showCreate, setShowCreate] = useState(false); const [createName, setCreateName] = useState(""); const [createCode, setCreateCode] = useState(""); @@ -69,28 +70,36 @@ export default function MachinesClient({ initialMachines = [] }: { initialMachin useEffect(() => { let alive = true; + let timer: ReturnType | null = null; - async function load() { + 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 ?? []); - setLoading(false); + if (initial) setLoading(false); } } catch { - if (alive) setLoading(false); + if (alive && initial) setLoading(false); + } finally { + if (!alive) return; + timer = setTimeout(() => { + void load(false); + }, LIVE_REFRESH_MS); } } - load(); - const t = setInterval(load, 15000); - + void load(initialMachines.length === 0); return () => { alive = false; - clearInterval(t); + if (timer) clearTimeout(timer); }; - }, []); + }, [initialMachines.length]); async function createMachine() { if (!createName.trim()) { diff --git a/app/(app)/machines/[machineId]/MachineDetailClient.tsx b/app/(app)/machines/[machineId]/MachineDetailClient.tsx index 8b4689f..358a72a 100644 --- a/app/(app)/machines/[machineId]/MachineDetailClient.tsx +++ b/app/(app)/machines/[machineId]/MachineDetailClient.tsx @@ -128,6 +128,7 @@ const TOL = 0.10; const DEFAULT_MICRO_MULT = 1.5; const DEFAULT_MACRO_MULT = 5; const NORMAL_TOL_SEC = 0.1; +const LIVE_REFRESH_MS = 5000; const BUCKET = { normal: { @@ -289,6 +290,24 @@ function rowsToWorkOrders(rows: WorkOrderRow[]): WorkOrderUpload[] { return out; } +function toErrorMessage(value: unknown, fallback: string): string { + if (typeof value === "string" && value.trim().length > 0) return value; + if (value && typeof value === "object") { + const maybeMessage = + ("message" in value && typeof value.message === "string" && value.message) || + ("error" in value && typeof value.error === "string" && value.error) || + ("text" in value && typeof value.text === "string" && value.text) || + null; + if (maybeMessage && maybeMessage.trim().length > 0) return maybeMessage; + try { + return JSON.stringify(value); + } catch { + return fallback; + } + } + return fallback; +} + export default function MachineDetailClient() { const { t, locale } = useI18n(); const { screenlessMode } = useScreenlessMode(); @@ -319,9 +338,14 @@ export default function MachineDetailClient() { if (!machineId) return; let alive = true; + let timer: ReturnType | null = null; - async function load() { + async function load(initial: boolean) { try { + if (!initial && typeof document !== "undefined" && document.hidden) { + return; + } + const res = await fetch(`/api/machines/${machineId}?windowSec=3600&events=critical`, { cache: "no-cache", credentials: "include", @@ -329,7 +353,7 @@ export default function MachineDetailClient() { if (res.status === 304) { if (!alive) return; - setLoading(false); + if (initial) setLoading(false); return; } @@ -338,8 +362,8 @@ export default function MachineDetailClient() { if (!alive) return; if (!res.ok || json?.ok === false) { - setError(json?.error ?? t("machine.detail.error.failed")); - setLoading(false); + setError(toErrorMessage(json?.error, t("machine.detail.error.failed"))); + if (initial) setLoading(false); return; } @@ -350,19 +374,25 @@ export default function MachineDetailClient() { setThresholds(json.thresholds ?? null); setActiveStoppage(json.activeStoppage ?? null); setError(null); - setLoading(false); + if (initial) setLoading(false); } catch { if (!alive) return; - setError(t("machine.detail.error.network")); - setLoading(false); + if (initial) { + setError(t("machine.detail.error.network")); + setLoading(false); + } + } finally { + if (!alive) return; + timer = setTimeout(() => { + void load(false); + }, LIVE_REFRESH_MS); } } - load(); - const timer = setInterval(load, 15000); + void load(true); return () => { alive = false; - clearInterval(timer); + if (timer) clearTimeout(timer); }; }, [machineId, t]); @@ -479,7 +509,7 @@ export default function MachineDetailClient() { } else { setUploadState({ status: "error", - message: json?.error ?? t("machine.detail.workOrders.uploadError"), + message: toErrorMessage(json?.error, t("machine.detail.workOrders.uploadError")), }); } event.target.value = ""; @@ -508,7 +538,7 @@ export default function MachineDetailClient() { const res = await fetch(`/api/machines/${machineId}`, { method: "DELETE" }); const data = await res.json().catch(() => ({})); if (!res.ok || !data.ok) { - throw new Error(data.error || t("machines.delete.error.failed")); + throw new Error(toErrorMessage(data?.error, t("machines.delete.error.failed"))); } router.push("/machines"); } catch (err: unknown) { @@ -886,9 +916,10 @@ export default function MachineDetailClient() { const cycleDerived = useMemo(() => { const rows = cycles ?? []; + const fallbackIdeal = cycleTarget && cycleTarget > 0 ? cycleTarget : null; const mapped: CycleDerivedRow[] = rows.map((cycle) => { - const ideal = cycle.ideal ?? null; + const ideal = cycle.ideal ?? fallbackIdeal; const actual = cycle.actual ?? null; const extra = ideal != null && actual != null ? actual - ideal : null; @@ -914,7 +945,7 @@ export default function MachineDetailClient() { const avgDeltaPct = deltas.length ? deltas.reduce((a, b) => a + b, 0) / deltas.length : null; return { mapped, counts, avgDeltaPct }; - }, [cycles, thresholds]); + }, [cycles, cycleTarget, thresholds]); const deviationSeries = useMemo(() => { const last = cycleDerived.mapped.slice(-100); @@ -1313,7 +1344,7 @@ export default function MachineDetailClient() { className="h-[380px] rounded-3xl border border-white/10 bg-black/30 p-4 backdrop-blur" style={{ boxShadow: "var(--app-chart-shadow)" }} > - + - + diff --git a/app/(app)/machines/loading.tsx b/app/(app)/machines/loading.tsx new file mode 100644 index 0000000..2b44b84 --- /dev/null +++ b/app/(app)/machines/loading.tsx @@ -0,0 +1,22 @@ +export default function MachinesLoading() { + return ( +
+
+
+
+
+
+
+
+
+
+
+ +
+ {Array.from({ length: 6 }).map((_, idx) => ( +
+ ))} +
+
+ ); +} diff --git a/app/(app)/machines/page.tsx b/app/(app)/machines/page.tsx index 362b7a6..ab8ce7e 100644 --- a/app/(app)/machines/page.tsx +++ b/app/(app)/machines/page.tsx @@ -1,6 +1,10 @@ import { redirect } from "next/navigation"; -import { prisma } from "@/lib/prisma"; import { requireSession } from "@/lib/auth/requireSession"; +import { + fetchLatestHeartbeats, + fetchMachineBase, + mergeMachineOverviewRows, +} from "@/lib/machines/withLatest"; import MachinesClient from "./MachinesClient"; function toIso(value?: Date | null) { @@ -11,34 +15,32 @@ export default async function MachinesPage() { const session = await requireSession(); if (!session) redirect("/login?next=/machines"); - const machines = await prisma.machine.findMany({ - where: { orgId: session.orgId }, - orderBy: { createdAt: "desc" }, - 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 }, - }, - }, + const machines = await fetchMachineBase(session.orgId); + const heartbeats = await fetchLatestHeartbeats( + session.orgId, + machines.map((machine) => machine.id) + ); + const rows = mergeMachineOverviewRows({ + machines, + heartbeats, + includeKpi: false, }); - const initialMachines = machines.map((machine) => ({ - ...machine, - latestHeartbeat: machine.heartbeats[0] + const initialMachines = rows.map((machine) => ({ + id: machine.id, + name: machine.name, + code: machine.code ?? null, + location: machine.location ?? null, + latestHeartbeat: machine.latestHeartbeat ? { - ...machine.heartbeats[0], - ts: toIso(machine.heartbeats[0].ts) ?? "", - tsServer: toIso(machine.heartbeats[0].tsServer), + ts: toIso(machine.latestHeartbeat.ts) ?? "", + tsServer: toIso(machine.latestHeartbeat.tsServer), + status: machine.latestHeartbeat.status, + message: machine.latestHeartbeat.message ?? null, + ip: machine.latestHeartbeat.ip ?? null, + fwVersion: machine.latestHeartbeat.fwVersion ?? null, } : null, - heartbeats: undefined, })); return ; diff --git a/app/(app)/overview/OverviewClient.tsx b/app/(app)/overview/OverviewClient.tsx index eaf545b..96b9506 100644 --- a/app/(app)/overview/OverviewClient.tsx +++ b/app/(app)/overview/OverviewClient.tsx @@ -1,57 +1,13 @@ "use client"; import Link from "next/link"; -import { useEffect, useMemo, useState } from "react"; +import { Suspense, lazy, useEffect, useMemo, useState } from "react"; import { useI18n } from "@/lib/i18n/useI18n"; - -type Heartbeat = { - ts: string; - tsServer?: string | null; - status: string; - message?: string | null; - ip?: string | null; - fwVersion?: string | null; -}; - -type Kpi = { - ts: string; - 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; -}; - -type MachineRow = { - id: string; - name: string; - code?: string | null; - location?: string | null; - latestHeartbeat: Heartbeat | null; - latestKpi?: Kpi | null; -}; - -type EventRow = { - id: string; - ts: string; - topic?: string; - eventType: string; - severity: string; - title: string; - description?: string | null; - requiresAck: boolean; - machineId?: string; - machineName?: string; - source: "ingested"; -}; +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; @@ -87,17 +43,20 @@ function fmtNum(v?: number | null) { return `${Math.round(v)}`; } -function severityClass(sev?: string) { - const s = (sev ?? "").toLowerCase(); - if (s === "critical") return "bg-red-500/15 text-red-300"; - if (s === "warning") return "bg-yellow-500/15 text-yellow-300"; - if (s === "info") return "bg-blue-500/15 text-blue-300"; - return "bg-white/10 text-zinc-200"; -} - -function sourceClass(src: EventRow["source"]) { - if (src === "ingested") return "bg-white/10 text-zinc-200"; - return "bg-white/10 text-zinc-200"; +function OverviewTimelineSkeleton() { + return ( +
+
+
+
+
+
+ {Array.from({ length: 4 }).map((_, idx) => ( +
+ ))} +
+
+ ); } export default function OverviewClient({ @@ -111,7 +70,7 @@ export default function OverviewClient({ const [machines, setMachines] = useState(() => initialMachines); const [events, setEvents] = useState(() => initialEvents); const [loading, setLoading] = useState(false); - const [eventsLoading, setEventsLoading] = useState(false); + const [eventsLoading, setEventsLoading] = useState(() => initialEvents.length === 0); useEffect(() => { let alive = true; @@ -119,9 +78,12 @@ export default function OverviewClient({ async function load() { try { setEventsLoading(true); - const res = await fetch(`/api/overview?events=critical&eventMachines=${MAX_EVENT_MACHINES}`, { - cache: "no-cache", - }); + 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; @@ -166,6 +128,7 @@ export default function OverviewClient({ let goodSum = 0; let scrapSum = 0; let targetSum = 0; + let hasKpi = false; for (const m of machines) { const hb = m.latestHeartbeat; @@ -183,22 +146,35 @@ export default function OverviewClient({ 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; } - if (k?.good != null) goodSum += Number(k.good); - if (k?.scrap != null) scrapSum += Number(k.scrap); - if (k?.target != null) targetSum += Number(k.target); } return { @@ -212,9 +188,9 @@ export default function OverviewClient({ availability: availCount ? availSum / availCount : null, performance: perfCount ? perfSum / perfCount : null, quality: qualCount ? qualSum / qualCount : null, - goodSum, - scrapSum, - targetSum, + goodSum: hasKpi ? goodSum : null, + scrapSum: hasKpi ? scrapSum : null, + targetSum: hasKpi ? targetSum : null, }; }, [machines]); @@ -238,27 +214,6 @@ export default function OverviewClient({ return list; }, [machines]); - const formatEventType = (eventType?: string) => { - if (!eventType) return ""; - const key = `overview.event.${eventType}`; - const label = t(key); - return label === key ? eventType : label; - }; - - const formatSource = (source?: string) => { - if (!source) return ""; - const key = `overview.source.${source}`; - const label = t(key); - return label === key ? source : label; - }; - - const formatSeverity = (severity?: string) => { - if (!severity) return ""; - const key = `overview.severity.${severity}`; - const label = t(key); - return label === key ? severity.toUpperCase() : label; - }; - return (
@@ -409,56 +364,9 @@ export default function OverviewClient({ )}
-
-
-
{t("overview.timeline")}
-
- {events.length} {t("overview.items")} -
-
- - {events.length === 0 && !eventsLoading ? ( -
{t("overview.noEvents")}
- ) : ( -
- {events.map((e) => ( -
-
-
-
- - {formatSeverity(e.severity)} - - - {formatEventType(e.eventType)} - - - {formatSource(e.source)} - - {e.requiresAck ? ( - - {t("overview.ack")} - - ) : null} -
- -
- {e.machineName ? `${e.machineName}: ` : ""} - {e.title} -
- {e.description ? ( -
{e.description}
- ) : null} -
-
- {secondsAgo(e.ts, locale, t("common.never"))} -
-
-
- ))} -
- )} -
+ }> + +
); diff --git a/app/(app)/overview/OverviewTimeline.tsx b/app/(app)/overview/OverviewTimeline.tsx new file mode 100644 index 0000000..3e99c71 --- /dev/null +++ b/app/(app)/overview/OverviewTimeline.tsx @@ -0,0 +1,129 @@ +"use client"; + +import type { EventRow } from "./types"; + +type Translator = (key: string, vars?: Record) => string; + +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 severityClass(sev?: string) { + const s = (sev ?? "").toLowerCase(); + if (s === "critical") return "bg-red-500/15 text-red-300"; + if (s === "warning") return "bg-yellow-500/15 text-yellow-300"; + if (s === "info") return "bg-blue-500/15 text-blue-300"; + return "bg-white/10 text-zinc-200"; +} + +function sourceClass(src: EventRow["source"]) { + if (src === "ingested") return "bg-white/10 text-zinc-200"; + return "bg-white/10 text-zinc-200"; +} + +function formatEventType(eventType: string | undefined, t: Translator) { + if (!eventType) return ""; + const key = `overview.event.${eventType}`; + const label = t(key); + return label === key ? eventType : label; +} + +function formatSource(source: string | undefined, t: Translator) { + if (!source) return ""; + const key = `overview.source.${source}`; + const label = t(key); + return label === key ? source : label; +} + +function formatSeverity(severity: string | undefined, t: Translator) { + if (!severity) return ""; + const key = `overview.severity.${severity}`; + const label = t(key); + return label === key ? severity.toUpperCase() : label; +} + +export default function OverviewTimeline({ + events, + eventsLoading, + locale, + t, +}: { + events: EventRow[]; + eventsLoading: boolean; + locale: string; + t: Translator; +}) { + if (eventsLoading && events.length === 0) { + return ( +
+
+
+
+
+
+ {Array.from({ length: 4 }).map((_, idx) => ( +
+ ))} +
+
+ ); + } + + return ( +
+
+
{t("overview.timeline")}
+
+ {events.length} {t("overview.items")} +
+
+ + {events.length === 0 && !eventsLoading ? ( +
{t("overview.noEvents")}
+ ) : ( +
+ {events.map((e) => ( +
+
+
+
+ + {formatSeverity(e.severity, t)} + + + {formatEventType(e.eventType, t)} + + + {formatSource(e.source, t)} + + {e.requiresAck ? ( + + {t("overview.ack")} + + ) : null} +
+ +
+ {e.machineName ? `${e.machineName}: ` : ""} + {e.title} +
+ {e.description ? ( +
{e.description}
+ ) : null} +
+
+ {secondsAgo(e.ts, locale, t("common.never"))} +
+
+
+ ))} +
+ )} +
+ ); +} diff --git a/app/(app)/overview/loading.tsx b/app/(app)/overview/loading.tsx new file mode 100644 index 0000000..f1fc7e7 --- /dev/null +++ b/app/(app)/overview/loading.tsx @@ -0,0 +1,30 @@ +export default function OverviewLoading() { + return ( +
+
+
+
+
+
+
+
+ +
+ {Array.from({ length: 3 }).map((_, idx) => ( +
+ ))} +
+ +
+ {Array.from({ length: 4 }).map((_, idx) => ( +
+ ))} +
+ +
+
+
+
+
+ ); +} diff --git a/app/(app)/overview/page.tsx b/app/(app)/overview/page.tsx index 6789ba6..8b90ff5 100644 --- a/app/(app)/overview/page.tsx +++ b/app/(app)/overview/page.tsx @@ -1,6 +1,8 @@ import { redirect } from "next/navigation"; import { requireSession } from "@/lib/auth/requireSession"; -import { getOverviewData } from "@/lib/overview/getOverviewData"; +import { getOverviewSummary } from "@/lib/overview/getOverviewSummary"; +import type { getOverviewData } from "@/lib/overview/getOverviewData"; +import { logLine } from "@/lib/logger"; import OverviewClient from "./OverviewClient"; function toIso(value?: Date | null) { @@ -11,12 +13,18 @@ export default async function OverviewPage() { const session = await requireSession(); if (!session) redirect("/login?next=/overview"); - const { machines, events } = await getOverviewData({ - orgId: session.orgId, - eventsMode: "critical", - eventsWindowSec: 21600, - eventMachines: 6, - }); + let machines: Awaited>["machines"]; + let events: Awaited>["events"] = []; + try { + const data = await getOverviewSummary({ orgId: session.orgId }); + machines = data.machines; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + const stack = err instanceof Error ? err.stack : undefined; + logLine("OverviewPage.getOverviewSummary.error", { message, stack }); + console.error("[OverviewPage] getOverviewSummary:", err); + machines = []; + } const initialMachines = machines.map((machine) => ({ ...machine, diff --git a/app/(app)/overview/types.ts b/app/(app)/overview/types.ts new file mode 100644 index 0000000..06ce77e --- /dev/null +++ b/app/(app)/overview/types.ts @@ -0,0 +1,45 @@ +export type Heartbeat = { + ts: string; + tsServer?: string | null; + status: string; + message?: string | null; + ip?: string | null; + fwVersion?: string | null; +}; + +export type Kpi = { + ts: string; + 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 type MachineRow = { + id: string; + name: string; + code?: string | null; + location?: string | null; + latestHeartbeat: Heartbeat | null; + latestKpi?: Kpi | null; +}; + +export type EventRow = { + id: string; + ts: string; + topic?: string; + eventType: string; + severity: string; + title: string; + description?: string | null; + requiresAck: boolean; + machineId?: string; + machineName?: string; + source: "ingested"; +}; diff --git a/app/(app)/reports/ReportsCharts.tsx b/app/(app)/reports/ReportsCharts.tsx new file mode 100644 index 0000000..9c2740c --- /dev/null +++ b/app/(app)/reports/ReportsCharts.tsx @@ -0,0 +1,249 @@ +"use client"; + +import { + Bar, + BarChart, + CartesianGrid, + Cell, + Line, + LineChart, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; + +type Translator = (key: string, vars?: Record) => string; +type TooltipPayload = { payload?: T; name?: string; value?: number | string }; +type SimpleTooltipProps = { + active?: boolean; + payload?: Array>; + label?: string | number; +}; + +type ChartPoint = { ts: string; label: string; value: number }; +type CycleHistogramRow = { + label: string; + count: number; + rangeStart?: number; + rangeEnd?: number; + overflow?: "low" | "high"; + minValue?: number; + maxValue?: number; +}; + +function CycleTooltip({ active, payload, t }: SimpleTooltipProps & { t: Translator }) { + if (!active || !payload?.length) return null; + const p = payload[0]?.payload; + if (!p) return null; + + let detail = ""; + if (p.overflow === "low") { + detail = `${t("reports.tooltip.below")} ${p.rangeEnd?.toFixed(1)}s`; + } else if (p.overflow === "high") { + detail = `${t("reports.tooltip.above")} ${p.rangeStart?.toFixed(1)}s`; + } else if (p.rangeStart != null && p.rangeEnd != null) { + detail = `${p.rangeStart.toFixed(1)}s - ${p.rangeEnd.toFixed(1)}s`; + } + + const extreme = + p.overflow && (p.minValue != null || p.maxValue != null) + ? `${t("reports.tooltip.extremes")}: ${p.minValue?.toFixed(1) ?? "--"}s - ${p.maxValue?.toFixed(1) ?? "--"}s` + : ""; + + return ( +
+
{p.label}
+
+
+ {t("reports.tooltip.cycles")}: {p.count} +
+ {detail ? ( +
+ {t("reports.tooltip.range")}: {detail} +
+ ) : null} + {extreme ?
{extreme}
: null} +
+
+ ); +} + +function DowntimeTooltip({ + active, + payload, + t, +}: SimpleTooltipProps<{ name?: string; value?: number }> & { t: Translator }) { + if (!active || !payload?.length) return null; + const row = payload[0]?.payload ?? {}; + const label = row.name ?? payload[0]?.name ?? ""; + const value = row.value ?? payload[0]?.value ?? 0; + + return ( +
+
{label}
+
+ {t("reports.tooltip.downtime")}: {Number(value)} min +
+
+ ); +} + +export default function ReportsCharts({ + oeeSeries, + downtimeSeries, + downtimeColors, + cycleHistogram, + scrapSeries, + lossRows, + locale, + t, +}: { + oeeSeries: ChartPoint[]; + downtimeSeries: { name: string; value: number }[]; + downtimeColors: Record; + cycleHistogram: CycleHistogramRow[]; + scrapSeries: ChartPoint[]; + lossRows: Array<{ label: string; value: string }>; + locale: string; + t: Translator; +}) { + return ( + <> +
+
+
{t("reports.oeeTrend")}
+
+ {oeeSeries.length ? ( + + + + + + { + const row = payload?.[0]?.payload; + return row?.ts ? new Date(row.ts).toLocaleString(locale) : ""; + }} + formatter={(val: number | string | undefined) => [ + val == null ? "--" : `${Number(val).toFixed(1)}%`, + "OEE", + ]} + /> + + + + ) : ( +
+ {t("reports.noTrend")} +
+ )} +
+
+ +
+
{t("reports.downtimePareto")}
+
+ {downtimeSeries.length ? ( + + + + + + } /> + + {downtimeSeries.map((row, idx) => ( + + ))} + + + + ) : ( +
+ {t("reports.noTrend")} +
+ )} +
+
+
+ +
+
+
{t("reports.cycleDistribution")}
+
+ {cycleHistogram.length ? ( + + + + + + } /> + + + + ) : ( +
+ {t("reports.noCycle")} +
+ )} +
+
+ +
+
{t("reports.scrapTrend")}
+
+ {scrapSeries.length ? ( + + + + + + { + const row = payload?.[0]?.payload; + return row?.ts ? new Date(row.ts).toLocaleString(locale) : ""; + }} + formatter={(val: number | string | undefined) => [ + val == null ? "--" : `${Number(val).toFixed(1)}%`, + t("reports.scrapRate"), + ]} + /> + + + + ) : ( +
+ {t("reports.noDowntime")} +
+ )} +
+
+ +
+
{t("reports.topLossDrivers")}
+
+ {lossRows.map((row) => ( +
+ {row.label} + {row.value} +
+ ))} +
+
+
+ + ); +} diff --git a/app/(app)/reports/downtime-pareto/page.tsx b/app/(app)/reports/downtime-pareto/page.tsx index 12fe18a..5b18162 100644 --- a/app/(app)/reports/downtime-pareto/page.tsx +++ b/app/(app)/reports/downtime-pareto/page.tsx @@ -1,12 +1,13 @@ import { redirect } from "next/navigation"; -export default function LegacyDowntimeParetoPage({ +export default async function LegacyDowntimeParetoPage({ searchParams, }: { - searchParams: Record; + searchParams: Promise>; }) { + const params = await searchParams; const qs = new URLSearchParams(); - for (const [k, v] of Object.entries(searchParams)) { + for (const [k, v] of Object.entries(params)) { if (typeof v === "string") qs.set(k, v); else if (Array.isArray(v)) v.forEach((vv) => qs.append(k, vv)); } diff --git a/app/(app)/reports/page.tsx b/app/(app)/reports/page.tsx index 4fcd028..03688f1 100644 --- a/app/(app)/reports/page.tsx +++ b/app/(app)/reports/page.tsx @@ -1,19 +1,9 @@ "use client"; -import { useEffect, useMemo, useState } from "react"; +import { Suspense, lazy, useEffect, useMemo, useState } from "react"; import { useI18n } from "@/lib/i18n/useI18n"; -import { - Bar, - BarChart, - CartesianGrid, - Cell, - Line, - LineChart, - ResponsiveContainer, - Tooltip, - XAxis, - YAxis, -} from "recharts"; + +const ReportsCharts = lazy(() => import("./ReportsCharts")); type RangeKey = "24h" | "7d" | "30d" | "custom"; @@ -68,13 +58,6 @@ type ReportPayload = { type MachineOption = { id: string; name: string }; type FilterOptions = { workOrders: string[]; skus: string[] }; type Translator = (key: string, vars?: Record) => string; -type TooltipPayload = { payload?: T; name?: string; value?: number | string }; -type SimpleTooltipProps = { - active?: boolean; - payload?: Array>; - label?: string | number; -}; -type CycleHistogramRow = ReportPayload["distribution"]["cycleTime"][number]; function fmtPct(v?: number | null) { if (v === null || v === undefined || Number.isNaN(v)) return "--"; @@ -106,56 +89,20 @@ function formatTickLabel(ts: string, range: RangeKey) { return `${month}-${day}`; } -function CycleTooltip({ active, payload, t }: SimpleTooltipProps & { t: Translator }) { - if (!active || !payload?.length) return null; - const p = payload[0]?.payload; - if (!p) return null; - - let detail = ""; - if (p.overflow === "low") { - detail = `${t("reports.tooltip.below")} ${p.rangeEnd?.toFixed(1)}s`; - } else if (p.overflow === "high") { - detail = `${t("reports.tooltip.above")} ${p.rangeStart?.toFixed(1)}s`; - } else if (p.rangeStart != null && p.rangeEnd != null) { - detail = `${p.rangeStart.toFixed(1)}s - ${p.rangeEnd.toFixed(1)}s`; - } - - const extreme = - p.overflow && (p.minValue != null || p.maxValue != null) - ? `${t("reports.tooltip.extremes")}: ${p.minValue?.toFixed(1) ?? "--"}s - ${p.maxValue?.toFixed(1) ?? "--"}s` - : ""; - +function ReportsChartsSkeleton() { return ( -
-
{p.label}
-
-
- {t("reports.tooltip.cycles")}: {p.count} -
- {detail ? ( -
- {t("reports.tooltip.range")}: {detail} -
- ) : null} - {extreme ?
{extreme}
: null} + <> +
+ {Array.from({ length: 2 }).map((_, idx) => ( +
+ ))}
-
- ); -} - -function DowntimeTooltip({ active, payload, t }: SimpleTooltipProps<{ name?: string; value?: number }> & { t: Translator }) { - if (!active || !payload?.length) return null; - const row = payload[0]?.payload ?? {}; - const label = row.name ?? payload[0]?.name ?? ""; - const value = row.value ?? payload[0]?.value ?? 0; - - return ( -
-
{label}
-
- {t("reports.tooltip.downtime")}: {Number(value)} min +
+ {Array.from({ length: 3 }).map((_, idx) => ( +
+ ))}
-
+ ); } @@ -534,6 +481,21 @@ export default function ReportsPage() { 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; @@ -696,147 +658,18 @@ export default function ReportsPage() { ))}
-
-
-
{t("reports.oeeTrend")}
-
- {oeeSeries.length ? ( - - - - - - { - const row = payload?.[0]?.payload; - return row?.ts ? new Date(row.ts).toLocaleString(locale) : ""; - }} - formatter={(val: number | string | undefined) => [ - val == null ? "--" : `${Number(val).toFixed(1)}%`, - "OEE", - ]} - /> - - - - ) : ( -
- {t("reports.noTrend")} -
- )} -
-
- -
-
{t("reports.downtimePareto")}
-
- {downtimeSeries.length ? ( - - - - - - } /> - - {downtimeSeries.map((row, idx) => ( - - ))} - - - - ) : ( -
- {t("reports.noTrend")} -
- )} -
-
-
- -
-
-
{t("reports.cycleDistribution")}
-
- {cycleHistogram.length ? ( - - - - - - } /> - - - - ) : ( -
- {t("reports.noCycle")} -
- )} -
-
- -
-
{t("reports.scrapTrend")}
-
- {scrapSeries.length ? ( - - - - - - { - const row = payload?.[0]?.payload; - return row?.ts ? new Date(row.ts).toLocaleString(locale) : ""; - }} - formatter={(val: number | string | undefined) => [ - val == null ? "--" : `${Number(val).toFixed(1)}%`, - t("reports.scrapRate"), - ]} - /> - - - - ) : ( -
- {t("reports.noDowntime")} -
- )} -
-
- -
-
{t("reports.topLossDrivers")}
-
- {[ - { 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}` : "--", - }, - ].map((row) => ( -
- {row.label} - {row.value} -
- ))} -
-
-
+ }> + +
diff --git a/app/(app)/settings/page.tsx b/app/(app)/settings/page.tsx index a4df35b..b689b5f 100644 --- a/app/(app)/settings/page.tsx +++ b/app/(app)/settings/page.tsx @@ -1,9 +1,10 @@ "use client"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { AlertsConfig } from "@/components/settings/AlertsConfig"; import { FinancialCostConfig } from "@/components/settings/FinancialCostConfig"; import { useI18n } from "@/lib/i18n/useI18n"; +import { SHIFT_OVERRIDE_DAYS, type ShiftOverrideDay } from "@/lib/settings"; import { useScreenlessMode } from "@/lib/ui/screenlessMode"; @@ -25,6 +26,7 @@ type SettingsPayload = { shiftSchedule: { shifts: Shift[]; + overrides?: Partial>; shiftChangeCompensationMin: number; lunchBreakMin: number; }; @@ -88,6 +90,7 @@ const DEFAULT_SETTINGS: SettingsPayload = { modules: { screenlessMode: false }, shiftSchedule: { shifts: [], + overrides: {}, shiftChangeCompensationMin: 10, lunchBreakMin: 30, }, @@ -199,6 +202,21 @@ function normalizeShift(raw: unknown, fallbackName: string): Shift { return { name, start, end, enabled }; } +function normalizeShiftOverrides( + raw: unknown, + fallbackName: (index: number) => string +): Partial> { + const record = asRecord(raw); + if (!record) return {}; + const out: Partial> = {}; + for (const day of SHIFT_OVERRIDE_DAYS) { + const shiftsRaw = Array.isArray(record[day]) ? (record[day] as unknown[]) : null; + if (!shiftsRaw) continue; + out[day] = shiftsRaw.map((shift, idx) => normalizeShift(shift, fallbackName(idx + 1))); + } + return out; +} + function normalizeSettings(raw: unknown, fallbackName: (index: number) => string): SettingsPayload { const record = asRecord(raw); const modules = asRecord(record?.modules) ?? {}; @@ -217,6 +235,7 @@ function normalizeSettings(raw: unknown, fallbackName: (index: number) => string const shifts = shiftsRaw.length ? shiftsRaw.map((s, idx) => normalizeShift(s, fallbackName(idx + 1))) : [{ name: fallbackName(1), ...DEFAULT_SHIFT }]; + const overrides = normalizeShiftOverrides(shiftSchedule.overrides, fallbackName); const thresholds = asRecord(record.thresholds) ?? {}; const alerts = asRecord(record.alerts) ?? {}; const defaults = asRecord(record.defaults) ?? {}; @@ -227,6 +246,7 @@ function normalizeSettings(raw: unknown, fallbackName: (index: number) => string timezone: String(record.timezone ?? DEFAULT_SETTINGS.timezone), shiftSchedule: { shifts, + overrides, shiftChangeCompensationMin: Number( shiftSchedule.shiftChangeCompensationMin ?? DEFAULT_SETTINGS.shiftSchedule.shiftChangeCompensationMin ), @@ -326,16 +346,26 @@ export default function SettingsPage() { const [inviteStatus, setInviteStatus] = useState(null); const [inviteSubmitting, setInviteSubmitting] = useState(false); const [activeTab, setActiveTab] = useState<(typeof SETTINGS_TABS)[number]["id"]>("general"); + const hasMountedRef = useRef(false); const defaultShiftName = useCallback( (index: number) => t("settings.shift.defaultName", { index }), [t] ); + const shiftOverrideDays = useMemo( + () => + SHIFT_OVERRIDE_DAYS.map((day) => ({ + key: day, + label: t(`settings.shiftOverrides.${day}`), + })), + [t] + ); - const loadSettings = useCallback(async () => { + const loadSettings = useCallback(async (forceRefresh = false) => { setLoading(true); setError(null); try { - const response = await fetch("/api/settings", { cache: "no-store" }); + const url = forceRefresh ? "/api/settings?refresh=1" : "/api/settings"; + const response = await fetch(url, { cache: forceRefresh ? "no-store" : "default" }); const { data, text } = await readResponse(response); const api = unwrapApiResponse(data); if (!response.ok || !api.ok) { @@ -350,7 +380,7 @@ export default function SettingsPage() { } finally { setLoading(false); } - }, [defaultShiftName, t]); + }, [defaultShiftName, t, setScreenlessMode]); const buildInviteUrl = useCallback((token: string) => { if (typeof window === "undefined") return `/invite/${token}`; @@ -380,10 +410,14 @@ export default function SettingsPage() { } }, [t]); + // Only run once on mount to prevent infinite loops from dependency changes useEffect(() => { + if (hasMountedRef.current) return; + hasMountedRef.current = true; loadSettings(); loadTeam(); - }, [loadSettings, loadTeam]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); const updateShift = useCallback((index: number, patch: Partial) => { setDraft((prev) => { @@ -448,6 +482,96 @@ export default function SettingsPage() { }); }, []); + const toggleShiftOverride = useCallback((day: ShiftOverrideDay) => { + setDraft((prev) => { + if (!prev) return prev; + const overrides = { ...(prev.shiftSchedule.overrides ?? {}) }; + if (overrides[day]) { + delete overrides[day]; + } else { + overrides[day] = prev.shiftSchedule.shifts.map((shift) => ({ ...shift })); + } + return { + ...prev, + shiftSchedule: { + ...prev.shiftSchedule, + overrides, + }, + }; + }); + }, []); + + const updateShiftOverride = useCallback((day: ShiftOverrideDay, index: number, patch: Partial) => { + setDraft((prev) => { + if (!prev) return prev; + const current = prev.shiftSchedule.overrides?.[day]; + if (!current) return prev; + const overrides = { ...(prev.shiftSchedule.overrides ?? {}) }; + overrides[day] = current.map((shift, idx) => (idx === index ? { ...shift, ...patch } : shift)); + return { + ...prev, + shiftSchedule: { + ...prev.shiftSchedule, + overrides, + }, + }; + }); + }, []); + + const addShiftOverride = useCallback( + (day: ShiftOverrideDay) => { + setDraft((prev) => { + if (!prev) return prev; + const overrides = { ...(prev.shiftSchedule.overrides ?? {}) }; + const current = overrides[day] ? [...overrides[day]!] : []; + if (current.length >= 3) return prev; + const nextIndex = current.length + 1; + current.push({ name: defaultShiftName(nextIndex), ...DEFAULT_SHIFT }); + overrides[day] = current; + return { + ...prev, + shiftSchedule: { + ...prev.shiftSchedule, + overrides, + }, + }; + }); + }, + [defaultShiftName] + ); + + const removeShiftOverride = useCallback((day: ShiftOverrideDay, index: number) => { + setDraft((prev) => { + if (!prev) return prev; + const current = prev.shiftSchedule.overrides?.[day]; + if (!current) return prev; + const overrides = { ...(prev.shiftSchedule.overrides ?? {}) }; + overrides[day] = current.filter((_, idx) => idx !== index); + return { + ...prev, + shiftSchedule: { + ...prev.shiftSchedule, + overrides, + }, + }; + }); + }, []); + + const clearShiftOverride = useCallback((day: ShiftOverrideDay) => { + setDraft((prev) => { + if (!prev) return prev; + const overrides = { ...(prev.shiftSchedule.overrides ?? {}) }; + overrides[day] = []; + return { + ...prev, + shiftSchedule: { + ...prev.shiftSchedule, + overrides, + }, + }; + }); + }, []); + const updateThreshold = useCallback( ( key: @@ -665,7 +789,7 @@ export default function SettingsPage() {
+ +
+
{t("settings.shiftOverrides.title")}
+
{t("settings.shiftOverrides.subtitle")}
+
+ {shiftOverrideDays.map((day) => { + const dayOverrides = draft.shiftSchedule.overrides?.[day.key]; + const overrideShifts = dayOverrides ?? []; + const isCustom = dayOverrides !== undefined; + return ( +
+
+
{day.label}
+ +
+ + {!isCustom && ( +
{t("settings.shiftOverrides.inherits")}
+ )} + + {isCustom && ( + <> + {overrideShifts.length === 0 ? ( +
+ {t("settings.shiftOverrides.dayOff")} +
+ ) : ( +
+ {overrideShifts.map((shift, index) => ( +
+
+ + updateShiftOverride(day.key, index, { name: event.target.value }) + } + className="w-full rounded-md border border-white/10 bg-black/30 px-2 py-1 text-xs text-white" + /> + +
+
+ + updateShiftOverride(day.key, index, { start: event.target.value }) + } + className="w-full rounded-md border border-white/10 bg-black/30 px-2 py-1 text-xs text-white" + /> + {t("settings.shiftTo")} + + updateShiftOverride(day.key, index, { end: event.target.value }) + } + className="w-full rounded-md border border-white/10 bg-black/30 px-2 py-1 text-xs text-white" + /> +
+
+ + updateShiftOverride(day.key, index, { enabled: event.target.checked }) + } + className="h-4 w-4 rounded border border-white/20 bg-black/20" + /> + {t("settings.shiftEnabled")} +
+
+ ))} +
+ )} + +
+ + +
+ + )} +
+ ); + })} +
+
)} diff --git a/app/api/analytics/pareto/route.ts b/app/api/analytics/pareto/route.ts index 1b53cc7..1f3b61a 100644 --- a/app/api/analytics/pareto/route.ts +++ b/app/api/analytics/pareto/route.ts @@ -64,7 +64,7 @@ export async function GET(req: Request) { 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); diff --git a/app/api/debug/logs/route.ts b/app/api/debug/logs/route.ts new file mode 100644 index 0000000..5fb9188 --- /dev/null +++ b/app/api/debug/logs/route.ts @@ -0,0 +1,45 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import fs from "fs"; +import { getLogPath } from "@/lib/logger"; + +const MAX_LINES = 100; + +/** + * GET /api/debug/logs?key=YOUR_DEBUG_LOGS_KEY + * + * Returns the last MAX_LINES from the app log file. Set DEBUG_LOGS_KEY in .env + * and call with ?key=... to view. If DEBUG_LOGS_KEY is unset, returns 401. + */ +export async function GET(req: NextRequest) { + const key = req.nextUrl.searchParams.get("key"); + const secret = process.env.DEBUG_LOGS_KEY; + + if (!secret || key !== secret) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const logPath = getLogPath(); + try { + const raw = fs.readFileSync(logPath, "utf8"); + const lines = raw.split("\n").filter(Boolean); + const recent = lines.slice(-MAX_LINES); + return NextResponse.json({ + logPath, + lines: recent.length, + entries: recent.map((line) => { + try { + return JSON.parse(line) as Record; + } catch { + return { raw: line }; + } + }), + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return NextResponse.json( + { error: "Failed to read log file", detail: message, logPath }, + { status: 500 } + ); + } +} diff --git a/app/api/debug/perf/route.ts b/app/api/debug/perf/route.ts new file mode 100644 index 0000000..db9f3fd --- /dev/null +++ b/app/api/debug/perf/route.ts @@ -0,0 +1,31 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { logLine } from "@/lib/logger"; + +export const dynamic = "force-dynamic"; + +type PerfPayload = { + event?: string; + data?: Record; +}; + +export async function POST(req: NextRequest) { + try { + const body = (await req.json()) as PerfPayload; + const type = typeof body?.event === "string" ? body.event : "nav"; + const data = body?.data && typeof body.data === "object" ? body.data : {}; + const userAgent = req.headers.get("user-agent") ?? ""; + + logLine("perf.client", { + type, + userAgent, + ...data, + }); + + return NextResponse.json({ ok: true }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + logLine("perf.client.error", { message }); + return NextResponse.json({ ok: false, error: "Bad payload" }, { status: 400 }); + } +} diff --git a/app/api/financial/costs/route.ts b/app/api/financial/costs/route.ts index dd47c2c..4a2baa8 100644 --- a/app/api/financial/costs/route.ts +++ b/app/api/financial/costs/route.ts @@ -1,8 +1,17 @@ import { NextResponse } from "next/server"; +import type { NextRequest } from "next/server"; +import { createHash } from "crypto"; +import { revalidateTag } from "next/cache"; import { prisma } from "@/lib/prisma"; import { requireSession } from "@/lib/auth/requireSession"; import { Prisma } from "@prisma/client"; import { z } from "zod"; +import { + FINANCIAL_CONFIG_SWR_SEC, + FINANCIAL_CONFIG_TTL_SEC, + getFinancialConfig, + type FinancialConfigPayload, +} from "@/lib/financial/cache"; function canManageFinancials(role?: string | null) { return role === "OWNER"; @@ -101,18 +110,37 @@ async function ensureOrgFinancialProfile( }); } -async function loadFinancialConfig(orgId: string) { - const [org, locations, machines, products] = await Promise.all([ - prisma.orgFinancialProfile.findUnique({ where: { orgId } }), - prisma.locationFinancialOverride.findMany({ where: { orgId }, orderBy: { location: "asc" } }), - prisma.machineFinancialOverride.findMany({ where: { orgId }, orderBy: { createdAt: "desc" } }), - prisma.productCostOverride.findMany({ where: { orgId }, orderBy: { sku: "asc" } }), - ]); - - return { org, locations, machines, products }; +function toMs(value?: Date | string | null) { + if (!value) return 0; + const date = typeof value === "string" ? new Date(value) : value; + const ms = date.getTime(); + return Number.isNaN(ms) ? 0 : ms; } -export async function GET() { +function maxUpdatedMs(rows: Array<{ updatedAt?: Date | string | null }>) { + let max = 0; + for (const row of rows) { + const ms = toMs(row.updatedAt); + if (ms > max) max = ms; + } + return max; +} + +function buildConfigEtag(orgId: string, payload: FinancialConfigPayload) { + const parts = [ + orgId, + toMs(payload.org?.updatedAt), + maxUpdatedMs(payload.locations ?? []), + maxUpdatedMs(payload.machines ?? []), + maxUpdatedMs(payload.products ?? []), + payload.locations?.length ?? 0, + payload.machines?.length ?? 0, + payload.products?.length ?? 0, + ]; + return `W/"${createHash("sha1").update(parts.join("|")).digest("hex")}"`; +} + +export async function GET(req: NextRequest) { const session = await requireSession(); if (!session) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 }); @@ -124,9 +152,25 @@ export async function GET() { return NextResponse.json({ ok: false, error: "Forbidden" }, { status: 403 }); } + const url = new URL(req.url); + const refresh = url.searchParams.get("refresh") === "1"; + await prisma.$transaction((tx) => ensureOrgFinancialProfile(tx, session.orgId, session.userId)); - const payload = await loadFinancialConfig(session.orgId); - return NextResponse.json({ ok: true, ...payload }); + const payload = await getFinancialConfig(session.orgId, { refresh }); + + const etag = buildConfigEtag(session.orgId, payload); + const responseHeaders = new Headers({ + "Cache-Control": `private, max-age=${FINANCIAL_CONFIG_TTL_SEC}, stale-while-revalidate=${FINANCIAL_CONFIG_SWR_SEC}`, + ETag: etag, + Vary: "Cookie", + }); + + const ifNoneMatch = req.headers.get("if-none-match"); + if (!refresh && ifNoneMatch && ifNoneMatch === etag) { + return new NextResponse(null, { status: 304, headers: responseHeaders }); + } + + return NextResponse.json({ ok: true, ...payload }, { headers: responseHeaders }); } export async function POST(req: Request) { @@ -257,6 +301,9 @@ export async function POST(req: Request) { } }); - const payload = await loadFinancialConfig(session.orgId); + revalidateTag(`financial-config:${session.orgId}`, { expire: 0 }); + revalidateTag(`financial-impact:${session.orgId}`, { expire: 0 }); + + const payload = await getFinancialConfig(session.orgId, { refresh: true }); return NextResponse.json({ ok: true, ...payload }); } diff --git a/app/api/financial/impact/route.ts b/app/api/financial/impact/route.ts index 6bc4061..f3c17c8 100644 --- a/app/api/financial/impact/route.ts +++ b/app/api/financial/impact/route.ts @@ -2,7 +2,11 @@ import { NextResponse } from "next/server"; import type { NextRequest } from "next/server"; import { prisma } from "@/lib/prisma"; import { requireSession } from "@/lib/auth/requireSession"; -import { computeFinancialImpact } from "@/lib/financial/impact"; +import { + FINANCIAL_IMPACT_SWR_SEC, + FINANCIAL_IMPACT_TTL_SEC, + getFinancialImpactCached, +} from "@/lib/financial/cache"; const RANGE_MS: Record = { "24h": 24 * 60 * 60 * 1000, @@ -50,22 +54,31 @@ export async function GET(req: NextRequest) { } const url = new URL(req.url); + const refresh = url.searchParams.get("refresh") === "1"; const { start, end } = pickRange(req); const machineId = url.searchParams.get("machineId") ?? undefined; const location = url.searchParams.get("location") ?? undefined; const sku = url.searchParams.get("sku") ?? undefined; const currency = url.searchParams.get("currency") ?? undefined; - const result = await computeFinancialImpact({ - orgId: session.orgId, - start, - end, - machineId, - location, - sku, - currency, - includeEvents: false, + const result = await getFinancialImpactCached( + { + orgId: session.orgId, + start, + end, + machineId, + location, + sku, + currency, + includeEvents: false, + }, + { refresh } + ); + + const responseHeaders = new Headers({ + "Cache-Control": `private, max-age=${FINANCIAL_IMPACT_TTL_SEC}, stale-while-revalidate=${FINANCIAL_IMPACT_SWR_SEC}`, + Vary: "Cookie", }); - return NextResponse.json({ ok: true, ...result }); + return NextResponse.json({ ok: true, ...result }, { headers: responseHeaders }); } diff --git a/app/api/ingest/cycle/route.ts b/app/api/ingest/cycle/route.ts index e9fae07..2461fb0 100644 --- a/app/api/ingest/cycle/route.ts +++ b/app/api/ingest/cycle/route.ts @@ -33,6 +33,48 @@ function unwrapEnvelope(raw: unknown) { }; } +function asNumber(value: unknown) { + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value === "string" && value.trim() !== "") { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; + } + return undefined; +} + +function normalizeCycleInput(raw: unknown): Record | null { + const row = asRecord(raw); + if (!row) return null; + const data = asRecord(row.data); + + const fromRowOrData = (keys: string[]) => { + for (const key of keys) { + if (row[key] !== undefined) return row[key]; + if (data && data[key] !== undefined) return data[key]; + } + return undefined; + }; + + return { + ...row, + actual_cycle_time: fromRowOrData(["actual_cycle_time", "actualCycleTime", "actual_cycle", "actual"]), + theoretical_cycle_time: fromRowOrData([ + "theoretical_cycle_time", + "theoreticalCycleTime", + "cycleTime", + "cycle_time", + "ideal", + ]), + cycle_count: fromRowOrData(["cycle_count", "cycleCount"]), + work_order_id: fromRowOrData(["work_order_id", "workOrderId"]), + good_delta: fromRowOrData(["good_delta", "goodDelta"]), + scrap_delta: fromRowOrData(["scrap_delta", "scrapDelta", "scrap_total"]), + timestamp: fromRowOrData(["timestamp", "tsMs"]), + ts: fromRowOrData(["ts", "tsMs"]), + event_timestamp: fromRowOrData(["event_timestamp", "eventTimestamp"]), + }; +} + const numberFromAny = z.preprocess((value) => { if (typeof value === "number") return value; if (typeof value === "string" && value.trim() !== "") return Number(value); @@ -87,15 +129,22 @@ export async function POST(req: Request) { return NextResponse.json({ ok: false, error: "Invalid payload" }, { status: 400 }); } - const cycleList = Array.isArray(cyclesRaw) ? cyclesRaw : [cyclesRaw]; + const cycleList = (Array.isArray(cyclesRaw) ? cyclesRaw : [cyclesRaw]) + .map((row) => normalizeCycleInput(row)) + .filter((row): row is Record => !!row); + + if (!cycleList.length) { + return NextResponse.json({ ok: false, error: "Invalid payload" }, { status: 400 }); + } + const parsedCycles = z.array(cycleSchema).safeParse(cycleList); if (!parsedCycles.success) { return NextResponse.json({ ok: false, error: "Invalid payload" }, { status: 400 }); } const fallbackTsMs = - (typeof bodyRecord.tsMs === "number" && bodyRecord.tsMs) || - (typeof bodyRecord.tsDevice === "number" && bodyRecord.tsDevice) || + asNumber(bodyRecord.tsMs) || + asNumber(bodyRecord.tsDevice) || undefined; const rows = parsedCycles.data.map((data) => { diff --git a/app/api/ingest/event/route.ts b/app/api/ingest/event/route.ts index 74d4622..1926cf6 100644 --- a/app/api/ingest/event/route.ts +++ b/app/api/ingest/event/route.ts @@ -4,6 +4,14 @@ 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 ?? "") @@ -30,6 +38,8 @@ const CANON_TYPE: Record = { "microparo": "microstop", "micro-paro": "microstop", "down": "stop", + "downtime-acknowledged": "downtime-acknowledged", + "scrap-manual-entry": "scrap-manual-entry", }; const ALLOWED_TYPES = new Set([ @@ -42,6 +52,8 @@ const ALLOWED_TYPES = new Set([ "quality-spike", "performance-degradation", "predictive-oee-decline", + "downtime-acknowledged", + "scrap-manual-entry", ]); const machineIdSchema = z.string().uuid(); @@ -58,6 +70,153 @@ function clampText(value: unknown, maxLen: number) { 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 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, + 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 }); @@ -103,8 +262,11 @@ export async function POST(req: Request) { 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 }, + 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( @@ -129,6 +291,8 @@ export async function POST(req: Request) { continue; } const evData = asRecord(evRecord.data) ?? {}; + const evReason = asRecord(evRecord.reason) ?? asRecord(evData.reason); + const evDowntime = asRecord(evRecord.downtime) ?? asRecord(evData.downtime); const rawType = evRecord.eventType ?? evRecord.anomaly_type ?? evRecord.topic ?? bodyRecord.topic ?? ""; const typ0 = normalizeType(rawType); @@ -211,6 +375,8 @@ export async function POST(req: Request) { 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; const activeWorkOrder = asRecord(evRecord.activeWorkOrder); const dataActiveWorkOrder = asRecord(evData.activeWorkOrder); @@ -244,8 +410,127 @@ export async function POST(req: Request) { created.push({ id: row.id, ts: row.ts, eventType: row.eventType }); + if (evReason) { + const inferredKind: ReasonCatalogKind = + String(evReason.type ?? "").toLowerCase() === "scrap" || finalType === "scrap-manual-entry" + ? "scrap" + : "downtime"; + const resolved = resolveReason(evReason, inferredKind, reasonCatalog, reasonCatalog.version); + + if (resolved.reasonCode) { + const reasonId = + clampText(evReason.reasonId, 128) ?? + (inferredKind === "downtime" + ? `evt:${machine.id}:downtime:${clampText(evReason.incidentKey ?? evDowntime?.incidentKey, 128) ?? row.id}` + : `evt:${machine.id}:scrap:${clampText(evReason.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(evReason.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(evReason.incidentKey ?? evDowntime?.incidentKey, 128) ?? row.id; + const durationSeconds = + numberFrom(evDowntime?.durationSeconds) ?? + numberFrom(evData.stoppage_duration_seconds) ?? + numberFrom(evData.stop_duration_seconds) ?? + null; + const episodeEndTsMs = + 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(evReason.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(evReason.scrapUnit, 16) ?? null, + ...commonWrite, + }, + update: { + kind: "scrap", + scrapEntryId, + scrapQty, + scrapUnit: clampText(evReason.scrapUnit, 16) ?? null, + ...commonWrite, + }, + }); + } + } + } + try { - await evaluateAlertsForEvent(row.id); + if (row.eventType !== "downtime-acknowledged" && row.eventType !== "scrap-manual-entry") { + await evaluateAlertsForEvent(row.id); + } } catch (err) { console.error("[alerts] evaluation failed", err); } diff --git a/app/api/ingest/kpi/route.ts b/app/api/ingest/kpi/route.ts index dabfbc2..fb38bc8 100644 --- a/app/api/ingest/kpi/route.ts +++ b/app/api/ingest/kpi/route.ts @@ -4,6 +4,7 @@ import { prisma } from "@/lib/prisma"; import { getMachineAuth } from "@/lib/machineAuthCache"; import { normalizeSnapshotV1 } from "@/lib/contracts/v1"; import { toJsonValue } from "@/lib/prismaJson"; +import { logLine } from "@/lib/logger"; function getClientIp(req: Request) { const xf = req.headers.get("x-forwarded-for"); @@ -21,11 +22,68 @@ function parseSeqToBigInt(seq: unknown): bigint | null { return null; } +function asRecord(value: unknown): Record | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + return value as Record; +} + +function readPath(root: unknown, path: string[]): unknown { + let current = root; + for (const key of path) { + const record = asRecord(current); + if (!record) return undefined; + current = record[key]; + } + return current; +} + +function collectQualityTrace(params: { + rawBody: unknown; + normalizedKpis: Record | null; + persistedQuality: number | null; + machineId: string; + rowId: string; +}) { + const { rawBody, normalizedKpis, persistedQuality, machineId, rowId } = params; + const candidates = [ + "kpis.quality", + "payload.kpis.quality", + "kpi_snapshot.quality", + "quality", + "payload.quality", + ] as const; + + const rawQualityCandidates: Record = {}; + for (const path of candidates) { + const value = readPath(rawBody, path.split(".")); + rawQualityCandidates[path] = { + type: value === null ? "null" : typeof value, + value, + }; + } + + const normalizedQuality = normalizedKpis?.quality; + return { + machineId, + rowId, + rawQualityCandidates, + normalizedQuality: { + type: normalizedQuality === null ? "null" : typeof normalizedQuality, + value: normalizedQuality ?? null, + }, + persistedQuality: { + type: persistedQuality === null ? "null" : typeof persistedQuality, + value: persistedQuality, + }, + }; +} + export async function POST(req: Request) { const endpoint = "/api/ingest/kpi"; const startedAt = Date.now(); const ip = getClientIp(req); const userAgent = req.headers.get("user-agent"); + const traceEnabled = process.env.TRACE_KPI_INGEST === "1" || req.headers.get("x-debug-ingest") === "1"; let rawBody: unknown = null; let orgId: string | null = null; @@ -182,11 +240,33 @@ export async function POST(req: Request) { }, }); + const trace = collectQualityTrace({ + rawBody, + normalizedKpis: asRecord(k), + persistedQuality: row.quality ?? null, + machineId: machine.id, + rowId: row.id, + }); + if (traceEnabled) { + logLine("ingest.kpi.trace", { + endpoint, + machineId: machine.id, + orgId, + schemaVersion, + seq: seq != null ? seq.toString() : null, + ip, + userAgent, + trace, + rawBody: toJsonValue(rawBody), + }); + } + return NextResponse.json({ ok: true, id: row.id, tsDevice: row.ts, tsServer: row.tsServer, + trace: traceEnabled ? trace : undefined, }); } catch (err: unknown) { const msg = err instanceof Error ? err.message : "Unknown error"; diff --git a/app/api/machines/[machineId]/route.ts b/app/api/machines/[machineId]/route.ts index 7c902bd..9ae57fe 100644 --- a/app/api/machines/[machineId]/route.ts +++ b/app/api/machines/[machineId]/route.ts @@ -1,9 +1,11 @@ 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(); @@ -29,10 +31,63 @@ function isPlainObject(value: unknown): value is Record { } 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` + 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) { @@ -158,25 +213,15 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ mach const eventWindowStart = new Date(Date.now() - eventsWindowSec * 1000); const criticalSeverities = ["critical", "error", "high"]; - const eventWhere = { + const eventWhereBase = { orgId: session.orgId, machineId, ts: { gte: eventWindowStart }, - eventType: { in: Array.from(ALLOWED_EVENT_TYPES) }, - ...(eventsMode === "critical" - ? { - OR: [ - { eventType: "macrostop" }, - { requiresAck: true }, - { severity: { in: criticalSeverities } }, - ], - } - : {}), }; const [rawEvents, eventsCountAll] = await Promise.all([ prisma.machineEvent.findMany({ - where: eventWhere, + where: eventWhereBase, orderBy: { ts: "desc" }, take: eventsOnly ? 300 : 120, select: { @@ -192,15 +237,29 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ mach workOrderId: true, }, }), - prisma.machineEvent.count({ where: eventWhere }), + 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(); - const deduped = normalized.filter((event) => { + const deduped = filtered.filter((event) => { const key = `${event.eventType}-${event.ts ?? ""}-${event.title}`; if (seen.has(key)) return false; seen.add(key); @@ -249,25 +308,185 @@ export async function DELETE(_req: Request, { params }: { params: Promise<{ mach return NextResponse.json({ ok: false, error: "Forbidden" }, { status: 403 }); } - const result = await prisma.$transaction(async (tx) => { - await tx.machineCycle.deleteMany({ - where: { + 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, + }); - return tx.machine.deleteMany({ - where: { - id: machineId, - orgId: session.orgId, - }, - }); - }); + 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; + } - if (result.count === 0) { - return NextResponse.json({ ok: false, error: "Machine not found" }, { status: 404 }); + 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: true }); + return NextResponse.json({ ok: false, error: "Delete failed", code: "DELETE_RETRY_EXHAUSTED" }, { status: 500 }); } diff --git a/app/api/machines/route.ts b/app/api/machines/route.ts index 44c4e4a..97f1f54 100644 --- a/app/api/machines/route.ts +++ b/app/api/machines/route.ts @@ -1,11 +1,25 @@ import { NextResponse } from "next/server"; import { randomBytes } from "crypto"; import { prisma } from "@/lib/prisma"; -import { cookies } from "next/headers"; 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"; -const COOKIE_NAME = "mis_session"; +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), @@ -13,72 +27,66 @@ const createMachineSchema = z.object({ location: z.string().trim().max(80).optional(), }); -async function requireSession() { - const sessionId = (await cookies()).get(COOKIE_NAME)?.value; - if (!sessionId) return null; +export async function GET(req: Request) { + const perfEnabled = PERF_LOGS_ENABLED; + const totalStart = nowMs(); + const timings: Record = {}; + const { coldStart, uptimeMs } = getColdStartInfo(); + const url = new URL(req.url); + const includeKpi = url.searchParams.get("includeKpi") === "1"; - const session = await prisma.session.findFirst({ - where: { id: sessionId, revokedAt: null, expiresAt: { gt: new Date() } }, - include: { org: true, user: true }, - }); - - if (!session || !session.user?.isActive || !session.user?.emailVerifiedAt) { - return null; - } - - return session; -} - -export async function GET() { + 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 machines = await prisma.machine.findMany({ - where: { orgId: session.orgId }, - orderBy: { createdAt: "desc" }, - 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, - }, - }, - }, - }); + 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> = []; + 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 = machines.map((m) => ({ - ...m, - latestHeartbeat: m.heartbeats[0] ?? null, - latestKpi: m.kpiSnapshots[0] ?? null, - heartbeats: undefined, - kpiSnapshots: undefined, - })); + const out = mergeMachineOverviewRows({ + machines, + heartbeats, + kpis, + includeKpi, + }); - return NextResponse.json({ ok: true, machines: out }); + 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) { diff --git a/app/api/overview/route.ts b/app/api/overview/route.ts index 1e2e528..f2afeea 100644 --- a/app/api/overview/route.ts +++ b/app/api/overview/route.ts @@ -4,23 +4,72 @@ import { createHash } from "crypto"; import { prisma } from "@/lib/prisma"; import { requireSession } from "@/lib/auth/requireSession"; import { getOverviewData } from "@/lib/overview/getOverviewData"; +import { getOverviewSummary } from "@/lib/overview/getOverviewSummary"; +import { logLine } from "@/lib/logger"; +import { elapsedMs, formatServerTiming, nowMs, PERF_LOGS_ENABLED } from "@/lib/perf/serverTiming"; + +let overviewColdStart = true; + +function getColdStartInfo() { + const coldStart = overviewColdStart; + overviewColdStart = false; + return { coldStart, uptimeMs: Math.round(process.uptime() * 1000) }; +} function toMs(value?: Date | null) { return value ? value.getTime() : 0; } export async function GET(req: NextRequest) { + const perfEnabled = PERF_LOGS_ENABLED; + const totalStart = nowMs(); + const timings: Record = {}; + const { coldStart, uptimeMs } = getColdStartInfo(); + + 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 url = new URL(req.url); + const detail = url.searchParams.get("detail") === "1"; + + if (!detail) { + const summaryStart = nowMs(); + const { machines: machineRows } = await getOverviewSummary({ orgId: session.orgId }); + if (perfEnabled) timings.summary = elapsedMs(summaryStart); + + const payload = { ok: true, machines: machineRows, events: [] }; + const responseHeaders = new Headers(); + if (perfEnabled) { + timings.total = elapsedMs(totalStart); + responseHeaders.set("Server-Timing", formatServerTiming(timings)); + const payloadBytes = Buffer.byteLength(JSON.stringify(payload)); + logLine("perf.overview.api", { + orgId: session.orgId, + detail: false, + coldStart, + uptimeMs, + timings, + counts: { machines: machineRows.length, events: 0 }, + payloadBytes, + }); + } + + return NextResponse.json(payload, { headers: responseHeaders }); + } + + const preQueryStart = nowMs(); const eventsMode = url.searchParams.get("events") ?? "critical"; const eventsWindowSecRaw = Number(url.searchParams.get("eventsWindowSec") ?? "21600"); const eventsWindowSec = Number.isFinite(eventsWindowSecRaw) ? eventsWindowSecRaw : 21600; const eventMachinesRaw = Number(url.searchParams.get("eventMachines") ?? "6"); const eventMachines = Number.isFinite(eventMachinesRaw) ? Math.max(1, eventMachinesRaw) : 6; + if (perfEnabled) timings.preQuery = elapsedMs(preQueryStart); + + const aggStart = nowMs(); const [machineAgg, heartbeatAgg, kpiAgg, eventAgg, orgSettings] = await Promise.all([ prisma.machine.aggregate({ where: { orgId: session.orgId }, @@ -43,6 +92,7 @@ export async function GET(req: NextRequest) { select: { updatedAt: true, stoppageMultiplier: true, macroStoppageMultiplier: true }, }), ]); + if (perfEnabled) timings.agg = elapsedMs(aggStart); const lastModifiedMs = Math.max( toMs(machineAgg._max.updatedAt), @@ -86,6 +136,7 @@ export async function GET(req: NextRequest) { } } + const dataStart = nowMs(); const { machines: machineRows, events } = await getOverviewData({ orgId: session.orgId, eventsMode, @@ -93,9 +144,29 @@ export async function GET(req: NextRequest) { eventMachines, orgSettings, }); + if (perfEnabled) timings.data = elapsedMs(dataStart); - return NextResponse.json( - { ok: true, machines: machineRows, events }, - { headers: responseHeaders } - ); + const postQueryStart = nowMs(); + + const payload = { ok: true, machines: machineRows, events }; + 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.overview.api", { + orgId: session.orgId, + detail: true, + coldStart, + uptimeMs, + eventsMode, + eventsWindowSec, + eventMachines, + timings, + counts: { machines: machineRows.length, events: events.length }, + payloadBytes, + }); + } + + return NextResponse.json(payload, { headers: responseHeaders }); } diff --git a/app/api/reasons/catalog/route.ts b/app/api/reasons/catalog/route.ts new file mode 100644 index 0000000..8b8da07 --- /dev/null +++ b/app/api/reasons/catalog/route.ts @@ -0,0 +1,48 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { requireSession } from "@/lib/auth/requireSession"; +import { + flattenReasonCatalog, + loadFallbackReasonCatalog, + normalizeReasonCatalog, + type ReasonCatalogKind, +} from "@/lib/reasonCatalog"; + +function asKind(value: string | null): ReasonCatalogKind | null { + const kind = String(value ?? "").toLowerCase(); + if (kind === "downtime" || kind === "scrap") return kind; + return null; +} + +export async function GET(req: Request) { + const session = await requireSession(); + if (!session) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 }); + + const url = new URL(req.url); + const kind = asKind(url.searchParams.get("kind")); + if (!kind) { + return NextResponse.json({ ok: false, error: "Invalid kind (downtime|scrap)" }, { status: 400 }); + } + + const orgSettings = await prisma.orgSettings.findUnique({ + where: { orgId: session.orgId }, + select: { defaultsJson: true }, + }); + const defaultsJson = + orgSettings?.defaultsJson && typeof orgSettings.defaultsJson === "object" && !Array.isArray(orgSettings.defaultsJson) + ? (orgSettings.defaultsJson as Record) + : {}; + const settingsCatalog = normalizeReasonCatalog(defaultsJson.reasonCatalog ?? defaultsJson.reasonCatalogData); + const fallbackCatalog = await loadFallbackReasonCatalog(); + const catalog = settingsCatalog ?? fallbackCatalog; + const rows = flattenReasonCatalog(catalog, kind); + + return NextResponse.json({ + ok: true, + source: settingsCatalog ? "settings" : "fallback", + kind, + catalogVersion: catalog.version, + categories: catalog[kind], + rows, + }); +} diff --git a/app/api/reports/filters/route.ts b/app/api/reports/filters/route.ts index 104f88f..0c7bc4f 100644 --- a/app/api/reports/filters/route.ts +++ b/app/api/reports/filters/route.ts @@ -2,6 +2,16 @@ import { NextResponse } from "next/server"; import type { NextRequest } from "next/server"; import { prisma } from "@/lib/prisma"; import { requireSession } from "@/lib/auth/requireSession"; +import { logLine } from "@/lib/logger"; +import { elapsedMs, formatServerTiming, nowMs, PERF_LOGS_ENABLED } from "@/lib/perf/serverTiming"; + +let reportsFiltersColdStart = true; + +function getColdStartInfo() { + const coldStart = reportsFiltersColdStart; + reportsFiltersColdStart = false; + return { coldStart, uptimeMs: Math.round(process.uptime() * 1000) }; +} const RANGE_MS: Record = { "24h": 24 * 60 * 60 * 1000, @@ -33,10 +43,19 @@ function pickRange(req: NextRequest) { } export async function GET(req: NextRequest) { + const perfEnabled = PERF_LOGS_ENABLED; + const totalStart = nowMs(); + const timings: Record = {}; + const { coldStart, uptimeMs } = getColdStartInfo(); + + 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 url = new URL(req.url); + const range = url.searchParams.get("range") ?? "24h"; const machineId = url.searchParams.get("machineId") ?? undefined; const { start, end } = pickRange(req); @@ -46,20 +65,51 @@ export async function GET(req: NextRequest) { ts: { gte: start, lte: end }, }; + if (perfEnabled) timings.preQuery = elapsedMs(preQueryStart); + + const workOrdersStart = nowMs(); const workOrderRows = await prisma.machineCycle.findMany({ where: { ...baseWhere, workOrderId: { not: null } }, distinct: ["workOrderId"], select: { workOrderId: true }, }); + if (perfEnabled) timings.workOrders = elapsedMs(workOrdersStart); + const skuStart = nowMs(); const skuRows = await prisma.machineCycle.findMany({ where: { ...baseWhere, sku: { not: null } }, distinct: ["sku"], select: { sku: true }, }); + if (perfEnabled) timings.skus = elapsedMs(skuStart); + + const postQueryStart = nowMs(); const workOrders = workOrderRows.map((r) => r.workOrderId).filter(Boolean) as string[]; const skus = skuRows.map((r) => r.sku).filter(Boolean) as string[]; - return NextResponse.json({ ok: true, workOrders, skus }); + const payload = { ok: true, workOrders, skus }; + + 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.reports.filters", { + orgId: session.orgId, + coldStart, + uptimeMs, + range, + machineId, + timings, + rowCounts: { + workOrderRows: workOrderRows.length, + skuRows: skuRows.length, + }, + payloadBytes, + }); + } + + return NextResponse.json(payload, { headers: responseHeaders }); } diff --git a/app/api/reports/route.ts b/app/api/reports/route.ts index 2566c5b..5fd95c3 100644 --- a/app/api/reports/route.ts +++ b/app/api/reports/route.ts @@ -2,6 +2,16 @@ import { NextResponse } from "next/server"; import type { NextRequest } from "next/server"; import { prisma } from "@/lib/prisma"; import { requireSession } from "@/lib/auth/requireSession"; +import { logLine } from "@/lib/logger"; +import { elapsedMs, formatServerTiming, nowMs, PERF_LOGS_ENABLED } from "@/lib/perf/serverTiming"; + +let reportsColdStart = true; + +function getColdStartInfo() { + const coldStart = reportsColdStart; + reportsColdStart = false; + return { coldStart, uptimeMs: Math.round(process.uptime() * 1000) }; +} const RANGE_MS: Record = { "24h": 24 * 60 * 60 * 1000, @@ -37,10 +47,19 @@ function safeNum(v: unknown) { } export async function GET(req: NextRequest) { + const perfEnabled = PERF_LOGS_ENABLED; + const totalStart = nowMs(); + const timings: Record = {}; + const { coldStart, uptimeMs } = getColdStartInfo(); + + 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 url = new URL(req.url); + const range = url.searchParams.get("range") ?? "24h"; const machineId = url.searchParams.get("machineId") ?? undefined; const { start, end } = pickRange(req); const workOrderId = url.searchParams.get("workOrderId") ?? undefined; @@ -52,6 +71,9 @@ export async function GET(req: NextRequest) { ...(sku ? { sku } : {}), }; + if (perfEnabled) timings.preQuery = elapsedMs(preQueryStart); + + const kpiStart = nowMs(); const kpiRows = await prisma.machineKpiSnapshot.findMany({ where: { ...baseWhere, ts: { gte: start, lte: end } }, orderBy: { ts: "asc" }, @@ -67,6 +89,7 @@ export async function GET(req: NextRequest) { machineId: true, }, }); + if (perfEnabled) timings.kpiRows = elapsedMs(kpiStart); let oeeSum = 0; let oeeCount = 0; @@ -96,10 +119,12 @@ export async function GET(req: NextRequest) { } } + const cyclesStart = nowMs(); const cycles = await prisma.machineCycle.findMany({ where: { ...baseWhere, ts: { gte: start, lte: end } }, select: { goodDelta: true, scrapDelta: true }, }); + if (perfEnabled) timings.cycles = elapsedMs(cyclesStart); let goodTotal = 0; let scrapTotal = 0; @@ -109,6 +134,7 @@ export async function GET(req: NextRequest) { if (safeNum(c.scrapDelta) != null) scrapTotal += Number(c.scrapDelta); } + const kpiAggStart = nowMs(); const kpiAgg = await prisma.machineKpiSnapshot.groupBy({ by: ["machineId"], where: { ...baseWhere, ts: { gte: start, lte: end } }, @@ -116,6 +142,7 @@ export async function GET(req: NextRequest) { _min: { good: true, scrap: true }, _count: { _all: true }, }); + if (perfEnabled) timings.kpiAgg = elapsedMs(kpiAggStart); let targetTotal = 0; if (goodTotal === 0 && scrapTotal === 0) { @@ -151,10 +178,12 @@ export async function GET(req: NextRequest) { if (maxTarget != null) targetTotal += maxTarget; } + const eventsStart = nowMs(); const events = await prisma.machineEvent.findMany({ where: { ...baseWhere, ts: { gte: start, lte: end } }, select: { eventType: true, data: true }, }); + if (perfEnabled) timings.events = elapsedMs(eventsStart); let macrostopSec = 0; let microstopSec = 0; @@ -223,10 +252,12 @@ export async function GET(req: NextRequest) { trend.scrapRate.push({ t, v: (scrap / (good + scrap)) * 100 }); } } + const cycleRowsStart = nowMs(); const cycleRows = await prisma.machineCycle.findMany({ where: { ...baseWhere, ts: { gte: start, lte: end } }, select: { actualCycleTime: true }, }); + if (perfEnabled) timings.cycleRows = elapsedMs(cycleRowsStart); const values = cycleRows .map((c) => Number(c.actualCycleTime)) @@ -310,10 +341,14 @@ export async function GET(req: NextRequest) { const scrapBySku = new Map(); const scrapByWo = new Map(); + const scrapRowsStart = nowMs(); const scrapRows = await prisma.machineCycle.findMany({ where: { ...baseWhere, ts: { gte: start, lte: end } }, select: { sku: true, workOrderId: true, scrapDelta: true }, }); + if (perfEnabled) timings.scrapRows = elapsedMs(scrapRowsStart); + + const postQueryStart = nowMs(); for (const row of scrapRows) { const scrap = safeNum(row.scrapDelta); @@ -340,20 +375,20 @@ export async function GET(req: NextRequest) { - return NextResponse.json({ + const payload = { ok: true, summary: { - oeeAvg, - availabilityAvg, - performanceAvg, - qualityAvg, - goodTotal, - scrapTotal, - targetTotal, - scrapRate, - topScrapSku, - topScrapWorkOrder, - }, + oeeAvg, + availabilityAvg, + performanceAvg, + qualityAvg, + goodTotal, + scrapTotal, + targetTotal, + scrapRate, + topScrapSku, + topScrapWorkOrder, + }, downtime: { macrostopSec, @@ -365,9 +400,36 @@ export async function GET(req: NextRequest) { }, trend, insights, - distribution: { - cycleTime: cycleTimeBins + distribution: { + cycleTime: cycleTimeBins, }, + }; - }); + 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.reports.api", { + orgId: session.orgId, + coldStart, + uptimeMs, + range, + machineId, + workOrderId, + sku, + timings, + rowCounts: { + kpiRows: kpiRows.length, + cycles: cycles.length, + events: events.length, + cycleRows: cycleRows.length, + scrapRows: scrapRows.length, + }, + payloadBytes, + }); + } + + return NextResponse.json(payload, { headers: responseHeaders }); } diff --git a/app/api/settings/machines/[machineId]/route.ts b/app/api/settings/machines/[machineId]/route.ts index 55e3cd3..4dc139f 100644 --- a/app/api/settings/machines/[machineId]/route.ts +++ b/app/api/settings/machines/[machineId]/route.ts @@ -14,8 +14,10 @@ import { validateDefaults, validateShiftFields, validateShiftSchedule, + validateShiftOverrides, validateThresholds, } from "@/lib/settings"; +import { loadFallbackReasonCatalog, normalizeReasonCatalog, type ReasonCatalog } from "@/lib/reasonCatalog"; import { publishSettingsUpdate } from "@/lib/mqtt"; import { z } from "zod"; @@ -44,6 +46,24 @@ function pickAllowedOverrides(raw: unknown) { return out; } +function withReasonCatalog>(payload: T, fallbackCatalog: ReasonCatalog) { + const base = (isPlainObject(payload) ? { ...payload } : {}) as T; + const defaults = isPlainObject(base.defaults) ? base.defaults : {}; + const parsed = + normalizeReasonCatalog(base.reasonCatalog) ?? + normalizeReasonCatalog(base.reasonCatalogData) ?? + normalizeReasonCatalog(defaults.reasonCatalog) ?? + normalizeReasonCatalog(defaults.reasonCatalogData) ?? + fallbackCatalog; + + return { + ...base, + reasonCatalog: parsed, + reasonCatalogData: parsed, + reasonCatalogVersion: Number(parsed.version || 1), + }; +} + async function ensureOrgSettings( tx: Prisma.TransactionClient, orgId: string, @@ -144,6 +164,7 @@ export async function GET( if (!machine) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 }); orgId = machine.orgId; } + const fallbackCatalog = await loadFallbackReasonCatalog(); const { settings, overrides } = await prisma.$transaction(async (tx) => { const orgSettings = await ensureOrgSettings(tx, orgId as string, userId); @@ -154,9 +175,15 @@ export async function GET( select: { overridesJson: true }, }); - const orgPayload = buildSettingsPayload(orgSettings.settings, orgSettings.shifts ?? []); + const orgPayload = withReasonCatalog( + buildSettingsPayload(orgSettings.settings, orgSettings.shifts ?? []), + fallbackCatalog + ); const rawOverrides = pickAllowedOverrides(machineSettings?.overridesJson ?? {}); - const effective = deepMerge(orgPayload, rawOverrides); + const effective = withReasonCatalog( + deepMerge(orgPayload, rawOverrides) as Record, + fallbackCatalog + ); return { settings: { org: orgPayload, effective }, overrides: rawOverrides }; }); @@ -242,6 +269,14 @@ export async function PUT( return NextResponse.json({ ok: false, error: shiftValidation.error }, { status: 400 }); } + const overridesResult = + patch?.shiftSchedule?.overrides !== undefined + ? validateShiftOverrides(patch.shiftSchedule.overrides) + : ({ ok: true, overrides: undefined } as const); + if (!overridesResult.ok) { + return NextResponse.json({ ok: false, error: overridesResult.error }, { status: 400 }); + } + const thresholdsValidation = validateThresholds(patch?.thresholds); if (!thresholdsValidation.ok) { return NextResponse.json({ ok: false, error: thresholdsValidation.error }, { status: 400 }); @@ -275,6 +310,12 @@ export async function PUT( ...patch, shiftSchedule: { ...patch.shiftSchedule, + overrides: + patch.shiftSchedule.overrides !== undefined + ? overridesResult.overrides === null + ? null + : overridesResult.overrides + : patch.shiftSchedule.overrides, shiftChangeCompensationMin: patch.shiftSchedule.shiftChangeCompensationMin !== undefined ? Number(patch.shiftSchedule.shiftChangeCompensationMin) @@ -372,9 +413,16 @@ export async function PUT( }, }); - const orgPayload = buildSettingsPayload(orgSettings.settings, orgSettings.shifts ?? []); + const fallbackCatalog = await loadFallbackReasonCatalog(); + const orgPayload = withReasonCatalog( + buildSettingsPayload(orgSettings.settings, orgSettings.shifts ?? []), + fallbackCatalog + ); const overrides = pickAllowedOverrides(saved.overridesJson ?? {}); - const effective = deepMerge(orgPayload, overrides); + const effective = withReasonCatalog( + deepMerge(orgPayload, overrides) as Record, + fallbackCatalog + ); return { orgPayload, diff --git a/app/api/settings/route.ts b/app/api/settings/route.ts index 42d4e55..4ee54c8 100644 --- a/app/api/settings/route.ts +++ b/app/api/settings/route.ts @@ -1,4 +1,7 @@ import { NextResponse } from "next/server"; +import type { NextRequest } from "next/server"; +import { createHash } from "crypto"; +import { revalidateTag, unstable_cache } from "next/cache"; import { Prisma } from "@prisma/client"; import { prisma } from "@/lib/prisma"; import { requireSession } from "@/lib/auth/requireSession"; @@ -13,8 +16,10 @@ import { validateDefaults, validateShiftFields, validateShiftSchedule, + validateShiftOverrides, validateThresholds, } from "@/lib/settings"; +import { loadFallbackReasonCatalog, normalizeReasonCatalog, type ReasonCatalog } from "@/lib/reasonCatalog"; import { publishSettingsUpdate } from "@/lib/mqtt"; import { z } from "zod"; @@ -34,6 +39,24 @@ function canManageSettings(role?: string | null) { return role === "OWNER" || role === "ADMIN"; } +function withReasonCatalog>(payload: T, fallbackCatalog: ReasonCatalog) { + const base = (isPlainObject(payload) ? { ...payload } : {}) as T; + const defaults = isPlainObject(base.defaults) ? base.defaults : {}; + const parsed = + normalizeReasonCatalog(base.reasonCatalog) ?? + normalizeReasonCatalog(base.reasonCatalogData) ?? + normalizeReasonCatalog(defaults.reasonCatalog) ?? + normalizeReasonCatalog(defaults.reasonCatalogData) ?? + fallbackCatalog; + + return { + ...base, + reasonCatalog: parsed, + reasonCatalogData: parsed, + reasonCatalogVersion: Number(parsed.version || 1), + }; +} + const settingsPayloadSchema = z .object({ source: z.string().trim().max(40).optional(), @@ -43,10 +66,14 @@ const settingsPayloadSchema = z thresholds: z.any().optional(), alerts: z.any().optional(), defaults: z.any().optional(), + reasonCatalog: z.any().optional(), version: z.union([z.number(), z.string()]).optional(), }) .passthrough(); +const SETTINGS_TTL_SEC = 10; +const SETTINGS_SWR_SEC = 30; + async function ensureOrgSettings(tx: Prisma.TransactionClient, orgId: string, userId: string) { let settings = await tx.orgSettings.findUnique({ where: { orgId }, @@ -111,24 +138,56 @@ async function ensureOrgSettings(tx: Prisma.TransactionClient, orgId: string, us return { settings, shifts }; } -export async function GET() { +async function loadSettingsPayload(orgId: string, userId: string) { + const loaded = await prisma.$transaction(async (tx) => { + const found = await ensureOrgSettings(tx, orgId, userId); + if (!found?.settings) throw new Error("SETTINGS_NOT_FOUND"); + return found; + }); + + const fallbackCatalog = await loadFallbackReasonCatalog(); + const payload = withReasonCatalog(buildSettingsPayload(loaded.settings, loaded.shifts ?? []), fallbackCatalog); + const defaultsRaw = isPlainObject(loaded.settings.defaultsJson) ? (loaded.settings.defaultsJson as any) : {}; + const modulesRaw = isPlainObject(defaultsRaw.modules) ? defaultsRaw.modules : {}; + const modules = { screenlessMode: modulesRaw.screenlessMode === true }; + + return { payload, modules }; +} + +async function loadSettingsCached(orgId: string, userId: string) { + const cached = unstable_cache( + () => loadSettingsPayload(orgId, userId), + ["settings", orgId], + { revalidate: SETTINGS_TTL_SEC, tags: [`settings:${orgId}`] } + ); + return cached(); +} + +export async function GET(req: NextRequest) { const session = await requireSession(); if (!session) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 }); try { - const loaded = await prisma.$transaction(async (tx) => { - const found = await ensureOrgSettings(tx, session.orgId, session.userId); - if (!found?.settings) throw new Error("SETTINGS_NOT_FOUND"); - return found; + const url = new URL(req.url); + const refresh = url.searchParams.get("refresh") === "1"; + const { payload, modules } = refresh + ? await loadSettingsPayload(session.orgId, session.userId) + : await loadSettingsCached(session.orgId, session.userId); + + const version = payload.version ?? 0; + const etag = `W/"${createHash("sha1").update(`${session.orgId}:${version}`).digest("hex")}"`; + const responseHeaders = new Headers({ + "Cache-Control": `private, max-age=${SETTINGS_TTL_SEC}, stale-while-revalidate=${SETTINGS_SWR_SEC}`, + ETag: etag, + Vary: "Cookie", }); - const payload = buildSettingsPayload(loaded.settings, loaded.shifts ?? []); + const ifNoneMatch = req.headers.get("if-none-match"); + if (!refresh && ifNoneMatch && ifNoneMatch === etag) { + return new NextResponse(null, { status: 304, headers: responseHeaders }); + } - const defaultsRaw = isPlainObject(loaded.settings.defaultsJson) ? (loaded.settings.defaultsJson as any) : {}; - const modulesRaw = isPlainObject(defaultsRaw.modules) ? defaultsRaw.modules : {}; - const modules = { screenlessMode: modulesRaw.screenlessMode === true }; - - return NextResponse.json({ ok: true, settings: { ...payload, modules } }); + return NextResponse.json({ ok: true, settings: { ...payload, modules } }, { headers: responseHeaders }); } catch (err) { console.error("[settings GET] failed", err); @@ -162,6 +221,7 @@ export async function PUT(req: Request) { const thresholds = parsed.data.thresholds; const alerts = parsed.data.alerts; const defaults = parsed.data.defaults; + const reasonCatalogRaw = parsed.data.reasonCatalog; const expectedVersion = parsed.data.version; const modules = parsed.data.modules; @@ -173,6 +233,7 @@ export async function PUT(req: Request) { thresholds === undefined && alerts === undefined && defaults === undefined && + reasonCatalogRaw === undefined && modules === undefined ) { @@ -191,6 +252,13 @@ export async function PUT(req: Request) { if (defaults !== undefined && !isPlainObject(defaults)) { 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)) { return NextResponse.json({ ok: false, error: "Invalid modules payload" }, { status: 400 }); } @@ -210,6 +278,14 @@ export async function PUT(req: Request) { return NextResponse.json({ ok: false, error: shiftValidation.error }, { status: 400 }); } + const overridesResult = + shiftSchedule?.overrides !== undefined + ? validateShiftOverrides(shiftSchedule.overrides) + : ({ ok: true, overrides: undefined } as const); + if (!overridesResult.ok) { + return NextResponse.json({ ok: false, error: overridesResult.error }, { status: 400 }); + } + const thresholdsValidation = validateThresholds(thresholds); if (!thresholdsValidation.ok) { return NextResponse.json({ ok: false, error: thresholdsValidation.error }, { status: 400 }); @@ -257,12 +333,22 @@ export async function PUT(req: Request) { : { ...currentModulesRaw, screenlessMode }; // Write defaultsJson if either defaults changed OR modules changed - const shouldWriteDefaultsJson = !!nextDefaultsCore || screenlessMode !== undefined; + const shouldWriteDefaultsJson = + !!nextDefaultsCore || screenlessMode !== undefined || reasonCatalogRaw !== undefined; const nextDefaultsJson = shouldWriteDefaultsJson ? { ...(nextDefaultsCore ?? normalizeDefaults(currentDefaultsRaw)), modules: nextModules } : undefined; + if (nextDefaultsJson && reasonCatalogRaw !== undefined) { + const defaultsTarget = nextDefaultsJson as Record; + if (nextReasonCatalog === null) { + delete defaultsTarget.reasonCatalog; + } else if (nextReasonCatalog) { + defaultsTarget.reasonCatalog = nextReasonCatalog; + } + } + const updateData = stripUndefined({ timezone: timezone !== undefined ? String(timezone) : undefined, @@ -272,6 +358,12 @@ export async function PUT(req: Request) { : undefined, lunchBreakMin: shiftSchedule?.lunchBreakMin !== undefined ? Number(shiftSchedule.lunchBreakMin) : undefined, + shiftScheduleOverridesJson: + shiftSchedule?.overrides !== undefined + ? overridesResult.overrides === null + ? null + : overridesResult.overrides + : undefined, stoppageMultiplier: thresholds?.stoppageMultiplier !== undefined ? Number(thresholds.stoppageMultiplier) : undefined, macroStoppageMultiplier: @@ -373,6 +465,8 @@ export async function PUT(req: Request) { const modulesRaw = isPlainObject(defaultsRaw.modules) ? defaultsRaw.modules : {}; const modulesOut = { screenlessMode: modulesRaw.screenlessMode === true }; + revalidateTag(`settings:${session.orgId}`, { expire: 0 }); + return NextResponse.json({ ok: true, settings: { ...payload, modules: modulesOut } }); } catch (err) { diff --git a/app/global-error.tsx b/app/global-error.tsx new file mode 100644 index 0000000..5b7b934 --- /dev/null +++ b/app/global-error.tsx @@ -0,0 +1,37 @@ +"use client"; + +export default function GlobalError({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + return ( + + +
+

Something went wrong

+

+ An unexpected error occurred. Please try again. +

+ +
+ + + ); +} diff --git a/app/invite/[token]/page.tsx b/app/invite/[token]/page.tsx index 8554941..50db505 100644 --- a/app/invite/[token]/page.tsx +++ b/app/invite/[token]/page.tsx @@ -3,14 +3,14 @@ import { redirect } from "next/navigation"; import { prisma } from "@/lib/prisma"; import InviteAcceptForm from "./InviteAcceptForm"; -export default async function InvitePage({ params }: { params: { token: string } | Promise<{ token: string }> }) { +export default async function InvitePage({ params }: { params: Promise<{ token: string }> }) { const session = (await cookies()).get("mis_session")?.value; if (session) { redirect("/machines"); } - const resolvedParams = await Promise.resolve(params); - const token = String(resolvedParams?.token || "").trim().toLowerCase(); + const { token: rawToken } = await params; + const token = String(rawToken || "").trim().toLowerCase(); let invite = null; let error: string | null = null; diff --git a/app/layout.tsx b/app/layout.tsx index 4da95b7..ebb32f4 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,11 +1,14 @@ import type { Metadata } from "next"; import { cookies } from "next/headers"; import "./globals.css"; +import { prisma } from "@/lib/prisma"; -export const metadata: Metadata = { - title: "MIS Control Tower", - description: "MaliounTech Industrial Suite", -}; +export async function generateMetadata(): Promise { + return { + title: "MIS Control Tower", + description: "MaliounTech Industrial Suite", + }; +} export default async function RootLayout({ children }: { children: React.ReactNode }) { const cookieJar = await cookies(); diff --git a/app/login/page.tsx b/app/login/page.tsx index cdf8413..2f06476 100644 --- a/app/login/page.tsx +++ b/app/login/page.tsx @@ -6,13 +6,14 @@ import LoginForm from "./LoginForm"; // adjust path if needed export default async function LoginPage({ searchParams, }: { - searchParams?: { next?: string }; + searchParams?: Promise<{ next?: string }>; }) { const session = (await cookies()).get("mis_session")?.value; // If already logged in, send to next or machines if (session) { - const next = searchParams?.next || "/machines"; + const params = searchParams ? await searchParams : {}; + const next = params?.next || "/machines"; redirect(next); } diff --git a/components/downtime/DowntimePageClient.tsx b/components/downtime/DowntimePageClient.tsx index 4b5a3c6..cfa9fd0 100644 --- a/components/downtime/DowntimePageClient.tsx +++ b/components/downtime/DowntimePageClient.tsx @@ -80,6 +80,24 @@ type ApiDowntimeEventsRes = { events?: ApiDowntimeEvent[]; }; +type ApiReasonCatalogRow = { + kind: "downtime" | "scrap"; + categoryId: string; + categoryLabel: string; + detailId: string; + detailLabel: string; + reasonCode: string; + reasonLabel: string; +}; + +type ApiReasonCatalogRes = { + ok: boolean; + error?: string; + kind?: "downtime" | "scrap"; + catalogVersion?: number; + rows?: ApiReasonCatalogRow[]; +}; + function fmtDT(iso: string | null) { if (!iso) return "—"; const d = new Date(iso); @@ -1155,6 +1173,8 @@ export default function DowntimePageClient() { const [eventsRes, setEventsRes] = useState(null); const [eventsLoading, setEventsLoading] = useState(false); const [eventsErr, setEventsErr] = useState(null); + const [catalogRows, setCatalogRows] = useState([]); + const [catalogErr, setCatalogErr] = useState(null); const [eventsLimit, setEventsLimit] = useState(200); const [eventsBefore, setEventsBefore] = useState(null); @@ -1251,6 +1271,41 @@ export default function DowntimePageClient() { ac.abort(); }; }, [range, machineId]); + + useEffect(() => { + let alive = true; + const ac = new AbortController(); + + async function run() { + setCatalogErr(null); + try { + const res = await fetch("/api/reasons/catalog?kind=downtime", { + cache: "no-cache", + credentials: "include", + signal: ac.signal, + }); + const json = (await res.json().catch(() => ({}))) as ApiReasonCatalogRes; + if (!alive) return; + if (!res.ok || json.ok === false) { + setCatalogRows([]); + setCatalogErr(json.error ?? "Failed to load reason catalog"); + return; + } + setCatalogRows(Array.isArray(json.rows) ? json.rows : []); + } catch (err: unknown) { + if (!alive) return; + setCatalogRows([]); + setCatalogErr(err instanceof Error ? err.message : "Network error"); + } + } + + run(); + return () => { + alive = false; + ac.abort(); + }; + }, []); + useEffect(() => { let alive = true; const ac = new AbortController(); @@ -1308,6 +1363,29 @@ export default function DowntimePageClient() { return metricRowsAll.filter((r) => r.reasonCode === reasonCode); }, [metricRowsAll, reasonCode]); + const selectedReasonLabel = useMemo(() => { + if (!reasonCode) return null; + const fromMetrics = metricRowsAll.find((row) => row.reasonCode === reasonCode)?.reasonLabel; + if (fromMetrics) return fromMetrics; + const fromCatalog = catalogRows.find((row) => row.reasonCode === reasonCode)?.reasonLabel; + return fromCatalog ?? reasonCode; + }, [catalogRows, metricRowsAll, reasonCode]); + + const catalogByCategory = useMemo(() => { + const grouped = new Map(); + for (const row of catalogRows) { + const key = row.categoryId; + const slot = grouped.get(key) ?? { categoryLabel: row.categoryLabel, rows: [] }; + slot.rows.push(row); + grouped.set(key, slot); + } + return [...grouped.entries()].map(([categoryId, value]) => ({ + categoryId, + categoryLabel: value.categoryLabel, + rows: value.rows, + })); + }, [catalogRows]); + const totalMinutes = pareto?.totalMinutesLost ?? 0; const totalStops = useMemo( () => baseRows.reduce((acc, r) => acc + (r.count ?? 0), 0), @@ -1365,6 +1443,7 @@ const filteredEvents = useMemo(() => { e.machineName ?? "", e.reasonLabel ?? "", e.reasonCode ?? "", + e.reasonText ?? "", e.workOrderId ?? "", e.episodeId ?? "", ] @@ -1467,7 +1546,7 @@ const estImpactMxn = rate > 0 ? totalDowntimeMin * rate : 0; )} {reasonCode ? ( - Reason: {reasonCode} + Reason: {selectedReasonLabel ?? reasonCode}
+
+
Downtime reason menu
+
+ From settings or `downtime_menu.md` fallback +
+ {catalogErr ? ( +
{catalogErr}
+ ) : null} +
+ {catalogByCategory.map((group) => ( +
+
{group.categoryLabel}
+
+ {group.rows.map((option) => { + const active = reasonCode === option.reasonCode; + return ( + + ); + })} +
+
+ ))} + {!catalogErr && catalogByCategory.length === 0 ? ( +
No reason menu available.
+ ) : null} +
+
+
Reason
@@ -2162,6 +2280,9 @@ const estImpactMxn = rate > 0 ? totalDowntimeMin * rate : 0;
{e.reasonLabel}
{e.reasonCode}
+ {e.reasonText && e.reasonText !== e.reasonLabel ? ( +
{e.reasonText}
+ ) : null} {e.workOrderId ?? "—"} diff --git a/components/layout/AppShell.tsx b/components/layout/AppShell.tsx index a08f882..06e5541 100644 --- a/components/layout/AppShell.tsx +++ b/components/layout/AppShell.tsx @@ -3,6 +3,7 @@ import { useEffect, useState } from "react"; import { Menu } from "lucide-react"; import { Sidebar } from "@/components/layout/Sidebar"; +import { RouteAudit } from "@/components/perf/RouteAudit"; import { UtilityControls } from "@/components/layout/UtilityControls"; import { useI18n } from "@/lib/i18n/useI18n"; @@ -31,6 +32,7 @@ export function AppShell({ return (
+
diff --git a/components/layout/Sidebar.tsx b/components/layout/Sidebar.tsx index 04960d8..f7404d6 100644 --- a/components/layout/Sidebar.tsx +++ b/components/layout/Sidebar.tsx @@ -2,12 +2,14 @@ import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; -import { useEffect, useMemo, useState } from "react"; -import { BarChart3, Bell, DollarSign, LayoutGrid, LogOut, Settings, Wrench, X } from "lucide-react"; +import { useEffect, useMemo, useState, useTransition } from "react"; +import { BarChart3, Bell, DollarSign, LayoutGrid, Loader2, LogOut, Settings, Wrench, X } from "lucide-react"; import type { LucideIcon } from "lucide-react"; import { useI18n } from "@/lib/i18n/useI18n"; import { useScreenlessMode } from "@/lib/ui/screenlessMode"; +const PERF_ENABLED = process.env.NEXT_PUBLIC_PERF_LOGS === "1"; +const NAV_MARK_KEY = "perf_nav_start"; type NavItem = { href: string; @@ -38,6 +40,8 @@ export function Sidebar({ variant = "desktop", onNavigate, onClose }: SidebarPro const router = useRouter(); const { t } = useI18n(); const { screenlessMode } = useScreenlessMode(); + const [isPending, startTransition] = useTransition(); + const [pendingHref, setPendingHref] = useState(null); const [me, setMe] = useState<{ user?: { name?: string | null; email?: string | null }; org?: { name?: string | null }; @@ -93,13 +97,33 @@ export function Sidebar({ variant = "desktop", onNavigate, onClose }: SidebarPro } }, [screenlessMode, pathname, router]); - - useEffect(() => { - visibleItems.forEach((it) => { - router.prefetch(it.href); - }); - }, [router, visibleItems]); + 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; + try { + sessionStorage.setItem( + NAV_MARK_KEY, + JSON.stringify({ + href, + from: pathname, + ts: Date.now(), + }) + ); + } catch { + // ignore + } + }; + + // Prefetch disabled: Next.js 16 has RSC prefetch bugs that can cause 404 on + // client-side navigation (see e.g. vercel/next.js#85374). Use fresh fetch on click. const shellClass = [ "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]", @@ -126,23 +150,53 @@ export function Sidebar({ variant = "desktop", onNavigate, onClose }: SidebarPro