From e705f5e9654460a833fe61126eec67a8bbb77daf Mon Sep 17 00:00:00 2001 From: Marcelo Date: Fri, 24 Apr 2026 02:01:40 +0000 Subject: [PATCH] changes --- app/(app)/overview/OverviewClient.tsx | 15 + app/(app)/recap/RecapClient.tsx | 237 +++++++ app/(app)/recap/page.tsx | 46 ++ app/(app)/reports/ReportsPageClient.tsx | 681 ++++++++++++++++++++ app/(app)/reports/page.tsx | 720 +--------------------- app/api/ingest/event/route.ts | 4 +- app/api/recap/route.ts | 29 + app/api/reports/filters/route.ts | 32 +- app/api/reports/route.ts | 52 +- components/layout/Sidebar.tsx | 137 ++-- components/recap/RecapDowntimeTop.tsx | 55 ++ components/recap/RecapKpiRow.tsx | 37 ++ components/recap/RecapMachineStatus.tsx | 46 ++ components/recap/RecapProductionBySku.tsx | 45 ++ components/recap/RecapWorkOrderStatus.tsx | 57 ++ lib/i18n/en.json | 68 +- lib/i18n/es-MX.json | 68 +- lib/recap/getRecapData.ts | 661 ++++++++++++++++++++ lib/recap/types.ts | 70 +++ 19 files changed, 2255 insertions(+), 805 deletions(-) create mode 100644 app/(app)/recap/RecapClient.tsx create mode 100644 app/(app)/recap/page.tsx create mode 100644 app/(app)/reports/ReportsPageClient.tsx create mode 100644 app/api/recap/route.ts create mode 100644 components/recap/RecapDowntimeTop.tsx create mode 100644 components/recap/RecapKpiRow.tsx create mode 100644 components/recap/RecapMachineStatus.tsx create mode 100644 components/recap/RecapProductionBySku.tsx create mode 100644 components/recap/RecapWorkOrderStatus.tsx create mode 100644 lib/recap/getRecapData.ts create mode 100644 lib/recap/types.ts diff --git a/app/(app)/overview/OverviewClient.tsx b/app/(app)/overview/OverviewClient.tsx index 96b9506..90e8188 100644 --- a/app/(app)/overview/OverviewClient.tsx +++ b/app/(app)/overview/OverviewClient.tsx @@ -232,6 +232,21 @@ export default function OverviewClient({ {loading &&
{t("overview.loading")}
} +
+
+
+
{t("overview.recap.title")}
+
{t("overview.recap.subtitle")}
+
+ + {t("overview.recap.cta")} + +
+
+
{t("overview.fleetHealth")}
diff --git a/app/(app)/recap/RecapClient.tsx b/app/(app)/recap/RecapClient.tsx new file mode 100644 index 0000000..940c01f --- /dev/null +++ b/app/(app)/recap/RecapClient.tsx @@ -0,0 +1,237 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { useI18n } from "@/lib/i18n/useI18n"; +import type { RecapMachine, RecapResponse } from "@/lib/recap/types"; +import RecapKpiRow from "@/components/recap/RecapKpiRow"; +import RecapProductionBySku from "@/components/recap/RecapProductionBySku"; +import RecapDowntimeTop from "@/components/recap/RecapDowntimeTop"; +import RecapWorkOrderStatus from "@/components/recap/RecapWorkOrderStatus"; +import RecapMachineStatus from "@/components/recap/RecapMachineStatus"; + +type Props = { + initialData: RecapResponse; + initialFilters: { + machineId: string; + shift: string; + start: string; + end: string; + }; +}; + +type RangeMode = "24h" | "shift" | "custom"; + +function toInputDate(value: string) { + if (!value) return ""; + const d = new Date(value); + const pad = (n: number) => String(n).padStart(2, "0"); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; +} + +function toMinutesLabel(minutes: number | null) { + if (minutes == null || minutes <= 0) return "0"; + return String(Math.round(minutes)); +} + +export default function RecapClient({ initialData, initialFilters }: Props) { + const { t, locale } = useI18n(); + const [data, setData] = useState(initialData); + const [machineId, setMachineId] = useState(initialFilters.machineId || ""); + const [shift, setShift] = useState(initialFilters.shift || "shift1"); + const [customStart, setCustomStart] = useState(toInputDate(initialFilters.start)); + const [customEnd, setCustomEnd] = useState(toInputDate(initialFilters.end)); + const [mode, setMode] = useState(() => { + if (initialFilters.shift) return "shift"; + if (initialFilters.start || initialFilters.end) return "custom"; + return "24h"; + }); + const [loading, setLoading] = useState(false); + + useEffect(() => { + let alive = true; + + async function load() { + setLoading(true); + const qs = new URLSearchParams(); + if (machineId) qs.set("machineId", machineId); + if (mode === "shift") qs.set("shift", shift || "shift1"); + if (mode === "custom") { + if (customStart) qs.set("start", new Date(customStart).toISOString()); + if (customEnd) qs.set("end", new Date(customEnd).toISOString()); + } + + try { + const res = await fetch(`/api/recap?${qs.toString()}`, { cache: "no-cache" }); + const json = await res.json().catch(() => null); + if (!alive || !json) return; + setData(json as RecapResponse); + } finally { + if (alive) setLoading(false); + } + } + + const timeout = setTimeout(load, 200); + return () => { + alive = false; + clearTimeout(timeout); + }; + }, [machineId, mode, shift, customStart, customEnd]); + + useEffect(() => { + async function refresh() { + const qs = new URLSearchParams(); + if (machineId) qs.set("machineId", machineId); + if (mode === "shift") qs.set("shift", shift || "shift1"); + if (mode === "custom") { + if (customStart) qs.set("start", new Date(customStart).toISOString()); + if (customEnd) qs.set("end", new Date(customEnd).toISOString()); + } + const res = await fetch(`/api/recap?${qs.toString()}`, { cache: "no-cache" }); + const json = await res.json().catch(() => null); + if (json) setData(json as RecapResponse); + } + + const onFocus = () => { + void refresh(); + }; + + const interval = window.setInterval(onFocus, 60000); + window.addEventListener("focus", onFocus); + return () => { + window.clearInterval(interval); + window.removeEventListener("focus", onFocus); + }; + }, [machineId, mode, shift, customStart, customEnd]); + + const selectedMachine = useMemo(() => { + if (!data.machines.length) return null; + return data.machines.find((m) => m.machineId === machineId) ?? data.machines[0]; + }, [data.machines, machineId]); + + const fleet = useMemo(() => { + let good = 0; + let scrap = 0; + let stops = 0; + let oeeSum = 0; + let oeeCount = 0; + for (const m of data.machines) { + good += m.production.goodParts; + scrap += m.production.scrapParts; + stops += m.downtime.stopsCount; + if (m.oee.avg != null) { + oeeSum += m.oee.avg; + oeeCount += 1; + } + } + return { + oeeAvg: oeeCount ? oeeSum / oeeCount : null, + good, + scrap, + stops, + }; + }, [data.machines]); + + const bannerMold = selectedMachine?.workOrders.moldChangeInProgress; + const bannerStop = (selectedMachine?.downtime.ongoingStopMin ?? 0) > 0; + + return ( +
+
+
+
+

{t("recap.title")}

+

+ {t("recap.subtitle")} · {new Date(data.range.start).toLocaleString(locale)} - {new Date(data.range.end).toLocaleString(locale)} +

+
+
+ + + + + {mode === "shift" ? ( + + ) : null} + + {mode === "custom" ? ( + <> + setCustomStart(event.target.value)} + className="rounded-xl border border-white/10 bg-black/40 px-3 py-2 text-zinc-200" + /> + setCustomEnd(event.target.value)} + className="rounded-xl border border-white/10 bg-black/40 px-3 py-2 text-zinc-200" + /> + + ) : null} +
+
+
+ + {bannerMold ? ( +
+ {t("recap.banner.mold")} {selectedMachine?.workOrders.active?.startedAt ? new Date(selectedMachine.workOrders.active.startedAt).toLocaleTimeString(locale, { hour: "2-digit", minute: "2-digit" }) : "--:--"} +
+ ) : null} + {bannerStop ? ( +
+ {t("recap.banner.stopped", { minutes: toMinutesLabel(selectedMachine?.downtime.ongoingStopMin ?? null) })} +
+ ) : null} + + {loading ?
{t("common.loading")}
: null} + + + +
+ + +
+ +
+ + +
+
+ ); +} diff --git a/app/(app)/recap/page.tsx b/app/(app)/recap/page.tsx new file mode 100644 index 0000000..08f20e4 --- /dev/null +++ b/app/(app)/recap/page.tsx @@ -0,0 +1,46 @@ +import { redirect } from "next/navigation"; +import { requireSession } from "@/lib/auth/requireSession"; +import { getRecapDataCached, parseRecapQuery } from "@/lib/recap/getRecapData"; +import RecapClient from "./RecapClient"; + +export default async function RecapPage({ + searchParams, +}: { + searchParams?: Promise>; +}) { + const session = await requireSession(); + if (!session) redirect("/login?next=/recap"); + + const params = (await searchParams) ?? {}; + const getParam = (key: string) => { + const value = params[key]; + return Array.isArray(value) ? value[0] : value; + }; + + const parsed = parseRecapQuery({ + machineId: getParam("machineId"), + start: getParam("start"), + end: getParam("end"), + shift: getParam("shift"), + }); + + const initialData = await getRecapDataCached({ + orgId: session.orgId, + machineId: parsed.machineId, + start: parsed.start ?? undefined, + end: parsed.end ?? undefined, + shift: parsed.shift ?? undefined, + }); + + return ( + + ); +} diff --git a/app/(app)/reports/ReportsPageClient.tsx b/app/(app)/reports/ReportsPageClient.tsx new file mode 100644 index 0000000..844134b --- /dev/null +++ b/app/(app)/reports/ReportsPageClient.tsx @@ -0,0 +1,681 @@ +"use client"; + +import { Suspense, lazy, useEffect, useMemo, useState } from "react"; +import { useI18n } from "@/lib/i18n/useI18n"; + +const ReportsCharts = lazy(() => import("./ReportsCharts")); + +type RangeKey = "24h" | "7d" | "30d" | "custom"; + +type ReportSummary = { + oeeAvg: number | null; + availabilityAvg: number | null; + performanceAvg: number | null; + qualityAvg: number | null; + goodTotal: number | null; + scrapTotal: number | null; + targetTotal: number | null; + scrapRate: number | null; + topScrapSku?: string | null; + topScrapWorkOrder?: string | null; +}; + +type ReportDowntime = { + macrostopSec: number; + microstopSec: number; + slowCycleCount: number; + qualitySpikeCount: number; + performanceDegradationCount: number; + oeeDropCount: number; +}; + +type ReportTrendPoint = { t: string; v: number }; + +type ReportPayload = { + summary: ReportSummary; + downtime: ReportDowntime; + trend: { + oee: ReportTrendPoint[]; + availability: ReportTrendPoint[]; + performance: ReportTrendPoint[]; + quality: ReportTrendPoint[]; + scrapRate: ReportTrendPoint[]; + }; + distribution: { + cycleTime: { + label: string; + count: number; + rangeStart?: number; + rangeEnd?: number; + overflow?: "low" | "high"; + minValue?: number; + maxValue?: number; + }[]; + }; + insights?: string[]; +}; + +type MachineOption = { id: string; name: string }; +type FilterOptions = { workOrders: string[]; skus: string[] }; +type Translator = (key: string, vars?: Record) => string; + +function fmtPct(v?: number | null) { + if (v === null || v === undefined || Number.isNaN(v)) return "--"; + return `${v.toFixed(1)}%`; +} + +function fmtDuration(sec?: number | null) { + if (!sec) return "--"; + const h = Math.floor(sec / 3600); + const m = Math.floor((sec % 3600) / 60); + if (h > 0) return `${h}h ${m}m`; + return `${m}m`; +} + +function downsample(rows: T[], max: number) { + if (rows.length <= max) return rows; + const step = Math.ceil(rows.length / max); + return rows.filter((_, idx) => idx % step === 0); +} + +function formatTickLabel(ts: string, range: RangeKey) { + const d = new Date(ts); + if (Number.isNaN(d.getTime())) return ts; + const hh = String(d.getHours()).padStart(2, "0"); + const mm = String(d.getMinutes()).padStart(2, "0"); + const month = String(d.getMonth() + 1).padStart(2, "0"); + const day = String(d.getDate()).padStart(2, "0"); + if (range === "24h") return `${hh}:${mm}`; + return `${month}-${day}`; +} + +function ReportsChartsSkeleton() { + return ( + <> +
+ {Array.from({ length: 2 }).map((_, idx) => ( +
+ ))} +
+
+ {Array.from({ length: 3 }).map((_, idx) => ( +
+ ))} +
+ + ); +} + +function buildCsv(report: ReportPayload, t: Translator) { + const rows = new Map>(); + const addSeries = (series: ReportTrendPoint[], key: string) => { + for (const p of series) { + const row = rows.get(p.t) ?? { timestamp: p.t }; + row[key] = p.v; + rows.set(p.t, row); + } + }; + + addSeries(report.trend.oee, "oee"); + addSeries(report.trend.availability, "availability"); + addSeries(report.trend.performance, "performance"); + addSeries(report.trend.quality, "quality"); + addSeries(report.trend.scrapRate, "scrapRate"); + + const ordered = [...rows.values()].sort((a, b) => { + const at = new Date(String(a.timestamp)).getTime(); + const bt = new Date(String(b.timestamp)).getTime(); + return at - bt; + }); + + const header = ["timestamp", "oee", "availability", "performance", "quality", "scrapRate"].join(","); + const lines = ordered.map((row) => + [ + row.timestamp, + row.oee ?? "", + row.availability ?? "", + row.performance ?? "", + row.quality ?? "", + row.scrapRate ?? "", + ] + .map((v) => (v == null ? "" : String(v))) + .join(",") + ); + + const summary = report.summary; + const downtime = report.downtime; + + const sectionLines: string[] = []; + sectionLines.push( + [t("reports.csv.section"), t("reports.csv.key"), t("reports.csv.value")].join(",") + ); + const addRow = (section: string, key: string, value: string | number | null | undefined) => { + sectionLines.push( + [section, key, value == null ? "" : String(value)] + .map((v) => (v.includes(",") ? `"${v.replace(/\"/g, '""')}"` : v)) + .join(",") + ); + }; + + addRow("summary", "oeeAvg", summary.oeeAvg); + addRow("summary", "availabilityAvg", summary.availabilityAvg); + addRow("summary", "performanceAvg", summary.performanceAvg); + addRow("summary", "qualityAvg", summary.qualityAvg); + addRow("summary", "goodTotal", summary.goodTotal); + addRow("summary", "scrapTotal", summary.scrapTotal); + addRow("summary", "targetTotal", summary.targetTotal); + addRow("summary", "scrapRate", summary.scrapRate); + addRow("summary", "topScrapSku", summary.topScrapSku ?? ""); + addRow("summary", "topScrapWorkOrder", summary.topScrapWorkOrder ?? ""); + + addRow("loss_drivers", "macrostopSec", downtime.macrostopSec); + addRow("loss_drivers", "microstopSec", downtime.microstopSec); + addRow("loss_drivers", "slowCycleCount", downtime.slowCycleCount); + addRow("loss_drivers", "qualitySpikeCount", downtime.qualitySpikeCount); + addRow("loss_drivers", "performanceDegradationCount", downtime.performanceDegradationCount); + addRow("loss_drivers", "oeeDropCount", downtime.oeeDropCount); + + for (const bin of report.distribution.cycleTime) { + addRow("cycle_distribution", bin.label, bin.count); + } + + if (report.insights?.length) { + report.insights.forEach((note, idx) => addRow("insights", String(idx + 1), note)); + } + + return [header, ...lines, "", ...sectionLines].join("\n"); +} + +function downloadText(filename: string, content: string) { + const blob = new Blob([content], { type: "text/csv;charset=utf-8;" }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.setAttribute("download", filename); + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); +} + +function buildPdfHtml( + report: ReportPayload, + rangeLabel: string, + filters: { machine: string; workOrder: string; sku: string }, + t: Translator +) { + const summary = report.summary; + const downtime = report.downtime; + const cycleBins = report.distribution.cycleTime; + const insights = report.insights ?? []; + + return ` + + + + + ${t("reports.pdf.title")} + + + +

${t("reports.title")}

+
${t("reports.pdf.range")}: ${rangeLabel} | ${t("reports.pdf.machine")}: ${filters.machine} | ${t("reports.pdf.workOrder")}: ${filters.workOrder} | ${t("reports.pdf.sku")}: ${filters.sku}
+ +
+
+
OEE (avg)
+
${summary.oeeAvg != null ? summary.oeeAvg.toFixed(1) + "%" : "--"}
+
+
+
Availability (avg)
+
${summary.availabilityAvg != null ? summary.availabilityAvg.toFixed(1) + "%" : "--"}
+
+
+
Performance (avg)
+
${summary.performanceAvg != null ? summary.performanceAvg.toFixed(1) + "%" : "--"}
+
+
+
Quality (avg)
+
${summary.qualityAvg != null ? summary.qualityAvg.toFixed(1) + "%" : "--"}
+
+
+ +
+
${t("reports.pdf.topLoss")}
+ + + + + + + + + + + + +
${t("reports.pdf.metric")}${t("reports.pdf.value")}
${t("reports.loss.macrostop")} (sec)${downtime.macrostopSec}
${t("reports.loss.microstop")} (sec)${downtime.microstopSec}
${t("reports.loss.slowCycle")}${downtime.slowCycleCount}
${t("reports.loss.qualitySpike")}${downtime.qualitySpikeCount}
${t("reports.loss.perfDegradation")}${downtime.performanceDegradationCount}
${t("reports.loss.oeeDrop")}${downtime.oeeDropCount}
+
+ +
+
${t("reports.pdf.qualitySummary")}
+ + + + + + + + + + + + +
${t("reports.pdf.metric")}${t("reports.pdf.value")}
${t("reports.scrapRate")}${summary.scrapRate != null ? summary.scrapRate.toFixed(1) + "%" : "--"}
${t("overview.good")}${summary.goodTotal ?? "--"}
${t("overview.scrap")}${summary.scrapTotal ?? "--"}
${t("overview.target")}${summary.targetTotal ?? "--"}
${t("reports.topScrapSku")}${summary.topScrapSku ?? "--"}
${t("reports.topScrapWorkOrder")}${summary.topScrapWorkOrder ?? "--"}
+
+ +
+
${t("reports.pdf.cycleDistribution")}
+ + + + + + ${cycleBins + .map((bin) => ``) + .join("")} + +
${t("reports.tooltip.range")}${t("reports.tooltip.cycles")}
${bin.label}${bin.count}
+
+ +
+
${t("reports.pdf.notes")}
+ ${insights.length ? `
    ${insights.map((n) => `
  • ${n}
  • `).join("")}
` : `
${t("reports.pdf.none")}
`} +
+ + + `.trim(); +} + +export default function ReportsPageClient({ + initialMachines = [], +}: { + initialMachines?: MachineOption[]; +}) { + const { t, locale } = useI18n(); + const [range, setRange] = useState("24h"); + const [report, setReport] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [machines] = useState(() => initialMachines); + const [filterOptions, setFilterOptions] = useState({ workOrders: [], skus: [] }); + const [machineId, setMachineId] = useState(""); + const [workOrderId, setWorkOrderId] = useState(""); + const [sku, setSku] = useState(""); + + const rangeLabel = useMemo(() => { + if (range === "24h") return t("reports.rangeLabel.last24"); + if (range === "7d") return t("reports.rangeLabel.last7"); + if (range === "30d") return t("reports.rangeLabel.last30"); + return t("reports.rangeLabel.custom"); + }, [range, t]); + + useEffect(() => { + let alive = true; + const controller = new AbortController(); + + async function load() { + setLoading(true); + setError(null); + try { + const params = new URLSearchParams({ range }); + if (machineId) params.set("machineId", machineId); + if (workOrderId) params.set("workOrderId", workOrderId); + if (sku) params.set("sku", sku); + + const res = await fetch(`/api/reports?${params.toString()}`, { + cache: "no-cache", + signal: controller.signal, + }); + const json = await res.json(); + if (!alive) return; + if (!res.ok || json?.ok === false) { + setError(json?.error ?? t("reports.error.failed")); + setReport(null); + } else { + setReport(json); + } + } catch { + if (!alive) return; + setError(t("reports.error.network")); + setReport(null); + } finally { + if (alive) setLoading(false); + } + } + + load(); + return () => { + alive = false; + controller.abort(); + }; + }, [range, machineId, workOrderId, sku, t]); + + useEffect(() => { + let alive = true; + const controller = new AbortController(); + + async function loadFilters() { + try { + const params = new URLSearchParams({ range }); + if (machineId) params.set("machineId", machineId); + const res = await fetch(`/api/reports/filters?${params.toString()}`, { + cache: "no-cache", + signal: controller.signal, + }); + const json = await res.json(); + if (!alive) return; + if (!res.ok || json?.ok === false) { + setFilterOptions({ workOrders: [], skus: [] }); + } else { + setFilterOptions({ + workOrders: json.workOrders ?? [], + skus: json.skus ?? [], + }); + } + } catch { + if (!alive) return; + setFilterOptions({ workOrders: [], skus: [] }); + } + } + + loadFilters(); + return () => { + alive = false; + controller.abort(); + }; + }, [range, machineId]); + + const summary = report?.summary; + const downtime = report?.downtime; + const trend = report?.trend; + const distribution = report?.distribution; + + const oeeSeries = useMemo(() => { + const rows = trend?.oee ?? []; + const trimmed = downsample(rows, 600); + return trimmed.map((p) => ({ + ts: p.t, + label: formatTickLabel(p.t, range), + value: p.v, + })); + }, [trend?.oee, range]); + + const scrapSeries = useMemo(() => { + const rows = trend?.scrapRate ?? []; + const trimmed = downsample(rows, 600); + return trimmed.map((p) => ({ + ts: p.t, + label: formatTickLabel(p.t, range), + value: p.v, + })); + }, [trend?.scrapRate, range]); + + const cycleHistogram = useMemo(() => { + return distribution?.cycleTime ?? []; + }, [distribution?.cycleTime]); + + const downtimeSeries = useMemo(() => { + if (!downtime) return []; + return [ + { name: "Macrostop", value: Math.round(downtime.macrostopSec / 60) }, + { name: "Microstop", value: Math.round(downtime.microstopSec / 60) }, + ]; + }, [downtime]); + + const downtimeColors: Record = { + Macrostop: "#FF3B5C", + Microstop: "#FF7A00", + }; + + const lossRows = useMemo( + () => [ + { label: t("reports.loss.macrostop"), value: fmtDuration(downtime?.macrostopSec) }, + { label: t("reports.loss.microstop"), value: fmtDuration(downtime?.microstopSec) }, + { label: t("reports.loss.slowCycle"), value: downtime ? `${downtime.slowCycleCount}` : "--" }, + { label: t("reports.loss.qualitySpike"), value: downtime ? `${downtime.qualitySpikeCount}` : "--" }, + { label: t("reports.loss.oeeDrop"), value: downtime ? `${downtime.oeeDropCount}` : "--" }, + { + label: t("reports.loss.perfDegradation"), + value: downtime ? `${downtime.performanceDegradationCount}` : "--", + }, + ], + [downtime, t] + ); + + const machineLabel = useMemo(() => { + if (!machineId) return t("reports.filter.allMachines"); + return machines.find((m) => m.id === machineId)?.name ?? machineId; + }, [machineId, machines, t]); + + const workOrderLabel = workOrderId || t("reports.filter.allWorkOrders"); + const skuLabel = sku || t("reports.filter.allSkus"); + + const handleExportCsv = () => { + if (!report) return; + const csv = buildCsv(report, t); + downloadText("reports.csv", csv); + }; + + const handleExportPdf = () => { + if (!report) return; + const html = buildPdfHtml( + report, + rangeLabel, + { + machine: machineLabel, + workOrder: workOrderLabel, + sku: skuLabel, + }, + t + ); + + const win = window.open("", "_blank", "width=900,height=650"); + if (!win) return; + win.document.open(); + win.document.write(html); + win.document.close(); + win.focus(); + setTimeout(() => win.print(), 300); + }; + + return ( +
+
+
+

{t("reports.title")}

+

{t("reports.subtitle")}

+
+ +
+ + +
+
+ +
+
+
{t("reports.filters")}
+
{rangeLabel}
+
+ +
+
+
{t("reports.filter.range")}
+
+ {(["24h", "7d", "30d", "custom"] as RangeKey[]).map((k) => ( + + ))} +
+
+ +
+
{t("reports.filter.machine")}
+ +
+ +
+
{t("reports.filter.workOrder")}
+ setWorkOrderId(e.target.value)} + placeholder={t("reports.filter.allWorkOrders")} + className="mt-2 w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm text-zinc-300 placeholder:text-zinc-500" + /> + + {filterOptions.workOrders.map((wo) => ( + +
+ +
+
{t("reports.filter.sku")}
+ setSku(e.target.value)} + placeholder={t("reports.filter.allSkus")} + className="mt-2 w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm text-zinc-300 placeholder:text-zinc-500" + /> + + {filterOptions.skus.map((s) => ( + +
+
+
+ +
+ {loading &&
{t("reports.loading")}
} + {error && !loading && ( +
+ {error} +
+ )} +
+ +
+ {[ + { label: "OEE", value: fmtPct(summary?.oeeAvg), tone: "text-emerald-300" }, + { label: "Availability", value: fmtPct(summary?.availabilityAvg), tone: "text-white" }, + { label: "Performance", value: fmtPct(summary?.performanceAvg), tone: "text-white" }, + { label: "Quality", value: fmtPct(summary?.qualityAvg), tone: "text-white" }, + ].map((kpi) => ( +
+
{kpi.label} (avg)
+
{kpi.value}
+
+ {summary ? t("reports.kpi.note.withData") : t("reports.kpi.note.noData")} +
+
+ ))} +
+ + }> + + + +
+
+
{t("reports.qualitySummary")}
+
+
+
{t("reports.scrapRate")}
+
+ {summary?.scrapRate != null ? fmtPct(summary.scrapRate) : "--"} +
+
+
+
{t("reports.topScrapSku")}
+
{summary?.topScrapSku ?? "--"}
+
+
+
{t("reports.topScrapWorkOrder")}
+
{summary?.topScrapWorkOrder ?? "--"}
+
+
+
+ +
+
{t("reports.notes")}
+
+
{t("reports.notes.suggested")}
+ {report?.insights && report.insights.length > 0 ? ( +
+ {report.insights.map((note, idx) => ( +
{note}
+ ))} +
+ ) : ( +
{t("reports.notes.none")}
+ )} +
+
+
+
+ ); +} diff --git a/app/(app)/reports/page.tsx b/app/(app)/reports/page.tsx index 03688f1..17598f5 100644 --- a/app/(app)/reports/page.tsx +++ b/app/(app)/reports/page.tsx @@ -1,713 +1,17 @@ -"use client"; +import { redirect } from "next/navigation"; +import { prisma } from "@/lib/prisma"; +import { requireSession } from "@/lib/auth/requireSession"; +import ReportsPageClient from "./ReportsPageClient"; -import { Suspense, lazy, useEffect, useMemo, useState } from "react"; -import { useI18n } from "@/lib/i18n/useI18n"; +export default async function ReportsPage() { + const session = await requireSession(); + if (!session) redirect("/login?next=/reports"); -const ReportsCharts = lazy(() => import("./ReportsCharts")); - -type RangeKey = "24h" | "7d" | "30d" | "custom"; - -type ReportSummary = { - oeeAvg: number | null; - availabilityAvg: number | null; - performanceAvg: number | null; - qualityAvg: number | null; - goodTotal: number | null; - scrapTotal: number | null; - targetTotal: number | null; - scrapRate: number | null; - topScrapSku?: string | null; - topScrapWorkOrder?: string | null; -}; - -type ReportDowntime = { - macrostopSec: number; - microstopSec: number; - slowCycleCount: number; - qualitySpikeCount: number; - performanceDegradationCount: number; - oeeDropCount: number; -}; - -type ReportTrendPoint = { t: string; v: number }; - -type ReportPayload = { - summary: ReportSummary; - downtime: ReportDowntime; - trend: { - oee: ReportTrendPoint[]; - availability: ReportTrendPoint[]; - performance: ReportTrendPoint[]; - quality: ReportTrendPoint[]; - scrapRate: ReportTrendPoint[]; - }; - distribution: { - cycleTime: { - label: string; - count: number; - rangeStart?: number; - rangeEnd?: number; - overflow?: "low" | "high"; - minValue?: number; - maxValue?: number; - }[]; - }; - insights?: string[]; -}; - -type MachineOption = { id: string; name: string }; -type FilterOptions = { workOrders: string[]; skus: string[] }; -type Translator = (key: string, vars?: Record) => string; - -function fmtPct(v?: number | null) { - if (v === null || v === undefined || Number.isNaN(v)) return "--"; - return `${v.toFixed(1)}%`; -} - -function fmtDuration(sec?: number | null) { - if (!sec) return "--"; - const h = Math.floor(sec / 3600); - const m = Math.floor((sec % 3600) / 60); - if (h > 0) return `${h}h ${m}m`; - return `${m}m`; -} - -function downsample(rows: T[], max: number) { - if (rows.length <= max) return rows; - const step = Math.ceil(rows.length / max); - return rows.filter((_, idx) => idx % step === 0); -} - -function formatTickLabel(ts: string, range: RangeKey) { - const d = new Date(ts); - if (Number.isNaN(d.getTime())) return ts; - const hh = String(d.getHours()).padStart(2, "0"); - const mm = String(d.getMinutes()).padStart(2, "0"); - const month = String(d.getMonth() + 1).padStart(2, "0"); - const day = String(d.getDate()).padStart(2, "0"); - if (range === "24h") return `${hh}:${mm}`; - return `${month}-${day}`; -} - -function ReportsChartsSkeleton() { - return ( - <> -
- {Array.from({ length: 2 }).map((_, idx) => ( -
- ))} -
-
- {Array.from({ length: 3 }).map((_, idx) => ( -
- ))} -
- - ); -} - -function toMachineOption(value: unknown): MachineOption | null { - if (!value || typeof value !== "object") return null; - const record = value as Record; - const id = typeof record.id === "string" ? record.id : ""; - const name = typeof record.name === "string" ? record.name : ""; - if (!id || !name) return null; - return { id, name }; -} - -function buildCsv(report: ReportPayload, t: Translator) { - const rows = new Map>(); - const addSeries = (series: ReportTrendPoint[], key: string) => { - for (const p of series) { - const row = rows.get(p.t) ?? { timestamp: p.t }; - row[key] = p.v; - rows.set(p.t, row); - } - }; - - addSeries(report.trend.oee, "oee"); - addSeries(report.trend.availability, "availability"); - addSeries(report.trend.performance, "performance"); - addSeries(report.trend.quality, "quality"); - addSeries(report.trend.scrapRate, "scrapRate"); - - const ordered = [...rows.values()].sort((a, b) => { - const at = new Date(String(a.timestamp)).getTime(); - const bt = new Date(String(b.timestamp)).getTime(); - return at - bt; + const machines = await prisma.machine.findMany({ + where: { orgId: session.orgId }, + orderBy: { createdAt: "desc" }, + select: { id: true, name: true }, }); - const header = ["timestamp", "oee", "availability", "performance", "quality", "scrapRate"].join(","); - const lines = ordered.map((row) => - [ - row.timestamp, - row.oee ?? "", - row.availability ?? "", - row.performance ?? "", - row.quality ?? "", - row.scrapRate ?? "", - ] - .map((v) => (v == null ? "" : String(v))) - .join(",") - ); - - const summary = report.summary; - const downtime = report.downtime; - - const sectionLines: string[] = []; - sectionLines.push( - [t("reports.csv.section"), t("reports.csv.key"), t("reports.csv.value")].join(",") - ); - const addRow = (section: string, key: string, value: string | number | null | undefined) => { - sectionLines.push( - [section, key, value == null ? "" : String(value)] - .map((v) => (v.includes(",") ? `"${v.replace(/\"/g, '""')}"` : v)) - .join(",") - ); - }; - - addRow("summary", "oeeAvg", summary.oeeAvg); - addRow("summary", "availabilityAvg", summary.availabilityAvg); - addRow("summary", "performanceAvg", summary.performanceAvg); - addRow("summary", "qualityAvg", summary.qualityAvg); - addRow("summary", "goodTotal", summary.goodTotal); - addRow("summary", "scrapTotal", summary.scrapTotal); - addRow("summary", "targetTotal", summary.targetTotal); - addRow("summary", "scrapRate", summary.scrapRate); - addRow("summary", "topScrapSku", summary.topScrapSku ?? ""); - addRow("summary", "topScrapWorkOrder", summary.topScrapWorkOrder ?? ""); - - addRow("loss_drivers", "macrostopSec", downtime.macrostopSec); - addRow("loss_drivers", "microstopSec", downtime.microstopSec); - addRow("loss_drivers", "slowCycleCount", downtime.slowCycleCount); - addRow("loss_drivers", "qualitySpikeCount", downtime.qualitySpikeCount); - addRow("loss_drivers", "performanceDegradationCount", downtime.performanceDegradationCount); - addRow("loss_drivers", "oeeDropCount", downtime.oeeDropCount); - - for (const bin of report.distribution.cycleTime) { - addRow("cycle_distribution", bin.label, bin.count); - } - - if (report.insights?.length) { - report.insights.forEach((note, idx) => addRow("insights", String(idx + 1), note)); - } - - return [header, ...lines, "", ...sectionLines].join("\n"); -} - -function downloadText(filename: string, content: string) { - const blob = new Blob([content], { type: "text/csv;charset=utf-8;" }); - const url = URL.createObjectURL(blob); - const link = document.createElement("a"); - link.href = url; - link.setAttribute("download", filename); - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - URL.revokeObjectURL(url); -} - -function buildPdfHtml( - report: ReportPayload, - rangeLabel: string, - filters: { machine: string; workOrder: string; sku: string }, - t: Translator -) { - const summary = report.summary; - const downtime = report.downtime; - const cycleBins = report.distribution.cycleTime; - const insights = report.insights ?? []; - - return ` - - - - - ${t("reports.pdf.title")} - - - -

${t("reports.title")}

-
${t("reports.pdf.range")}: ${rangeLabel} | ${t("reports.pdf.machine")}: ${filters.machine} | ${t("reports.pdf.workOrder")}: ${filters.workOrder} | ${t("reports.pdf.sku")}: ${filters.sku}
- -
-
-
OEE (avg)
-
${summary.oeeAvg != null ? summary.oeeAvg.toFixed(1) + "%" : "--"}
-
-
-
Availability (avg)
-
${summary.availabilityAvg != null ? summary.availabilityAvg.toFixed(1) + "%" : "--"}
-
-
-
Performance (avg)
-
${summary.performanceAvg != null ? summary.performanceAvg.toFixed(1) + "%" : "--"}
-
-
-
Quality (avg)
-
${summary.qualityAvg != null ? summary.qualityAvg.toFixed(1) + "%" : "--"}
-
-
- -
-
${t("reports.pdf.topLoss")}
- - - - - - - - - - - - -
${t("reports.pdf.metric")}${t("reports.pdf.value")}
${t("reports.loss.macrostop")} (sec)${downtime.macrostopSec}
${t("reports.loss.microstop")} (sec)${downtime.microstopSec}
${t("reports.loss.slowCycle")}${downtime.slowCycleCount}
${t("reports.loss.qualitySpike")}${downtime.qualitySpikeCount}
${t("reports.loss.perfDegradation")}${downtime.performanceDegradationCount}
${t("reports.loss.oeeDrop")}${downtime.oeeDropCount}
-
- -
-
${t("reports.pdf.qualitySummary")}
- - - - - - - - - - - - -
${t("reports.pdf.metric")}${t("reports.pdf.value")}
${t("reports.scrapRate")}${summary.scrapRate != null ? summary.scrapRate.toFixed(1) + "%" : "--"}
${t("overview.good")}${summary.goodTotal ?? "--"}
${t("overview.scrap")}${summary.scrapTotal ?? "--"}
${t("overview.target")}${summary.targetTotal ?? "--"}
${t("reports.topScrapSku")}${summary.topScrapSku ?? "--"}
${t("reports.topScrapWorkOrder")}${summary.topScrapWorkOrder ?? "--"}
-
- -
-
${t("reports.pdf.cycleDistribution")}
- - - - - - ${cycleBins - .map((bin) => ``) - .join("")} - -
${t("reports.tooltip.range")}${t("reports.tooltip.cycles")}
${bin.label}${bin.count}
-
- -
-
${t("reports.pdf.notes")}
- ${insights.length ? `
    ${insights.map((n) => `
  • ${n}
  • `).join("")}
` : `
${t("reports.pdf.none")}
`} -
- - - `.trim(); -} - -export default function ReportsPage() { - const { t, locale } = useI18n(); - const [range, setRange] = useState("24h"); - const [report, setReport] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [machines, setMachines] = useState([]); - const [filterOptions, setFilterOptions] = useState({ workOrders: [], skus: [] }); - const [machineId, setMachineId] = useState(""); - const [workOrderId, setWorkOrderId] = useState(""); - const [sku, setSku] = useState(""); - - const rangeLabel = useMemo(() => { - if (range === "24h") return t("reports.rangeLabel.last24"); - if (range === "7d") return t("reports.rangeLabel.last7"); - if (range === "30d") return t("reports.rangeLabel.last30"); - return t("reports.rangeLabel.custom"); - }, [range, t]); - - useEffect(() => { - let alive = true; - - async function loadMachines() { - try { - const res = await fetch("/api/machines", { cache: "no-store" }); - const json = await res.json(); - if (!alive) return; - const rows: unknown[] = Array.isArray(json?.machines) ? json.machines : []; - const options: MachineOption[] = []; - rows.forEach((row) => { - const option = toMachineOption(row); - if (option) options.push(option); - }); - setMachines(options); - } catch { - if (!alive) return; - setMachines([]); - } - } - - loadMachines(); - return () => { - alive = false; - }; - }, []); - - useEffect(() => { - let alive = true; - const controller = new AbortController(); - - async function load() { - setLoading(true); - setError(null); - try { - const params = new URLSearchParams({ range }); - if (machineId) params.set("machineId", machineId); - if (workOrderId) params.set("workOrderId", workOrderId); - if (sku) params.set("sku", sku); - - const res = await fetch(`/api/reports?${params.toString()}`, { - cache: "no-store", - signal: controller.signal, - }); - const json = await res.json(); - if (!alive) return; - if (!res.ok || json?.ok === false) { - setError(json?.error ?? t("reports.error.failed")); - setReport(null); - } else { - setReport(json); - } - } catch { - if (!alive) return; - setError(t("reports.error.network")); - setReport(null); - } finally { - if (alive) setLoading(false); - } - } - - load(); - return () => { - alive = false; - controller.abort(); - }; - }, [range, machineId, workOrderId, sku, t]); - - useEffect(() => { - let alive = true; - const controller = new AbortController(); - - async function loadFilters() { - try { - const params = new URLSearchParams({ range }); - if (machineId) params.set("machineId", machineId); - const res = await fetch(`/api/reports/filters?${params.toString()}`, { - cache: "no-store", - signal: controller.signal, - }); - const json = await res.json(); - if (!alive) return; - if (!res.ok || json?.ok === false) { - setFilterOptions({ workOrders: [], skus: [] }); - } else { - setFilterOptions({ - workOrders: json.workOrders ?? [], - skus: json.skus ?? [], - }); - } - } catch { - if (!alive) return; - setFilterOptions({ workOrders: [], skus: [] }); - } - } - - loadFilters(); - return () => { - alive = false; - controller.abort(); - }; - }, [range, machineId]); - - const summary = report?.summary; - const downtime = report?.downtime; - const trend = report?.trend; - const distribution = report?.distribution; - - const oeeSeries = useMemo(() => { - const rows = trend?.oee ?? []; - const trimmed = downsample(rows, 600); - return trimmed.map((p) => ({ - ts: p.t, - label: formatTickLabel(p.t, range), - value: p.v, - })); - }, [trend?.oee, range]); - - const scrapSeries = useMemo(() => { - const rows = trend?.scrapRate ?? []; - const trimmed = downsample(rows, 600); - return trimmed.map((p) => ({ - ts: p.t, - label: formatTickLabel(p.t, range), - value: p.v, - })); - }, [trend?.scrapRate, range]); - - const cycleHistogram = useMemo(() => { - return distribution?.cycleTime ?? []; - }, [distribution?.cycleTime]); - - const downtimeSeries = useMemo(() => { - if (!downtime) return []; - return [ - { name: "Macrostop", value: Math.round(downtime.macrostopSec / 60) }, - { name: "Microstop", value: Math.round(downtime.microstopSec / 60) }, - ]; - }, [downtime]); - - const downtimeColors: Record = { - Macrostop: "#FF3B5C", - Microstop: "#FF7A00", - }; - - const lossRows = useMemo( - () => [ - { label: t("reports.loss.macrostop"), value: fmtDuration(downtime?.macrostopSec) }, - { label: t("reports.loss.microstop"), value: fmtDuration(downtime?.microstopSec) }, - { label: t("reports.loss.slowCycle"), value: downtime ? `${downtime.slowCycleCount}` : "--" }, - { label: t("reports.loss.qualitySpike"), value: downtime ? `${downtime.qualitySpikeCount}` : "--" }, - { label: t("reports.loss.oeeDrop"), value: downtime ? `${downtime.oeeDropCount}` : "--" }, - { - label: t("reports.loss.perfDegradation"), - value: downtime ? `${downtime.performanceDegradationCount}` : "--", - }, - ], - [downtime, t] - ); - - const machineLabel = useMemo(() => { - if (!machineId) return t("reports.filter.allMachines"); - return machines.find((m) => m.id === machineId)?.name ?? machineId; - }, [machineId, machines, t]); - - const workOrderLabel = workOrderId || t("reports.filter.allWorkOrders"); - const skuLabel = sku || t("reports.filter.allSkus"); - - const handleExportCsv = () => { - if (!report) return; - const csv = buildCsv(report, t); - downloadText("reports.csv", csv); - }; - - const handleExportPdf = () => { - if (!report) return; - const html = buildPdfHtml( - report, - rangeLabel, - { - machine: machineLabel, - workOrder: workOrderLabel, - sku: skuLabel, - }, - t - ); - - const win = window.open("", "_blank", "width=900,height=650"); - if (!win) return; - win.document.open(); - win.document.write(html); - win.document.close(); - win.focus(); - setTimeout(() => win.print(), 300); - }; - - return ( -
-
-
-

{t("reports.title")}

-

{t("reports.subtitle")}

-
- -
- - -
-
- -
-
-
{t("reports.filters")}
-
{rangeLabel}
-
- -
-
-
{t("reports.filter.range")}
-
- {(["24h", "7d", "30d", "custom"] as RangeKey[]).map((k) => ( - - ))} -
-
- -
-
{t("reports.filter.machine")}
- -
- -
-
{t("reports.filter.workOrder")}
- setWorkOrderId(e.target.value)} - placeholder={t("reports.filter.allWorkOrders")} - className="mt-2 w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm text-zinc-300 placeholder:text-zinc-500" - /> - - {filterOptions.workOrders.map((wo) => ( - -
- -
-
{t("reports.filter.sku")}
- setSku(e.target.value)} - placeholder={t("reports.filter.allSkus")} - className="mt-2 w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm text-zinc-300 placeholder:text-zinc-500" - /> - - {filterOptions.skus.map((s) => ( - -
-
-
- -
- {loading &&
{t("reports.loading")}
} - {error && !loading && ( -
- {error} -
- )} -
- -
- {[ - { label: "OEE", value: fmtPct(summary?.oeeAvg), tone: "text-emerald-300" }, - { label: "Availability", value: fmtPct(summary?.availabilityAvg), tone: "text-white" }, - { label: "Performance", value: fmtPct(summary?.performanceAvg), tone: "text-white" }, - { label: "Quality", value: fmtPct(summary?.qualityAvg), tone: "text-white" }, - ].map((kpi) => ( -
-
{kpi.label} (avg)
-
{kpi.value}
-
- {summary ? t("reports.kpi.note.withData") : t("reports.kpi.note.noData")} -
-
- ))} -
- - }> - - - -
-
-
{t("reports.qualitySummary")}
-
-
-
{t("reports.scrapRate")}
-
- {summary?.scrapRate != null ? fmtPct(summary.scrapRate) : "--"} -
-
-
-
{t("reports.topScrapSku")}
-
{summary?.topScrapSku ?? "--"}
-
-
-
{t("reports.topScrapWorkOrder")}
-
{summary?.topScrapWorkOrder ?? "--"}
-
-
-
- -
-
{t("reports.notes")}
-
-
{t("reports.notes.suggested")}
- {report?.insights && report.insights.length > 0 ? ( -
- {report.insights.map((note, idx) => ( -
{note}
- ))} -
- ) : ( -
{t("reports.notes.none")}
- )} -
-
-
-
- ); + return ; } diff --git a/app/api/ingest/event/route.ts b/app/api/ingest/event/route.ts index c9389c1..b3a2ac5 100644 --- a/app/api/ingest/event/route.ts +++ b/app/api/ingest/event/route.ts @@ -439,7 +439,9 @@ export async function POST(req: Request) { // If the payload carries a `reason`, create the corresponding ReasonEntry. // If it doesn't, still create an "UNCLASSIFIED" downtime ReasonEntry for stop events so the dashboard can show coverage. - if (evReason || finalType === "microstop" || finalType === "macrostop" || finalType === "downtime-acknowledged") { + if (evRecord.is_update || evRecord.is_auto_ack || dataObj.is_update || dataObj.is_auto_ack){ + // skip duplicate reasonEntry for refresh/ack + } else if (evReason || finalType === "microstop" || finalType === "macrostop" || finalType === "downtime-acknowledged"){ const reasonRaw: Record = evReason ?? ({ diff --git a/app/api/recap/route.ts b/app/api/recap/route.ts new file mode 100644 index 0000000..484676b --- /dev/null +++ b/app/api/recap/route.ts @@ -0,0 +1,29 @@ +import { NextResponse } from "next/server"; +import type { NextRequest } from "next/server"; +import { requireSession } from "@/lib/auth/requireSession"; +import { getRecapDataCached, parseRecapQuery } from "@/lib/recap/getRecapData"; + +export async function GET(req: NextRequest) { + const session = await requireSession(); + if (!session) { + return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 }); + } + + const url = new URL(req.url); + const query = parseRecapQuery({ + machineId: url.searchParams.get("machineId"), + start: url.searchParams.get("start"), + end: url.searchParams.get("end"), + shift: url.searchParams.get("shift"), + }); + + const recap = await getRecapDataCached({ + orgId: session.orgId, + machineId: query.machineId, + start: query.start ?? undefined, + end: query.end ?? undefined, + shift: query.shift ?? undefined, + }); + + return NextResponse.json(recap); +} diff --git a/app/api/reports/filters/route.ts b/app/api/reports/filters/route.ts index 0c7bc4f..516f53a 100644 --- a/app/api/reports/filters/route.ts +++ b/app/api/reports/filters/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from "next/server"; import type { NextRequest } from "next/server"; +import { createHash } from "crypto"; import { prisma } from "@/lib/prisma"; import { requireSession } from "@/lib/auth/requireSession"; import { logLine } from "@/lib/logger"; @@ -42,6 +43,10 @@ function pickRange(req: NextRequest) { return { start: new Date(now.getTime() - ms), end: now }; } +function toMs(value?: Date | null) { + return value ? value.getTime() : 0; +} + export async function GET(req: NextRequest) { const perfEnabled = PERF_LOGS_ENABLED; const totalStart = nowMs(); @@ -67,6 +72,32 @@ export async function GET(req: NextRequest) { if (perfEnabled) timings.preQuery = elapsedMs(preQueryStart); + const versionStart = nowMs(); + const cycleMax = await prisma.machineCycle.aggregate({ + where: baseWhere, + _max: { tsServer: true }, + }); + if (perfEnabled) timings.version = elapsedMs(versionStart); + + const versionParts = [ + session.orgId, + range, + machineId ?? "", + toMs(cycleMax._max.tsServer), + ]; + const etag = `W/"${createHash("sha1").update(versionParts.join("|")).digest("hex")}"`; + const responseHeaders = new Headers({ + "Cache-Control": "private, no-cache, max-age=0, must-revalidate", + ETag: etag, + "Last-Modified": new Date(toMs(cycleMax._max.tsServer) || 0).toUTCString(), + Vary: "Cookie", + }); + + const ifNoneMatch = req.headers.get("if-none-match"); + if (ifNoneMatch && ifNoneMatch === etag) { + return new NextResponse(null, { status: 304, headers: responseHeaders }); + } + const workOrdersStart = nowMs(); const workOrderRows = await prisma.machineCycle.findMany({ where: { ...baseWhere, workOrderId: { not: null } }, @@ -90,7 +121,6 @@ export async function GET(req: NextRequest) { const payload = { ok: true, workOrders, skus }; - const responseHeaders = new Headers(); if (perfEnabled) { timings.postQuery = elapsedMs(postQueryStart); timings.total = elapsedMs(totalStart); diff --git a/app/api/reports/route.ts b/app/api/reports/route.ts index 5fd95c3..ab7a0f3 100644 --- a/app/api/reports/route.ts +++ b/app/api/reports/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from "next/server"; import type { NextRequest } from "next/server"; +import { createHash } from "crypto"; import { prisma } from "@/lib/prisma"; import { requireSession } from "@/lib/auth/requireSession"; import { logLine } from "@/lib/logger"; @@ -46,6 +47,10 @@ function safeNum(v: unknown) { return typeof v === "number" && Number.isFinite(v) ? v : null; } +function toMs(value?: Date | null) { + return value ? value.getTime() : 0; +} + export async function GET(req: NextRequest) { const perfEnabled = PERF_LOGS_ENABLED; const totalStart = nowMs(); @@ -73,6 +78,52 @@ export async function GET(req: NextRequest) { if (perfEnabled) timings.preQuery = elapsedMs(preQueryStart); + const versionStart = nowMs(); + const [kpiMax, cycleMax, eventMax] = await Promise.all([ + prisma.machineKpiSnapshot.aggregate({ + where: { ...baseWhere, ts: { gte: start, lte: end } }, + _max: { tsServer: true }, + }), + prisma.machineCycle.aggregate({ + where: { ...baseWhere, ts: { gte: start, lte: end } }, + _max: { tsServer: true }, + }), + prisma.machineEvent.aggregate({ + where: { ...baseWhere, ts: { gte: start, lte: end } }, + _max: { tsServer: true }, + }), + ]); + if (perfEnabled) timings.version = elapsedMs(versionStart); + + const lastModifiedMs = Math.max( + toMs(kpiMax._max.tsServer), + toMs(cycleMax._max.tsServer), + toMs(eventMax._max.tsServer) + ); + + const versionParts = [ + session.orgId, + range, + machineId ?? "", + workOrderId ?? "", + sku ?? "", + toMs(kpiMax._max.tsServer), + toMs(cycleMax._max.tsServer), + toMs(eventMax._max.tsServer), + ]; + const etag = `W/"${createHash("sha1").update(versionParts.join("|")).digest("hex")}"`; + const responseHeaders = new Headers({ + "Cache-Control": "private, no-cache, max-age=0, must-revalidate", + ETag: etag, + "Last-Modified": new Date(lastModifiedMs || 0).toUTCString(), + Vary: "Cookie", + }); + + const ifNoneMatch = req.headers.get("if-none-match"); + if (ifNoneMatch && ifNoneMatch === etag) { + return new NextResponse(null, { status: 304, headers: responseHeaders }); + } + const kpiStart = nowMs(); const kpiRows = await prisma.machineKpiSnapshot.findMany({ where: { ...baseWhere, ts: { gte: start, lte: end } }, @@ -405,7 +456,6 @@ export async function GET(req: NextRequest) { }, }; - const responseHeaders = new Headers(); if (perfEnabled) { timings.postQuery = elapsedMs(postQueryStart); timings.total = elapsedMs(totalStart); diff --git a/components/layout/Sidebar.tsx b/components/layout/Sidebar.tsx index f7404d6..24164be 100644 --- a/components/layout/Sidebar.tsx +++ b/components/layout/Sidebar.tsx @@ -3,7 +3,18 @@ import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; import { useEffect, useMemo, useState, useTransition } from "react"; -import { BarChart3, Bell, DollarSign, LayoutGrid, Loader2, LogOut, Settings, Wrench, X } from "lucide-react"; +import { + BarChart3, + Bell, + DollarSign, + LayoutGrid, + Loader2, + LogOut, + Settings, + Sunrise, + Wrench, + X, +} from "lucide-react"; import type { LucideIcon } from "lucide-react"; import { useI18n } from "@/lib/i18n/useI18n"; import { useScreenlessMode } from "@/lib/ui/screenlessMode"; @@ -24,10 +35,10 @@ const items: NavItem[] = [ { href: "/reports", labelKey: "nav.reports", icon: BarChart3 }, { href: "/alerts", labelKey: "nav.alerts", icon: Bell }, { href: "/financial", labelKey: "nav.financial", icon: DollarSign, ownerOnly: true }, - { href: "/settings", labelKey: "nav.settings", icon: Settings }, { href: "/downtime", labelKey: "nav.downtime", icon: BarChart3 }, - + { href: "/recap", labelKey: "nav.recap", icon: Sunrise }, ]; +const settingsItem: NavItem = { href: "/settings", labelKey: "nav.settings", icon: Settings }; type SidebarProps = { variant?: "desktop" | "drawer"; @@ -97,16 +108,7 @@ export function Sidebar({ variant = "desktop", onNavigate, onClose }: SidebarPro } }, [screenlessMode, pathname, router]); - useEffect(() => { - if (!pendingHref) return; - if (pathname === pendingHref || pathname.startsWith(`${pendingHref}/`)) { - setPendingHref(null); - } else if (!isPending) { - setPendingHref(null); - } - }, [pathname, pendingHref, isPending]); - - const markNavStart = (href: string) => { + const markNavStart = (href: string, ts: number) => { if (!PERF_ENABLED) return; try { sessionStorage.setItem( @@ -114,7 +116,7 @@ export function Sidebar({ variant = "desktop", onNavigate, onClose }: SidebarPro JSON.stringify({ href, from: pathname, - ts: Date.now(), + ts, }) ); } catch { @@ -128,6 +130,58 @@ export function Sidebar({ variant = "desktop", onNavigate, onClose }: SidebarPro "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]", ].join(" "); + const navLocked = isPending; + + const renderNavItem = (it: NavItem) => { + const isCurrent = pathname === it.href; + const active = isCurrent || pathname.startsWith(it.href + "/"); + const isPendingItem = isPending && pendingHref === it.href; + const Icon = it.icon; + return ( + { + if ( + navLocked || + event.defaultPrevented || + event.button !== 0 || + event.metaKey || + event.altKey || + event.ctrlKey || + event.shiftKey + ) { + return; + } + if (isCurrent) { + onNavigate?.(); + return; + } + event.preventDefault(); + markNavStart(it.href, Math.round(performance.timeOrigin + event.timeStamp)); + setPendingHref(it.href); + startTransition(() => { + router.push(it.href); + }); + onNavigate?.(); + }} + className={[ + "flex items-center gap-3 rounded-xl px-3 py-2 text-sm transition", + active + ? "bg-emerald-500/15 text-emerald-300 border border-emerald-500/20" + : "text-zinc-300 hover:bg-white/5 hover:text-white", + navLocked ? "pointer-events-none" : "", + navLocked && !isPendingItem ? "opacity-60" : "", + ].join(" ")} + > + + {t(it.labelKey)} + {isPendingItem ? : null} + + ); + }; return (
-