This commit is contained in:
Marcelo
2026-04-24 02:01:40 +00:00
parent 2707fd974a
commit e705f5e965
19 changed files with 2255 additions and 805 deletions

View File

@@ -232,6 +232,21 @@ export default function OverviewClient({
{loading && <div className="mb-4 text-sm text-zinc-400">{t("overview.loading")}</div>} {loading && <div className="mb-4 text-sm text-zinc-400">{t("overview.loading")}</div>}
<div className="mb-4 rounded-2xl border border-white/10 bg-black/40 p-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<div className="text-sm font-semibold text-white">{t("overview.recap.title")}</div>
<div className="text-xs text-zinc-400">{t("overview.recap.subtitle")}</div>
</div>
<Link
href="/recap"
className="rounded-xl border border-emerald-500/30 bg-emerald-500/10 px-3 py-2 text-sm text-emerald-300 hover:bg-emerald-500/20"
>
{t("overview.recap.cta")}
</Link>
</div>
</div>
<div className="grid grid-cols-1 gap-4 xl:grid-cols-3"> <div className="grid grid-cols-1 gap-4 xl:grid-cols-3">
<div className="rounded-2xl border border-white/10 bg-white/5 p-5"> <div className="rounded-2xl border border-white/10 bg-white/5 p-5">
<div className="text-xs text-zinc-400">{t("overview.fleetHealth")}</div> <div className="text-xs text-zinc-400">{t("overview.fleetHealth")}</div>

View File

@@ -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<RecapResponse>(initialData);
const [machineId, setMachineId] = useState(initialFilters.machineId || "");
const [shift, setShift] = useState(initialFilters.shift || "shift1");
const [customStart, setCustomStart] = useState(toInputDate(initialFilters.start));
const [customEnd, setCustomEnd] = useState(toInputDate(initialFilters.end));
const [mode, setMode] = useState<RangeMode>(() => {
if (initialFilters.shift) return "shift";
if (initialFilters.start || initialFilters.end) return "custom";
return "24h";
});
const [loading, setLoading] = useState(false);
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 (
<div className="p-4 sm:p-6">
<div className="mb-4 rounded-2xl border border-white/10 bg-black/40 p-4">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div>
<h1 className="text-2xl font-semibold text-white">{t("recap.title")}</h1>
<p className="text-sm text-zinc-400">
{t("recap.subtitle")} · {new Date(data.range.start).toLocaleString(locale)} - {new Date(data.range.end).toLocaleString(locale)}
</p>
</div>
<div className="flex flex-wrap gap-2 text-sm">
<select
value={machineId}
onChange={(event) => setMachineId(event.target.value)}
className="rounded-xl border border-white/10 bg-black/40 px-3 py-2 text-zinc-200"
>
<option value="">{t("recap.allMachines")}</option>
{data.machines.map((m) => (
<option key={m.machineId} value={m.machineId}>
{m.machineName}
</option>
))}
</select>
<select
value={mode}
onChange={(event) => setMode(event.target.value as RangeMode)}
className="rounded-xl border border-white/10 bg-black/40 px-3 py-2 text-zinc-200"
>
<option value="24h">24h</option>
<option value="shift">{t("recap.range.shift")}</option>
<option value="custom">{t("recap.range.custom")}</option>
</select>
{mode === "shift" ? (
<select
value={shift}
onChange={(event) => setShift(event.target.value)}
className="rounded-xl border border-white/10 bg-black/40 px-3 py-2 text-zinc-200"
>
<option value="shift1">{t("recap.shift.1")}</option>
<option value="shift2">{t("recap.shift.2")}</option>
<option value="shift3">{t("recap.shift.3")}</option>
</select>
) : null}
{mode === "custom" ? (
<>
<input
type="datetime-local"
value={customStart}
onChange={(event) => setCustomStart(event.target.value)}
className="rounded-xl border border-white/10 bg-black/40 px-3 py-2 text-zinc-200"
/>
<input
type="datetime-local"
value={customEnd}
onChange={(event) => setCustomEnd(event.target.value)}
className="rounded-xl border border-white/10 bg-black/40 px-3 py-2 text-zinc-200"
/>
</>
) : null}
</div>
</div>
</div>
{bannerMold ? (
<div className="mb-3 rounded-2xl border border-amber-500/40 bg-amber-500/10 p-3 text-sm text-amber-300">
{t("recap.banner.mold")} {selectedMachine?.workOrders.active?.startedAt ? new Date(selectedMachine.workOrders.active.startedAt).toLocaleTimeString(locale, { hour: "2-digit", minute: "2-digit" }) : "--:--"}
</div>
) : null}
{bannerStop ? (
<div className="mb-3 rounded-2xl border border-red-500/40 bg-red-500/10 p-3 text-sm text-red-300">
{t("recap.banner.stopped", { minutes: toMinutesLabel(selectedMachine?.downtime.ongoingStopMin ?? null) })}
</div>
) : null}
{loading ? <div className="mb-3 text-sm text-zinc-400">{t("common.loading")}</div> : null}
<RecapKpiRow oeeAvg={fleet.oeeAvg} goodParts={fleet.good} totalStops={fleet.stops} scrapParts={fleet.scrap} />
<div className="mt-4 grid grid-cols-1 gap-4 xl:grid-cols-2">
<RecapProductionBySku rows={selectedMachine?.production.bySku ?? []} />
<RecapDowntimeTop rows={selectedMachine?.downtime.topReasons ?? []} />
</div>
<div className="mt-4 grid grid-cols-1 gap-4 xl:grid-cols-2">
<RecapWorkOrderStatus
workOrders={
selectedMachine?.workOrders ?? {
completed: [],
active: null,
moldChangeInProgress: false,
}
}
/>
<RecapMachineStatus machine={selectedMachine as RecapMachine | null} />
</div>
</div>
);
}

46
app/(app)/recap/page.tsx Normal file
View File

@@ -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<Record<string, string | string[] | undefined>>;
}) {
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 (
<RecapClient
initialData={initialData}
initialFilters={{
machineId: parsed.machineId ?? "",
shift: parsed.shift ?? "",
start: parsed.start?.toISOString() ?? "",
end: parsed.end?.toISOString() ?? "",
}}
/>
);
}

View File

@@ -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, string | number>) => string;
function fmtPct(v?: number | null) {
if (v === null || v === undefined || Number.isNaN(v)) return "--";
return `${v.toFixed(1)}%`;
}
function fmtDuration(sec?: number | null) {
if (!sec) return "--";
const h = Math.floor(sec / 3600);
const m = Math.floor((sec % 3600) / 60);
if (h > 0) return `${h}h ${m}m`;
return `${m}m`;
}
function downsample<T>(rows: T[], max: number) {
if (rows.length <= max) return rows;
const step = Math.ceil(rows.length / max);
return rows.filter((_, idx) => idx % step === 0);
}
function formatTickLabel(ts: string, range: RangeKey) {
const d = new Date(ts);
if (Number.isNaN(d.getTime())) return ts;
const hh = String(d.getHours()).padStart(2, "0");
const mm = String(d.getMinutes()).padStart(2, "0");
const month = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
if (range === "24h") return `${hh}:${mm}`;
return `${month}-${day}`;
}
function ReportsChartsSkeleton() {
return (
<>
<div className="mt-6 grid grid-cols-1 gap-4 xl:grid-cols-2">
{Array.from({ length: 2 }).map((_, idx) => (
<div key={idx} className="h-[320px] rounded-2xl border border-white/10 bg-white/5" />
))}
</div>
<div className="mt-6 grid grid-cols-1 gap-4 xl:grid-cols-3">
{Array.from({ length: 3 }).map((_, idx) => (
<div key={idx} className="h-[280px] rounded-2xl border border-white/10 bg-white/5" />
))}
</div>
</>
);
}
function buildCsv(report: ReportPayload, t: Translator) {
const rows = new Map<string, Record<string, string | number>>();
const addSeries = (series: ReportTrendPoint[], key: string) => {
for (const p of series) {
const row = rows.get(p.t) ?? { timestamp: p.t };
row[key] = p.v;
rows.set(p.t, row);
}
};
addSeries(report.trend.oee, "oee");
addSeries(report.trend.availability, "availability");
addSeries(report.trend.performance, "performance");
addSeries(report.trend.quality, "quality");
addSeries(report.trend.scrapRate, "scrapRate");
const ordered = [...rows.values()].sort((a, b) => {
const at = new Date(String(a.timestamp)).getTime();
const bt = new Date(String(b.timestamp)).getTime();
return at - bt;
});
const header = ["timestamp", "oee", "availability", "performance", "quality", "scrapRate"].join(",");
const lines = ordered.map((row) =>
[
row.timestamp,
row.oee ?? "",
row.availability ?? "",
row.performance ?? "",
row.quality ?? "",
row.scrapRate ?? "",
]
.map((v) => (v == null ? "" : String(v)))
.join(",")
);
const summary = report.summary;
const downtime = report.downtime;
const sectionLines: string[] = [];
sectionLines.push(
[t("reports.csv.section"), t("reports.csv.key"), t("reports.csv.value")].join(",")
);
const addRow = (section: string, key: string, value: string | number | null | undefined) => {
sectionLines.push(
[section, key, value == null ? "" : String(value)]
.map((v) => (v.includes(",") ? `"${v.replace(/\"/g, '""')}"` : v))
.join(",")
);
};
addRow("summary", "oeeAvg", summary.oeeAvg);
addRow("summary", "availabilityAvg", summary.availabilityAvg);
addRow("summary", "performanceAvg", summary.performanceAvg);
addRow("summary", "qualityAvg", summary.qualityAvg);
addRow("summary", "goodTotal", summary.goodTotal);
addRow("summary", "scrapTotal", summary.scrapTotal);
addRow("summary", "targetTotal", summary.targetTotal);
addRow("summary", "scrapRate", summary.scrapRate);
addRow("summary", "topScrapSku", summary.topScrapSku ?? "");
addRow("summary", "topScrapWorkOrder", summary.topScrapWorkOrder ?? "");
addRow("loss_drivers", "macrostopSec", downtime.macrostopSec);
addRow("loss_drivers", "microstopSec", downtime.microstopSec);
addRow("loss_drivers", "slowCycleCount", downtime.slowCycleCount);
addRow("loss_drivers", "qualitySpikeCount", downtime.qualitySpikeCount);
addRow("loss_drivers", "performanceDegradationCount", downtime.performanceDegradationCount);
addRow("loss_drivers", "oeeDropCount", downtime.oeeDropCount);
for (const bin of report.distribution.cycleTime) {
addRow("cycle_distribution", bin.label, bin.count);
}
if (report.insights?.length) {
report.insights.forEach((note, idx) => addRow("insights", String(idx + 1), note));
}
return [header, ...lines, "", ...sectionLines].join("\n");
}
function downloadText(filename: string, content: string) {
const blob = new Blob([content], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.setAttribute("download", filename);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
function buildPdfHtml(
report: ReportPayload,
rangeLabel: string,
filters: { machine: string; workOrder: string; sku: string },
t: Translator
) {
const summary = report.summary;
const downtime = report.downtime;
const cycleBins = report.distribution.cycleTime;
const insights = report.insights ?? [];
return `
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>${t("reports.pdf.title")}</title>
<style>
body { font-family: Arial, sans-serif; color: #111; margin: 24px; }
h1 { margin: 0 0 6px; }
.meta { margin-bottom: 16px; color: #555; }
.grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
.card { border: 1px solid #ddd; border-radius: 8px; padding: 12px; }
.label { color: #666; font-size: 12px; text-transform: uppercase; letter-spacing: .03em; }
.value { font-size: 18px; font-weight: 600; margin-top: 6px; }
table { width: 100%; border-collapse: collapse; margin-top: 12px; }
th, td { border: 1px solid #ddd; padding: 6px 8px; font-size: 12px; }
th { background: #f5f5f5; text-align: left; }
</style>
</head>
<body>
<h1>${t("reports.title")}</h1>
<div class="meta">${t("reports.pdf.range")}: ${rangeLabel} | ${t("reports.pdf.machine")}: ${filters.machine} | ${t("reports.pdf.workOrder")}: ${filters.workOrder} | ${t("reports.pdf.sku")}: ${filters.sku}</div>
<div class="grid">
<div class="card">
<div class="label">OEE (avg)</div>
<div class="value">${summary.oeeAvg != null ? summary.oeeAvg.toFixed(1) + "%" : "--"}</div>
</div>
<div class="card">
<div class="label">Availability (avg)</div>
<div class="value">${summary.availabilityAvg != null ? summary.availabilityAvg.toFixed(1) + "%" : "--"}</div>
</div>
<div class="card">
<div class="label">Performance (avg)</div>
<div class="value">${summary.performanceAvg != null ? summary.performanceAvg.toFixed(1) + "%" : "--"}</div>
</div>
<div class="card">
<div class="label">Quality (avg)</div>
<div class="value">${summary.qualityAvg != null ? summary.qualityAvg.toFixed(1) + "%" : "--"}</div>
</div>
</div>
<div class="card" style="margin-top: 16px;">
<div class="label">${t("reports.pdf.topLoss")}</div>
<table>
<thead>
<tr><th>${t("reports.pdf.metric")}</th><th>${t("reports.pdf.value")}</th></tr>
</thead>
<tbody>
<tr><td>${t("reports.loss.macrostop")} (sec)</td><td>${downtime.macrostopSec}</td></tr>
<tr><td>${t("reports.loss.microstop")} (sec)</td><td>${downtime.microstopSec}</td></tr>
<tr><td>${t("reports.loss.slowCycle")}</td><td>${downtime.slowCycleCount}</td></tr>
<tr><td>${t("reports.loss.qualitySpike")}</td><td>${downtime.qualitySpikeCount}</td></tr>
<tr><td>${t("reports.loss.perfDegradation")}</td><td>${downtime.performanceDegradationCount}</td></tr>
<tr><td>${t("reports.loss.oeeDrop")}</td><td>${downtime.oeeDropCount}</td></tr>
</tbody>
</table>
</div>
<div class="card" style="margin-top: 16px;">
<div class="label">${t("reports.pdf.qualitySummary")}</div>
<table>
<thead>
<tr><th>${t("reports.pdf.metric")}</th><th>${t("reports.pdf.value")}</th></tr>
</thead>
<tbody>
<tr><td>${t("reports.scrapRate")}</td><td>${summary.scrapRate != null ? summary.scrapRate.toFixed(1) + "%" : "--"}</td></tr>
<tr><td>${t("overview.good")}</td><td>${summary.goodTotal ?? "--"}</td></tr>
<tr><td>${t("overview.scrap")}</td><td>${summary.scrapTotal ?? "--"}</td></tr>
<tr><td>${t("overview.target")}</td><td>${summary.targetTotal ?? "--"}</td></tr>
<tr><td>${t("reports.topScrapSku")}</td><td>${summary.topScrapSku ?? "--"}</td></tr>
<tr><td>${t("reports.topScrapWorkOrder")}</td><td>${summary.topScrapWorkOrder ?? "--"}</td></tr>
</tbody>
</table>
</div>
<div class="card" style="margin-top: 16px;">
<div class="label">${t("reports.pdf.cycleDistribution")}</div>
<table>
<thead>
<tr><th>${t("reports.tooltip.range")}</th><th>${t("reports.tooltip.cycles")}</th></tr>
</thead>
<tbody>
${cycleBins
.map((bin) => `<tr><td>${bin.label}</td><td>${bin.count}</td></tr>`)
.join("")}
</tbody>
</table>
</div>
<div class="card" style="margin-top: 16px;">
<div class="label">${t("reports.pdf.notes")}</div>
${insights.length ? `<ul>${insights.map((n) => `<li>${n}</li>`).join("")}</ul>` : `<div>${t("reports.pdf.none")}</div>`}
</div>
</body>
</html>
`.trim();
}
export default function ReportsPageClient({
initialMachines = [],
}: {
initialMachines?: MachineOption[];
}) {
const { t, locale } = useI18n();
const [range, setRange] = useState<RangeKey>("24h");
const [report, setReport] = useState<ReportPayload | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [machines] = useState<MachineOption[]>(() => initialMachines);
const [filterOptions, setFilterOptions] = useState<FilterOptions>({ workOrders: [], skus: [] });
const [machineId, setMachineId] = useState("");
const [workOrderId, setWorkOrderId] = useState("");
const [sku, setSku] = useState("");
const rangeLabel = useMemo(() => {
if (range === "24h") return t("reports.rangeLabel.last24");
if (range === "7d") return t("reports.rangeLabel.last7");
if (range === "30d") return t("reports.rangeLabel.last30");
return t("reports.rangeLabel.custom");
}, [range, t]);
useEffect(() => {
let alive = true;
const controller = new AbortController();
async function load() {
setLoading(true);
setError(null);
try {
const params = new URLSearchParams({ range });
if (machineId) params.set("machineId", machineId);
if (workOrderId) params.set("workOrderId", workOrderId);
if (sku) params.set("sku", sku);
const res = await fetch(`/api/reports?${params.toString()}`, {
cache: "no-cache",
signal: controller.signal,
});
const json = await res.json();
if (!alive) return;
if (!res.ok || json?.ok === false) {
setError(json?.error ?? t("reports.error.failed"));
setReport(null);
} else {
setReport(json);
}
} catch {
if (!alive) return;
setError(t("reports.error.network"));
setReport(null);
} finally {
if (alive) setLoading(false);
}
}
load();
return () => {
alive = false;
controller.abort();
};
}, [range, machineId, workOrderId, sku, t]);
useEffect(() => {
let alive = true;
const controller = new AbortController();
async function loadFilters() {
try {
const params = new URLSearchParams({ range });
if (machineId) params.set("machineId", machineId);
const res = await fetch(`/api/reports/filters?${params.toString()}`, {
cache: "no-cache",
signal: controller.signal,
});
const json = await res.json();
if (!alive) return;
if (!res.ok || json?.ok === false) {
setFilterOptions({ workOrders: [], skus: [] });
} else {
setFilterOptions({
workOrders: json.workOrders ?? [],
skus: json.skus ?? [],
});
}
} catch {
if (!alive) return;
setFilterOptions({ workOrders: [], skus: [] });
}
}
loadFilters();
return () => {
alive = false;
controller.abort();
};
}, [range, machineId]);
const summary = report?.summary;
const downtime = report?.downtime;
const trend = report?.trend;
const distribution = report?.distribution;
const oeeSeries = useMemo(() => {
const rows = trend?.oee ?? [];
const trimmed = downsample(rows, 600);
return trimmed.map((p) => ({
ts: p.t,
label: formatTickLabel(p.t, range),
value: p.v,
}));
}, [trend?.oee, range]);
const scrapSeries = useMemo(() => {
const rows = trend?.scrapRate ?? [];
const trimmed = downsample(rows, 600);
return trimmed.map((p) => ({
ts: p.t,
label: formatTickLabel(p.t, range),
value: p.v,
}));
}, [trend?.scrapRate, range]);
const cycleHistogram = useMemo(() => {
return distribution?.cycleTime ?? [];
}, [distribution?.cycleTime]);
const downtimeSeries = useMemo(() => {
if (!downtime) return [];
return [
{ name: "Macrostop", value: Math.round(downtime.macrostopSec / 60) },
{ name: "Microstop", value: Math.round(downtime.microstopSec / 60) },
];
}, [downtime]);
const downtimeColors: Record<string, string> = {
Macrostop: "#FF3B5C",
Microstop: "#FF7A00",
};
const lossRows = useMemo(
() => [
{ label: t("reports.loss.macrostop"), value: fmtDuration(downtime?.macrostopSec) },
{ label: t("reports.loss.microstop"), value: fmtDuration(downtime?.microstopSec) },
{ label: t("reports.loss.slowCycle"), value: downtime ? `${downtime.slowCycleCount}` : "--" },
{ label: t("reports.loss.qualitySpike"), value: downtime ? `${downtime.qualitySpikeCount}` : "--" },
{ label: t("reports.loss.oeeDrop"), value: downtime ? `${downtime.oeeDropCount}` : "--" },
{
label: t("reports.loss.perfDegradation"),
value: downtime ? `${downtime.performanceDegradationCount}` : "--",
},
],
[downtime, t]
);
const machineLabel = useMemo(() => {
if (!machineId) return t("reports.filter.allMachines");
return machines.find((m) => m.id === machineId)?.name ?? machineId;
}, [machineId, machines, t]);
const workOrderLabel = workOrderId || t("reports.filter.allWorkOrders");
const skuLabel = sku || t("reports.filter.allSkus");
const handleExportCsv = () => {
if (!report) return;
const csv = buildCsv(report, t);
downloadText("reports.csv", csv);
};
const handleExportPdf = () => {
if (!report) return;
const html = buildPdfHtml(
report,
rangeLabel,
{
machine: machineLabel,
workOrder: workOrderLabel,
sku: skuLabel,
},
t
);
const win = window.open("", "_blank", "width=900,height=650");
if (!win) return;
win.document.open();
win.document.write(html);
win.document.close();
win.focus();
setTimeout(() => win.print(), 300);
};
return (
<div className="p-4 sm:p-6">
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<h1 className="text-2xl font-semibold text-white">{t("reports.title")}</h1>
<p className="text-sm text-zinc-400">{t("reports.subtitle")}</p>
</div>
<div className="flex w-full flex-wrap items-center gap-2 sm:w-auto">
<button
onClick={handleExportCsv}
className="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-white hover:bg-white/10 sm:w-auto"
>
{t("reports.exportCsv")}
</button>
<button
onClick={handleExportPdf}
className="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-white hover:bg-white/10 sm:w-auto"
>
{t("reports.exportPdf")}
</button>
</div>
</div>
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
<div className="flex flex-wrap items-center justify-between gap-4">
<div className="text-sm font-semibold text-white">{t("reports.filters")}</div>
<div className="text-xs text-zinc-400">{rangeLabel}</div>
</div>
<div className="mt-4 grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-4">
<div className="rounded-xl border border-white/10 bg-black/20 p-3">
<div className="text-[11px] text-zinc-400">{t("reports.filter.range")}</div>
<div className="mt-2 flex flex-wrap gap-2">
{(["24h", "7d", "30d", "custom"] as RangeKey[]).map((k) => (
<button
key={k}
onClick={() => setRange(k)}
className={`rounded-full border px-3 py-1 text-xs ${
range === k
? "border-emerald-500/30 bg-emerald-500/15 text-emerald-200"
: "border-white/10 bg-white/5 text-zinc-300 hover:bg-white/10"
}`}
>
{k.toUpperCase()}
</button>
))}
</div>
</div>
<div className="rounded-xl border border-white/10 bg-black/20 p-3">
<div className="text-[11px] text-zinc-400">{t("reports.filter.machine")}</div>
<select
value={machineId}
onChange={(e) => setMachineId(e.target.value)}
className="mt-2 w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm text-zinc-300"
>
<option value="">{t("reports.filter.allMachines")}</option>
{machines.map((m) => (
<option key={m.id} value={m.id}>
{m.name}
</option>
))}
</select>
</div>
<div className="rounded-xl border border-white/10 bg-black/20 p-3">
<div className="text-[11px] text-zinc-400">{t("reports.filter.workOrder")}</div>
<input
list="work-order-list"
value={workOrderId}
onChange={(e) => setWorkOrderId(e.target.value)}
placeholder={t("reports.filter.allWorkOrders")}
className="mt-2 w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm text-zinc-300 placeholder:text-zinc-500"
/>
<datalist id="work-order-list">
{filterOptions.workOrders.map((wo) => (
<option key={wo} value={wo} />
))}
</datalist>
</div>
<div className="rounded-xl border border-white/10 bg-black/20 p-3">
<div className="text-[11px] text-zinc-400">{t("reports.filter.sku")}</div>
<input
list="sku-list"
value={sku}
onChange={(e) => setSku(e.target.value)}
placeholder={t("reports.filter.allSkus")}
className="mt-2 w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm text-zinc-300 placeholder:text-zinc-500"
/>
<datalist id="sku-list">
{filterOptions.skus.map((s) => (
<option key={s} value={s} />
))}
</datalist>
</div>
</div>
</div>
<div className="mt-4">
{loading && <div className="text-sm text-zinc-400">{t("reports.loading")}</div>}
{error && !loading && (
<div className="rounded-2xl border border-red-500/20 bg-red-500/10 p-4 text-sm text-red-200">
{error}
</div>
)}
</div>
<div className="mt-4 grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
{[
{ label: "OEE", value: fmtPct(summary?.oeeAvg), tone: "text-emerald-300" },
{ label: "Availability", value: fmtPct(summary?.availabilityAvg), tone: "text-white" },
{ label: "Performance", value: fmtPct(summary?.performanceAvg), tone: "text-white" },
{ label: "Quality", value: fmtPct(summary?.qualityAvg), tone: "text-white" },
].map((kpi) => (
<div key={kpi.label} className="rounded-2xl border border-white/10 bg-white/5 p-5">
<div className="text-xs text-zinc-400">{kpi.label} (avg)</div>
<div className={`mt-2 text-3xl font-semibold ${kpi.tone}`}>{kpi.value}</div>
<div className="mt-2 text-xs text-zinc-500">
{summary ? t("reports.kpi.note.withData") : t("reports.kpi.note.noData")}
</div>
</div>
))}
</div>
<Suspense fallback={<ReportsChartsSkeleton />}>
<ReportsCharts
oeeSeries={oeeSeries}
downtimeSeries={downtimeSeries}
downtimeColors={downtimeColors}
cycleHistogram={cycleHistogram}
scrapSeries={scrapSeries}
lossRows={lossRows}
locale={locale}
t={t}
/>
</Suspense>
<div className="mt-6 grid grid-cols-1 gap-4 xl:grid-cols-2">
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
<div className="mb-3 text-sm font-semibold text-white">{t("reports.qualitySummary")}</div>
<div className="space-y-3 text-sm text-zinc-300">
<div className="rounded-xl border border-white/10 bg-black/20 p-3">
<div className="text-xs text-zinc-400">{t("reports.scrapRate")}</div>
<div className="mt-1 text-lg font-semibold text-white">
{summary?.scrapRate != null ? fmtPct(summary.scrapRate) : "--"}
</div>
</div>
<div className="rounded-xl border border-white/10 bg-black/20 p-3">
<div className="text-xs text-zinc-400">{t("reports.topScrapSku")}</div>
<div className="mt-1 text-sm text-zinc-300">{summary?.topScrapSku ?? "--"}</div>
</div>
<div className="rounded-xl border border-white/10 bg-black/20 p-3">
<div className="text-xs text-zinc-400">{t("reports.topScrapWorkOrder")}</div>
<div className="mt-1 text-sm text-zinc-300">{summary?.topScrapWorkOrder ?? "--"}</div>
</div>
</div>
</div>
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
<div className="mb-3 text-sm font-semibold text-white">{t("reports.notes")}</div>
<div className="rounded-xl border border-white/10 bg-black/20 p-4 text-sm text-zinc-300">
<div className="mb-2 text-xs text-zinc-400">{t("reports.notes.suggested")}</div>
{report?.insights && report.insights.length > 0 ? (
<div className="space-y-2">
{report.insights.map((note, idx) => (
<div key={idx}>{note}</div>
))}
</div>
) : (
<div>{t("reports.notes.none")}</div>
)}
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,713 +1,17 @@
"use client"; import { redirect } from "next/navigation";
import { prisma } from "@/lib/prisma";
import { requireSession } from "@/lib/auth/requireSession";
import ReportsPageClient from "./ReportsPageClient";
import { Suspense, lazy, useEffect, useMemo, useState } from "react"; export default async function ReportsPage() {
import { useI18n } from "@/lib/i18n/useI18n"; const session = await requireSession();
if (!session) redirect("/login?next=/reports");
const ReportsCharts = lazy(() => import("./ReportsCharts")); const machines = await prisma.machine.findMany({
where: { orgId: session.orgId },
type RangeKey = "24h" | "7d" | "30d" | "custom"; orderBy: { createdAt: "desc" },
select: { id: true, name: true },
type ReportSummary = {
oeeAvg: number | null;
availabilityAvg: number | null;
performanceAvg: number | null;
qualityAvg: number | null;
goodTotal: number | null;
scrapTotal: number | null;
targetTotal: number | null;
scrapRate: number | null;
topScrapSku?: string | null;
topScrapWorkOrder?: string | null;
};
type ReportDowntime = {
macrostopSec: number;
microstopSec: number;
slowCycleCount: number;
qualitySpikeCount: number;
performanceDegradationCount: number;
oeeDropCount: number;
};
type ReportTrendPoint = { t: string; v: number };
type ReportPayload = {
summary: ReportSummary;
downtime: ReportDowntime;
trend: {
oee: ReportTrendPoint[];
availability: ReportTrendPoint[];
performance: ReportTrendPoint[];
quality: ReportTrendPoint[];
scrapRate: ReportTrendPoint[];
};
distribution: {
cycleTime: {
label: string;
count: number;
rangeStart?: number;
rangeEnd?: number;
overflow?: "low" | "high";
minValue?: number;
maxValue?: number;
}[];
};
insights?: string[];
};
type MachineOption = { id: string; name: string };
type FilterOptions = { workOrders: string[]; skus: string[] };
type Translator = (key: string, vars?: Record<string, string | number>) => string;
function fmtPct(v?: number | null) {
if (v === null || v === undefined || Number.isNaN(v)) return "--";
return `${v.toFixed(1)}%`;
}
function fmtDuration(sec?: number | null) {
if (!sec) return "--";
const h = Math.floor(sec / 3600);
const m = Math.floor((sec % 3600) / 60);
if (h > 0) return `${h}h ${m}m`;
return `${m}m`;
}
function downsample<T>(rows: T[], max: number) {
if (rows.length <= max) return rows;
const step = Math.ceil(rows.length / max);
return rows.filter((_, idx) => idx % step === 0);
}
function formatTickLabel(ts: string, range: RangeKey) {
const d = new Date(ts);
if (Number.isNaN(d.getTime())) return ts;
const hh = String(d.getHours()).padStart(2, "0");
const mm = String(d.getMinutes()).padStart(2, "0");
const month = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
if (range === "24h") return `${hh}:${mm}`;
return `${month}-${day}`;
}
function ReportsChartsSkeleton() {
return (
<>
<div className="mt-6 grid grid-cols-1 gap-4 xl:grid-cols-2">
{Array.from({ length: 2 }).map((_, idx) => (
<div key={idx} className="h-[320px] rounded-2xl border border-white/10 bg-white/5" />
))}
</div>
<div className="mt-6 grid grid-cols-1 gap-4 xl:grid-cols-3">
{Array.from({ length: 3 }).map((_, idx) => (
<div key={idx} className="h-[280px] rounded-2xl border border-white/10 bg-white/5" />
))}
</div>
</>
);
}
function toMachineOption(value: unknown): MachineOption | null {
if (!value || typeof value !== "object") return null;
const record = value as Record<string, unknown>;
const id = typeof record.id === "string" ? record.id : "";
const name = typeof record.name === "string" ? record.name : "";
if (!id || !name) return null;
return { id, name };
}
function buildCsv(report: ReportPayload, t: Translator) {
const rows = new Map<string, Record<string, string | number>>();
const addSeries = (series: ReportTrendPoint[], key: string) => {
for (const p of series) {
const row = rows.get(p.t) ?? { timestamp: p.t };
row[key] = p.v;
rows.set(p.t, row);
}
};
addSeries(report.trend.oee, "oee");
addSeries(report.trend.availability, "availability");
addSeries(report.trend.performance, "performance");
addSeries(report.trend.quality, "quality");
addSeries(report.trend.scrapRate, "scrapRate");
const ordered = [...rows.values()].sort((a, b) => {
const at = new Date(String(a.timestamp)).getTime();
const bt = new Date(String(b.timestamp)).getTime();
return at - bt;
}); });
const header = ["timestamp", "oee", "availability", "performance", "quality", "scrapRate"].join(","); return <ReportsPageClient initialMachines={machines} />;
const lines = ordered.map((row) =>
[
row.timestamp,
row.oee ?? "",
row.availability ?? "",
row.performance ?? "",
row.quality ?? "",
row.scrapRate ?? "",
]
.map((v) => (v == null ? "" : String(v)))
.join(",")
);
const summary = report.summary;
const downtime = report.downtime;
const sectionLines: string[] = [];
sectionLines.push(
[t("reports.csv.section"), t("reports.csv.key"), t("reports.csv.value")].join(",")
);
const addRow = (section: string, key: string, value: string | number | null | undefined) => {
sectionLines.push(
[section, key, value == null ? "" : String(value)]
.map((v) => (v.includes(",") ? `"${v.replace(/\"/g, '""')}"` : v))
.join(",")
);
};
addRow("summary", "oeeAvg", summary.oeeAvg);
addRow("summary", "availabilityAvg", summary.availabilityAvg);
addRow("summary", "performanceAvg", summary.performanceAvg);
addRow("summary", "qualityAvg", summary.qualityAvg);
addRow("summary", "goodTotal", summary.goodTotal);
addRow("summary", "scrapTotal", summary.scrapTotal);
addRow("summary", "targetTotal", summary.targetTotal);
addRow("summary", "scrapRate", summary.scrapRate);
addRow("summary", "topScrapSku", summary.topScrapSku ?? "");
addRow("summary", "topScrapWorkOrder", summary.topScrapWorkOrder ?? "");
addRow("loss_drivers", "macrostopSec", downtime.macrostopSec);
addRow("loss_drivers", "microstopSec", downtime.microstopSec);
addRow("loss_drivers", "slowCycleCount", downtime.slowCycleCount);
addRow("loss_drivers", "qualitySpikeCount", downtime.qualitySpikeCount);
addRow("loss_drivers", "performanceDegradationCount", downtime.performanceDegradationCount);
addRow("loss_drivers", "oeeDropCount", downtime.oeeDropCount);
for (const bin of report.distribution.cycleTime) {
addRow("cycle_distribution", bin.label, bin.count);
}
if (report.insights?.length) {
report.insights.forEach((note, idx) => addRow("insights", String(idx + 1), note));
}
return [header, ...lines, "", ...sectionLines].join("\n");
}
function downloadText(filename: string, content: string) {
const blob = new Blob([content], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.setAttribute("download", filename);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
function buildPdfHtml(
report: ReportPayload,
rangeLabel: string,
filters: { machine: string; workOrder: string; sku: string },
t: Translator
) {
const summary = report.summary;
const downtime = report.downtime;
const cycleBins = report.distribution.cycleTime;
const insights = report.insights ?? [];
return `
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>${t("reports.pdf.title")}</title>
<style>
body { font-family: Arial, sans-serif; color: #111; margin: 24px; }
h1 { margin: 0 0 6px; }
.meta { margin-bottom: 16px; color: #555; }
.grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
.card { border: 1px solid #ddd; border-radius: 8px; padding: 12px; }
.label { color: #666; font-size: 12px; text-transform: uppercase; letter-spacing: .03em; }
.value { font-size: 18px; font-weight: 600; margin-top: 6px; }
table { width: 100%; border-collapse: collapse; margin-top: 12px; }
th, td { border: 1px solid #ddd; padding: 6px 8px; font-size: 12px; }
th { background: #f5f5f5; text-align: left; }
</style>
</head>
<body>
<h1>${t("reports.title")}</h1>
<div class="meta">${t("reports.pdf.range")}: ${rangeLabel} | ${t("reports.pdf.machine")}: ${filters.machine} | ${t("reports.pdf.workOrder")}: ${filters.workOrder} | ${t("reports.pdf.sku")}: ${filters.sku}</div>
<div class="grid">
<div class="card">
<div class="label">OEE (avg)</div>
<div class="value">${summary.oeeAvg != null ? summary.oeeAvg.toFixed(1) + "%" : "--"}</div>
</div>
<div class="card">
<div class="label">Availability (avg)</div>
<div class="value">${summary.availabilityAvg != null ? summary.availabilityAvg.toFixed(1) + "%" : "--"}</div>
</div>
<div class="card">
<div class="label">Performance (avg)</div>
<div class="value">${summary.performanceAvg != null ? summary.performanceAvg.toFixed(1) + "%" : "--"}</div>
</div>
<div class="card">
<div class="label">Quality (avg)</div>
<div class="value">${summary.qualityAvg != null ? summary.qualityAvg.toFixed(1) + "%" : "--"}</div>
</div>
</div>
<div class="card" style="margin-top: 16px;">
<div class="label">${t("reports.pdf.topLoss")}</div>
<table>
<thead>
<tr><th>${t("reports.pdf.metric")}</th><th>${t("reports.pdf.value")}</th></tr>
</thead>
<tbody>
<tr><td>${t("reports.loss.macrostop")} (sec)</td><td>${downtime.macrostopSec}</td></tr>
<tr><td>${t("reports.loss.microstop")} (sec)</td><td>${downtime.microstopSec}</td></tr>
<tr><td>${t("reports.loss.slowCycle")}</td><td>${downtime.slowCycleCount}</td></tr>
<tr><td>${t("reports.loss.qualitySpike")}</td><td>${downtime.qualitySpikeCount}</td></tr>
<tr><td>${t("reports.loss.perfDegradation")}</td><td>${downtime.performanceDegradationCount}</td></tr>
<tr><td>${t("reports.loss.oeeDrop")}</td><td>${downtime.oeeDropCount}</td></tr>
</tbody>
</table>
</div>
<div class="card" style="margin-top: 16px;">
<div class="label">${t("reports.pdf.qualitySummary")}</div>
<table>
<thead>
<tr><th>${t("reports.pdf.metric")}</th><th>${t("reports.pdf.value")}</th></tr>
</thead>
<tbody>
<tr><td>${t("reports.scrapRate")}</td><td>${summary.scrapRate != null ? summary.scrapRate.toFixed(1) + "%" : "--"}</td></tr>
<tr><td>${t("overview.good")}</td><td>${summary.goodTotal ?? "--"}</td></tr>
<tr><td>${t("overview.scrap")}</td><td>${summary.scrapTotal ?? "--"}</td></tr>
<tr><td>${t("overview.target")}</td><td>${summary.targetTotal ?? "--"}</td></tr>
<tr><td>${t("reports.topScrapSku")}</td><td>${summary.topScrapSku ?? "--"}</td></tr>
<tr><td>${t("reports.topScrapWorkOrder")}</td><td>${summary.topScrapWorkOrder ?? "--"}</td></tr>
</tbody>
</table>
</div>
<div class="card" style="margin-top: 16px;">
<div class="label">${t("reports.pdf.cycleDistribution")}</div>
<table>
<thead>
<tr><th>${t("reports.tooltip.range")}</th><th>${t("reports.tooltip.cycles")}</th></tr>
</thead>
<tbody>
${cycleBins
.map((bin) => `<tr><td>${bin.label}</td><td>${bin.count}</td></tr>`)
.join("")}
</tbody>
</table>
</div>
<div class="card" style="margin-top: 16px;">
<div class="label">${t("reports.pdf.notes")}</div>
${insights.length ? `<ul>${insights.map((n) => `<li>${n}</li>`).join("")}</ul>` : `<div>${t("reports.pdf.none")}</div>`}
</div>
</body>
</html>
`.trim();
}
export default function ReportsPage() {
const { t, locale } = useI18n();
const [range, setRange] = useState<RangeKey>("24h");
const [report, setReport] = useState<ReportPayload | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [machines, setMachines] = useState<MachineOption[]>([]);
const [filterOptions, setFilterOptions] = useState<FilterOptions>({ workOrders: [], skus: [] });
const [machineId, setMachineId] = useState("");
const [workOrderId, setWorkOrderId] = useState("");
const [sku, setSku] = useState("");
const rangeLabel = useMemo(() => {
if (range === "24h") return t("reports.rangeLabel.last24");
if (range === "7d") return t("reports.rangeLabel.last7");
if (range === "30d") return t("reports.rangeLabel.last30");
return t("reports.rangeLabel.custom");
}, [range, t]);
useEffect(() => {
let alive = true;
async function loadMachines() {
try {
const res = await fetch("/api/machines", { cache: "no-store" });
const json = await res.json();
if (!alive) return;
const rows: unknown[] = Array.isArray(json?.machines) ? json.machines : [];
const options: MachineOption[] = [];
rows.forEach((row) => {
const option = toMachineOption(row);
if (option) options.push(option);
});
setMachines(options);
} catch {
if (!alive) return;
setMachines([]);
}
}
loadMachines();
return () => {
alive = false;
};
}, []);
useEffect(() => {
let alive = true;
const controller = new AbortController();
async function load() {
setLoading(true);
setError(null);
try {
const params = new URLSearchParams({ range });
if (machineId) params.set("machineId", machineId);
if (workOrderId) params.set("workOrderId", workOrderId);
if (sku) params.set("sku", sku);
const res = await fetch(`/api/reports?${params.toString()}`, {
cache: "no-store",
signal: controller.signal,
});
const json = await res.json();
if (!alive) return;
if (!res.ok || json?.ok === false) {
setError(json?.error ?? t("reports.error.failed"));
setReport(null);
} else {
setReport(json);
}
} catch {
if (!alive) return;
setError(t("reports.error.network"));
setReport(null);
} finally {
if (alive) setLoading(false);
}
}
load();
return () => {
alive = false;
controller.abort();
};
}, [range, machineId, workOrderId, sku, t]);
useEffect(() => {
let alive = true;
const controller = new AbortController();
async function loadFilters() {
try {
const params = new URLSearchParams({ range });
if (machineId) params.set("machineId", machineId);
const res = await fetch(`/api/reports/filters?${params.toString()}`, {
cache: "no-store",
signal: controller.signal,
});
const json = await res.json();
if (!alive) return;
if (!res.ok || json?.ok === false) {
setFilterOptions({ workOrders: [], skus: [] });
} else {
setFilterOptions({
workOrders: json.workOrders ?? [],
skus: json.skus ?? [],
});
}
} catch {
if (!alive) return;
setFilterOptions({ workOrders: [], skus: [] });
}
}
loadFilters();
return () => {
alive = false;
controller.abort();
};
}, [range, machineId]);
const summary = report?.summary;
const downtime = report?.downtime;
const trend = report?.trend;
const distribution = report?.distribution;
const oeeSeries = useMemo(() => {
const rows = trend?.oee ?? [];
const trimmed = downsample(rows, 600);
return trimmed.map((p) => ({
ts: p.t,
label: formatTickLabel(p.t, range),
value: p.v,
}));
}, [trend?.oee, range]);
const scrapSeries = useMemo(() => {
const rows = trend?.scrapRate ?? [];
const trimmed = downsample(rows, 600);
return trimmed.map((p) => ({
ts: p.t,
label: formatTickLabel(p.t, range),
value: p.v,
}));
}, [trend?.scrapRate, range]);
const cycleHistogram = useMemo(() => {
return distribution?.cycleTime ?? [];
}, [distribution?.cycleTime]);
const downtimeSeries = useMemo(() => {
if (!downtime) return [];
return [
{ name: "Macrostop", value: Math.round(downtime.macrostopSec / 60) },
{ name: "Microstop", value: Math.round(downtime.microstopSec / 60) },
];
}, [downtime]);
const downtimeColors: Record<string, string> = {
Macrostop: "#FF3B5C",
Microstop: "#FF7A00",
};
const lossRows = useMemo(
() => [
{ label: t("reports.loss.macrostop"), value: fmtDuration(downtime?.macrostopSec) },
{ label: t("reports.loss.microstop"), value: fmtDuration(downtime?.microstopSec) },
{ label: t("reports.loss.slowCycle"), value: downtime ? `${downtime.slowCycleCount}` : "--" },
{ label: t("reports.loss.qualitySpike"), value: downtime ? `${downtime.qualitySpikeCount}` : "--" },
{ label: t("reports.loss.oeeDrop"), value: downtime ? `${downtime.oeeDropCount}` : "--" },
{
label: t("reports.loss.perfDegradation"),
value: downtime ? `${downtime.performanceDegradationCount}` : "--",
},
],
[downtime, t]
);
const machineLabel = useMemo(() => {
if (!machineId) return t("reports.filter.allMachines");
return machines.find((m) => m.id === machineId)?.name ?? machineId;
}, [machineId, machines, t]);
const workOrderLabel = workOrderId || t("reports.filter.allWorkOrders");
const skuLabel = sku || t("reports.filter.allSkus");
const handleExportCsv = () => {
if (!report) return;
const csv = buildCsv(report, t);
downloadText("reports.csv", csv);
};
const handleExportPdf = () => {
if (!report) return;
const html = buildPdfHtml(
report,
rangeLabel,
{
machine: machineLabel,
workOrder: workOrderLabel,
sku: skuLabel,
},
t
);
const win = window.open("", "_blank", "width=900,height=650");
if (!win) return;
win.document.open();
win.document.write(html);
win.document.close();
win.focus();
setTimeout(() => win.print(), 300);
};
return (
<div className="p-4 sm:p-6">
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<h1 className="text-2xl font-semibold text-white">{t("reports.title")}</h1>
<p className="text-sm text-zinc-400">{t("reports.subtitle")}</p>
</div>
<div className="flex w-full flex-wrap items-center gap-2 sm:w-auto">
<button
onClick={handleExportCsv}
className="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-white hover:bg-white/10 sm:w-auto"
>
{t("reports.exportCsv")}
</button>
<button
onClick={handleExportPdf}
className="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-white hover:bg-white/10 sm:w-auto"
>
{t("reports.exportPdf")}
</button>
</div>
</div>
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
<div className="flex flex-wrap items-center justify-between gap-4">
<div className="text-sm font-semibold text-white">{t("reports.filters")}</div>
<div className="text-xs text-zinc-400">{rangeLabel}</div>
</div>
<div className="mt-4 grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-4">
<div className="rounded-xl border border-white/10 bg-black/20 p-3">
<div className="text-[11px] text-zinc-400">{t("reports.filter.range")}</div>
<div className="mt-2 flex flex-wrap gap-2">
{(["24h", "7d", "30d", "custom"] as RangeKey[]).map((k) => (
<button
key={k}
onClick={() => setRange(k)}
className={`rounded-full border px-3 py-1 text-xs ${
range === k
? "border-emerald-500/30 bg-emerald-500/15 text-emerald-200"
: "border-white/10 bg-white/5 text-zinc-300 hover:bg-white/10"
}`}
>
{k.toUpperCase()}
</button>
))}
</div>
</div>
<div className="rounded-xl border border-white/10 bg-black/20 p-3">
<div className="text-[11px] text-zinc-400">{t("reports.filter.machine")}</div>
<select
value={machineId}
onChange={(e) => setMachineId(e.target.value)}
className="mt-2 w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm text-zinc-300"
>
<option value="">{t("reports.filter.allMachines")}</option>
{machines.map((m) => (
<option key={m.id} value={m.id}>
{m.name}
</option>
))}
</select>
</div>
<div className="rounded-xl border border-white/10 bg-black/20 p-3">
<div className="text-[11px] text-zinc-400">{t("reports.filter.workOrder")}</div>
<input
list="work-order-list"
value={workOrderId}
onChange={(e) => setWorkOrderId(e.target.value)}
placeholder={t("reports.filter.allWorkOrders")}
className="mt-2 w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm text-zinc-300 placeholder:text-zinc-500"
/>
<datalist id="work-order-list">
{filterOptions.workOrders.map((wo) => (
<option key={wo} value={wo} />
))}
</datalist>
</div>
<div className="rounded-xl border border-white/10 bg-black/20 p-3">
<div className="text-[11px] text-zinc-400">{t("reports.filter.sku")}</div>
<input
list="sku-list"
value={sku}
onChange={(e) => setSku(e.target.value)}
placeholder={t("reports.filter.allSkus")}
className="mt-2 w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm text-zinc-300 placeholder:text-zinc-500"
/>
<datalist id="sku-list">
{filterOptions.skus.map((s) => (
<option key={s} value={s} />
))}
</datalist>
</div>
</div>
</div>
<div className="mt-4">
{loading && <div className="text-sm text-zinc-400">{t("reports.loading")}</div>}
{error && !loading && (
<div className="rounded-2xl border border-red-500/20 bg-red-500/10 p-4 text-sm text-red-200">
{error}
</div>
)}
</div>
<div className="mt-4 grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
{[
{ label: "OEE", value: fmtPct(summary?.oeeAvg), tone: "text-emerald-300" },
{ label: "Availability", value: fmtPct(summary?.availabilityAvg), tone: "text-white" },
{ label: "Performance", value: fmtPct(summary?.performanceAvg), tone: "text-white" },
{ label: "Quality", value: fmtPct(summary?.qualityAvg), tone: "text-white" },
].map((kpi) => (
<div key={kpi.label} className="rounded-2xl border border-white/10 bg-white/5 p-5">
<div className="text-xs text-zinc-400">{kpi.label} (avg)</div>
<div className={`mt-2 text-3xl font-semibold ${kpi.tone}`}>{kpi.value}</div>
<div className="mt-2 text-xs text-zinc-500">
{summary ? t("reports.kpi.note.withData") : t("reports.kpi.note.noData")}
</div>
</div>
))}
</div>
<Suspense fallback={<ReportsChartsSkeleton />}>
<ReportsCharts
oeeSeries={oeeSeries}
downtimeSeries={downtimeSeries}
downtimeColors={downtimeColors}
cycleHistogram={cycleHistogram}
scrapSeries={scrapSeries}
lossRows={lossRows}
locale={locale}
t={t}
/>
</Suspense>
<div className="mt-6 grid grid-cols-1 gap-4 xl:grid-cols-2">
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
<div className="mb-3 text-sm font-semibold text-white">{t("reports.qualitySummary")}</div>
<div className="space-y-3 text-sm text-zinc-300">
<div className="rounded-xl border border-white/10 bg-black/20 p-3">
<div className="text-xs text-zinc-400">{t("reports.scrapRate")}</div>
<div className="mt-1 text-lg font-semibold text-white">
{summary?.scrapRate != null ? fmtPct(summary.scrapRate) : "--"}
</div>
</div>
<div className="rounded-xl border border-white/10 bg-black/20 p-3">
<div className="text-xs text-zinc-400">{t("reports.topScrapSku")}</div>
<div className="mt-1 text-sm text-zinc-300">{summary?.topScrapSku ?? "--"}</div>
</div>
<div className="rounded-xl border border-white/10 bg-black/20 p-3">
<div className="text-xs text-zinc-400">{t("reports.topScrapWorkOrder")}</div>
<div className="mt-1 text-sm text-zinc-300">{summary?.topScrapWorkOrder ?? "--"}</div>
</div>
</div>
</div>
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
<div className="mb-3 text-sm font-semibold text-white">{t("reports.notes")}</div>
<div className="rounded-xl border border-white/10 bg-black/20 p-4 text-sm text-zinc-300">
<div className="mb-2 text-xs text-zinc-400">{t("reports.notes.suggested")}</div>
{report?.insights && report.insights.length > 0 ? (
<div className="space-y-2">
{report.insights.map((note, idx) => (
<div key={idx}>{note}</div>
))}
</div>
) : (
<div>{t("reports.notes.none")}</div>
)}
</div>
</div>
</div>
</div>
);
} }

View File

@@ -439,7 +439,9 @@ export async function POST(req: Request) {
// If the payload carries a `reason`, create the corresponding ReasonEntry. // 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 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<string, unknown> = const reasonRaw: Record<string, unknown> =
evReason ?? evReason ??
({ ({

29
app/api/recap/route.ts Normal file
View File

@@ -0,0 +1,29 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { requireSession } from "@/lib/auth/requireSession";
import { getRecapDataCached, parseRecapQuery } from "@/lib/recap/getRecapData";
export async function GET(req: NextRequest) {
const session = await requireSession();
if (!session) {
return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
}
const url = new URL(req.url);
const query = parseRecapQuery({
machineId: url.searchParams.get("machineId"),
start: url.searchParams.get("start"),
end: url.searchParams.get("end"),
shift: url.searchParams.get("shift"),
});
const recap = await getRecapDataCached({
orgId: session.orgId,
machineId: query.machineId,
start: query.start ?? undefined,
end: query.end ?? undefined,
shift: query.shift ?? undefined,
});
return NextResponse.json(recap);
}

View File

@@ -1,5 +1,6 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import type { NextRequest } from "next/server"; import type { NextRequest } from "next/server";
import { createHash } from "crypto";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { requireSession } from "@/lib/auth/requireSession"; import { requireSession } from "@/lib/auth/requireSession";
import { logLine } from "@/lib/logger"; import { logLine } from "@/lib/logger";
@@ -42,6 +43,10 @@ function pickRange(req: NextRequest) {
return { start: new Date(now.getTime() - ms), end: now }; return { start: new Date(now.getTime() - ms), end: now };
} }
function toMs(value?: Date | null) {
return value ? value.getTime() : 0;
}
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
const perfEnabled = PERF_LOGS_ENABLED; const perfEnabled = PERF_LOGS_ENABLED;
const totalStart = nowMs(); const totalStart = nowMs();
@@ -67,6 +72,32 @@ export async function GET(req: NextRequest) {
if (perfEnabled) timings.preQuery = elapsedMs(preQueryStart); if (perfEnabled) timings.preQuery = elapsedMs(preQueryStart);
const versionStart = nowMs();
const cycleMax = await prisma.machineCycle.aggregate({
where: baseWhere,
_max: { tsServer: true },
});
if (perfEnabled) timings.version = elapsedMs(versionStart);
const versionParts = [
session.orgId,
range,
machineId ?? "",
toMs(cycleMax._max.tsServer),
];
const etag = `W/"${createHash("sha1").update(versionParts.join("|")).digest("hex")}"`;
const responseHeaders = new Headers({
"Cache-Control": "private, no-cache, max-age=0, must-revalidate",
ETag: etag,
"Last-Modified": new Date(toMs(cycleMax._max.tsServer) || 0).toUTCString(),
Vary: "Cookie",
});
const ifNoneMatch = req.headers.get("if-none-match");
if (ifNoneMatch && ifNoneMatch === etag) {
return new NextResponse(null, { status: 304, headers: responseHeaders });
}
const workOrdersStart = nowMs(); const workOrdersStart = nowMs();
const workOrderRows = await prisma.machineCycle.findMany({ const workOrderRows = await prisma.machineCycle.findMany({
where: { ...baseWhere, workOrderId: { not: null } }, where: { ...baseWhere, workOrderId: { not: null } },
@@ -90,7 +121,6 @@ export async function GET(req: NextRequest) {
const payload = { ok: true, workOrders, skus }; const payload = { ok: true, workOrders, skus };
const responseHeaders = new Headers();
if (perfEnabled) { if (perfEnabled) {
timings.postQuery = elapsedMs(postQueryStart); timings.postQuery = elapsedMs(postQueryStart);
timings.total = elapsedMs(totalStart); timings.total = elapsedMs(totalStart);

View File

@@ -1,5 +1,6 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import type { NextRequest } from "next/server"; import type { NextRequest } from "next/server";
import { createHash } from "crypto";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { requireSession } from "@/lib/auth/requireSession"; import { requireSession } from "@/lib/auth/requireSession";
import { logLine } from "@/lib/logger"; import { logLine } from "@/lib/logger";
@@ -46,6 +47,10 @@ function safeNum(v: unknown) {
return typeof v === "number" && Number.isFinite(v) ? v : null; 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) { export async function GET(req: NextRequest) {
const perfEnabled = PERF_LOGS_ENABLED; const perfEnabled = PERF_LOGS_ENABLED;
const totalStart = nowMs(); const totalStart = nowMs();
@@ -73,6 +78,52 @@ export async function GET(req: NextRequest) {
if (perfEnabled) timings.preQuery = elapsedMs(preQueryStart); if (perfEnabled) timings.preQuery = elapsedMs(preQueryStart);
const versionStart = nowMs();
const [kpiMax, cycleMax, eventMax] = await Promise.all([
prisma.machineKpiSnapshot.aggregate({
where: { ...baseWhere, ts: { gte: start, lte: end } },
_max: { tsServer: true },
}),
prisma.machineCycle.aggregate({
where: { ...baseWhere, ts: { gte: start, lte: end } },
_max: { tsServer: true },
}),
prisma.machineEvent.aggregate({
where: { ...baseWhere, ts: { gte: start, lte: end } },
_max: { tsServer: true },
}),
]);
if (perfEnabled) timings.version = elapsedMs(versionStart);
const lastModifiedMs = Math.max(
toMs(kpiMax._max.tsServer),
toMs(cycleMax._max.tsServer),
toMs(eventMax._max.tsServer)
);
const versionParts = [
session.orgId,
range,
machineId ?? "",
workOrderId ?? "",
sku ?? "",
toMs(kpiMax._max.tsServer),
toMs(cycleMax._max.tsServer),
toMs(eventMax._max.tsServer),
];
const etag = `W/"${createHash("sha1").update(versionParts.join("|")).digest("hex")}"`;
const responseHeaders = new Headers({
"Cache-Control": "private, no-cache, max-age=0, must-revalidate",
ETag: etag,
"Last-Modified": new Date(lastModifiedMs || 0).toUTCString(),
Vary: "Cookie",
});
const ifNoneMatch = req.headers.get("if-none-match");
if (ifNoneMatch && ifNoneMatch === etag) {
return new NextResponse(null, { status: 304, headers: responseHeaders });
}
const kpiStart = nowMs(); const kpiStart = nowMs();
const kpiRows = await prisma.machineKpiSnapshot.findMany({ const kpiRows = await prisma.machineKpiSnapshot.findMany({
where: { ...baseWhere, ts: { gte: start, lte: end } }, where: { ...baseWhere, ts: { gte: start, lte: end } },
@@ -405,7 +456,6 @@ export async function GET(req: NextRequest) {
}, },
}; };
const responseHeaders = new Headers();
if (perfEnabled) { if (perfEnabled) {
timings.postQuery = elapsedMs(postQueryStart); timings.postQuery = elapsedMs(postQueryStart);
timings.total = elapsedMs(totalStart); timings.total = elapsedMs(totalStart);

View File

@@ -3,7 +3,18 @@
import Link from "next/link"; import Link from "next/link";
import { usePathname, useRouter } from "next/navigation"; import { usePathname, useRouter } from "next/navigation";
import { useEffect, useMemo, useState, useTransition } from "react"; import { useEffect, useMemo, useState, useTransition } from "react";
import { BarChart3, Bell, DollarSign, LayoutGrid, Loader2, LogOut, Settings, Wrench, X } from "lucide-react"; import {
BarChart3,
Bell,
DollarSign,
LayoutGrid,
Loader2,
LogOut,
Settings,
Sunrise,
Wrench,
X,
} from "lucide-react";
import type { LucideIcon } from "lucide-react"; import type { LucideIcon } from "lucide-react";
import { useI18n } from "@/lib/i18n/useI18n"; import { useI18n } from "@/lib/i18n/useI18n";
import { useScreenlessMode } from "@/lib/ui/screenlessMode"; import { useScreenlessMode } from "@/lib/ui/screenlessMode";
@@ -24,10 +35,10 @@ const items: NavItem[] = [
{ href: "/reports", labelKey: "nav.reports", icon: BarChart3 }, { href: "/reports", labelKey: "nav.reports", icon: BarChart3 },
{ href: "/alerts", labelKey: "nav.alerts", icon: Bell }, { href: "/alerts", labelKey: "nav.alerts", icon: Bell },
{ href: "/financial", labelKey: "nav.financial", icon: DollarSign, ownerOnly: true }, { href: "/financial", labelKey: "nav.financial", icon: DollarSign, ownerOnly: true },
{ href: "/settings", labelKey: "nav.settings", icon: Settings },
{ href: "/downtime", labelKey: "nav.downtime", icon: BarChart3 }, { href: "/downtime", labelKey: "nav.downtime", icon: BarChart3 },
{ href: "/recap", labelKey: "nav.recap", icon: Sunrise },
]; ];
const settingsItem: NavItem = { href: "/settings", labelKey: "nav.settings", icon: Settings };
type SidebarProps = { type SidebarProps = {
variant?: "desktop" | "drawer"; variant?: "desktop" | "drawer";
@@ -97,16 +108,7 @@ export function Sidebar({ variant = "desktop", onNavigate, onClose }: SidebarPro
} }
}, [screenlessMode, pathname, router]); }, [screenlessMode, pathname, router]);
useEffect(() => { const markNavStart = (href: string, ts: number) => {
if (!pendingHref) return;
if (pathname === pendingHref || pathname.startsWith(`${pendingHref}/`)) {
setPendingHref(null);
} else if (!isPending) {
setPendingHref(null);
}
}, [pathname, pendingHref, isPending]);
const markNavStart = (href: string) => {
if (!PERF_ENABLED) return; if (!PERF_ENABLED) return;
try { try {
sessionStorage.setItem( sessionStorage.setItem(
@@ -114,7 +116,7 @@ export function Sidebar({ variant = "desktop", onNavigate, onClose }: SidebarPro
JSON.stringify({ JSON.stringify({
href, href,
from: pathname, from: pathname,
ts: Date.now(), ts,
}) })
); );
} catch { } catch {
@@ -128,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", "relative z-20 flex flex-col border-r border-white/10 bg-black/40 shrink-0",
variant === "desktop" ? "hidden md:flex h-screen w-64" : "flex h-full w-72 max-w-[85vw]", variant === "desktop" ? "hidden md:flex h-screen w-64" : "flex h-full w-72 max-w-[85vw]",
].join(" "); ].join(" ");
const navLocked = isPending;
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 (
<Link
key={it.href}
href={it.href}
prefetch={false}
aria-disabled={navLocked}
onClick={(event) => {
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(" ")}
>
<Icon className="h-4 w-4" />
<span>{t(it.labelKey)}</span>
{isPendingItem ? <Loader2 className="ml-auto h-4 w-4 animate-spin text-emerald-300" /> : null}
</Link>
);
};
return ( return (
<aside className={shellClass} aria-label={t("sidebar.productTitle")}> <aside className={shellClass} aria-label={t("sidebar.productTitle")}>
@@ -148,58 +202,9 @@ export function Sidebar({ variant = "desktop", onNavigate, onClose }: SidebarPro
)} )}
</div> </div>
<nav className="px-3 py-2 flex-1 space-y-1"> <nav className="px-3 py-2 flex-1 flex flex-col gap-2">
{visibleItems.map((it) => { <div className="space-y-1">{visibleItems.map(renderNavItem)}</div>
const isCurrent = pathname === it.href; <div className="mt-auto space-y-1 border-t border-white/10 pt-2">{renderNavItem(settingsItem)}</div>
const active = isCurrent || pathname.startsWith(it.href + "/");
const isPendingItem = isPending && pendingHref === it.href;
const navLocked = isPending;
const Icon = it.icon;
return (
<Link
key={it.href}
href={it.href}
prefetch={false}
aria-disabled={navLocked}
onClick={(event) => {
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);
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(" ")}
>
<Icon className="h-4 w-4" />
<span>{t(it.labelKey)}</span>
{isPendingItem ? <Loader2 className="ml-auto h-4 w-4 animate-spin text-emerald-300" /> : null}
</Link>
);
})}
</nav> </nav>
<div className="px-5 py-4 border-t border-white/10 space-y-3"> <div className="px-5 py-4 border-t border-white/10 space-y-3">

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,45 @@
"use client";
import { useI18n } from "@/lib/i18n/useI18n";
import type { RecapSkuRow } from "@/lib/recap/types";
type Props = {
rows: RecapSkuRow[];
};
export default function RecapProductionBySku({ rows }: Props) {
const { t } = useI18n();
return (
<div className="rounded-2xl border border-white/10 bg-black/40 p-4">
<div className="mb-3 text-sm font-semibold text-white">{t("recap.production.title")}</div>
{rows.length === 0 ? (
<div className="text-sm text-zinc-400">{t("recap.empty.production")}</div>
) : (
<div className="space-y-2">
<div className="grid grid-cols-5 gap-2 border-b border-white/10 pb-2 text-xs uppercase tracking-wide text-zinc-400">
<div>SKU</div>
<div>{t("recap.production.good")}</div>
<div>{t("recap.production.scrap")}</div>
<div>{t("recap.production.target")}</div>
<div>{t("recap.production.progress")}</div>
</div>
{rows.slice(0, 8).map((row) => {
const pct = row.progressPct == null ? "--" : `${Math.round(row.progressPct)}%`;
return (
<div key={row.sku} className="grid grid-cols-5 gap-2 text-sm text-zinc-200">
<div className="truncate">{row.sku}</div>
<div>{row.good}</div>
<div className={row.scrap > 0 ? "text-red-400" : "text-zinc-200"}>{row.scrap}</div>
<div>{row.target ?? "--"}</div>
<div>
<span className="text-emerald-400">{pct}</span>
</div>
</div>
);
})}
</div>
)}
</div>
);
}

View File

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

View File

@@ -9,6 +9,8 @@
"common.close": "Close", "common.close": "Close",
"common.save": "Save", "common.save": "Save",
"common.copy": "Copy", "common.copy": "Copy",
"common.yes": "Yes",
"common.no": "No",
"nav.overview": "Overview", "nav.overview": "Overview",
"nav.machines": "Machines", "nav.machines": "Machines",
"nav.reports": "Reports", "nav.reports": "Reports",
@@ -104,6 +106,43 @@
"overview.event.slow-cycle": "slow-cycle", "overview.event.slow-cycle": "slow-cycle",
"overview.status.offline": "OFFLINE", "overview.status.offline": "OFFLINE",
"overview.status.online": "ONLINE", "overview.status.online": "ONLINE",
"overview.recap.title": "Daily recap",
"overview.recap.subtitle": "Production, downtime, and work orders in one glance.",
"overview.recap.cta": "Open daily recap",
"recap.title": "Recap",
"recap.subtitle": "Last 24h",
"recap.allMachines": "All machines",
"recap.range.shift": "Shift",
"recap.range.custom": "Custom range",
"recap.shift.1": "Shift 1",
"recap.shift.2": "Shift 2",
"recap.shift.3": "Shift 3",
"recap.kpi.oee": "Avg OEE",
"recap.kpi.good": "Good parts",
"recap.kpi.stops": "Total stops",
"recap.kpi.scrap": "Scrap",
"recap.production.title": "Production by SKU",
"recap.production.good": "Good",
"recap.production.scrap": "Scrap",
"recap.production.target": "Target",
"recap.production.progress": "Progress",
"recap.downtime.title": "Top downtime",
"recap.workOrders.title": "Work orders",
"recap.workOrders.active": "Active",
"recap.workOrders.completed": "Completed",
"recap.workOrders.none": "No production recorded",
"recap.workOrders.startedAt": "Started",
"recap.workOrders.goodParts": "Good parts",
"recap.workOrders.duration": "Duration",
"recap.machine.title": "Machine status",
"recap.machine.running": "Running",
"recap.machine.stopped": "Stopped",
"recap.machine.mold": "Mold change",
"recap.machine.lastHeartbeat": "Last heartbeat",
"recap.machine.uptime": "Uptime",
"recap.banner.mold": "Mold change in progress since",
"recap.banner.stopped": "Machine stopped for {minutes} min",
"recap.empty.production": "No production recorded",
"machines.title": "Machines", "machines.title": "Machines",
"machines.subtitle": "Select a machine to view live KPIs.", "machines.subtitle": "Select a machine to view live KPIs.",
"machines.cancel": "Cancel", "machines.cancel": "Cancel",
@@ -526,6 +565,7 @@
"financial.field.scrapCostPerUnit": "Scrap cost / unit", "financial.field.scrapCostPerUnit": "Scrap cost / unit",
"financial.field.rawMaterialCostPerUnit": "Raw material / unit", "financial.field.rawMaterialCostPerUnit": "Raw material / unit",
"nav.downtime": "Downtime", "nav.downtime": "Downtime",
"nav.recap": "Daily recap",
"settings.tabs.modules": "Modules", "settings.tabs.modules": "Modules",
"settings.modules.title": "Modules", "settings.modules.title": "Modules",
"settings.modules.subtitle": "Enable/disable UI modules depending on how the plant operates.", "settings.modules.subtitle": "Enable/disable UI modules depending on how the plant operates.",

View File

@@ -9,6 +9,8 @@
"common.close": "Cerrar", "common.close": "Cerrar",
"common.save": "Guardar", "common.save": "Guardar",
"common.copy": "Copiar", "common.copy": "Copiar",
"common.yes": "Sí",
"common.no": "No",
"nav.overview": "Resumen", "nav.overview": "Resumen",
"nav.machines": "Máquinas", "nav.machines": "Máquinas",
"nav.reports": "Reportes", "nav.reports": "Reportes",
@@ -104,6 +106,43 @@
"overview.event.slow-cycle": "ciclo lento", "overview.event.slow-cycle": "ciclo lento",
"overview.status.offline": "FUERA DE LÍNEA", "overview.status.offline": "FUERA DE LÍNEA",
"overview.status.online": "EN LÍNEA", "overview.status.online": "EN LÍNEA",
"overview.recap.title": "Resumen diario de turno",
"overview.recap.subtitle": "Consulta producción, paros y órdenes en una sola vista.",
"overview.recap.cta": "Abrir resumen diario",
"recap.title": "Resumen",
"recap.subtitle": "Últimas 24h",
"recap.allMachines": "Todas las máquinas",
"recap.range.shift": "Turno",
"recap.range.custom": "Rango personalizado",
"recap.shift.1": "Turno 1",
"recap.shift.2": "Turno 2",
"recap.shift.3": "Turno 3",
"recap.kpi.oee": "OEE prom",
"recap.kpi.good": "Piezas buenas",
"recap.kpi.stops": "Paros totales",
"recap.kpi.scrap": "Scrap",
"recap.production.title": "Producción por SKU",
"recap.production.good": "Buenas",
"recap.production.scrap": "Scrap",
"recap.production.target": "Meta",
"recap.production.progress": "Avance",
"recap.downtime.title": "Top downtime",
"recap.workOrders.title": "Órdenes de trabajo",
"recap.workOrders.active": "Activa",
"recap.workOrders.completed": "Completadas",
"recap.workOrders.none": "Sin producción registrada",
"recap.workOrders.startedAt": "Inicio",
"recap.workOrders.goodParts": "Buenas",
"recap.workOrders.duration": "Duración",
"recap.machine.title": "Estado de máquina",
"recap.machine.running": "En marcha",
"recap.machine.stopped": "Detenida",
"recap.machine.mold": "Cambio de molde",
"recap.machine.lastHeartbeat": "Último heartbeat",
"recap.machine.uptime": "Uptime",
"recap.banner.mold": "Cambio de molde en curso desde",
"recap.banner.stopped": "Máquina detenida hace {minutes} min",
"recap.empty.production": "Sin producción registrada",
"machines.title": "Máquinas", "machines.title": "Máquinas",
"machines.subtitle": "Selecciona una máquina para ver KPIs en vivo.", "machines.subtitle": "Selecciona una máquina para ver KPIs en vivo.",
"machines.cancel": "Cancelar", "machines.cancel": "Cancelar",
@@ -526,6 +565,7 @@
"financial.field.scrapCostPerUnit": "Costo scrap / unidad", "financial.field.scrapCostPerUnit": "Costo scrap / unidad",
"financial.field.rawMaterialCostPerUnit": "Costo materia prima / unidad", "financial.field.rawMaterialCostPerUnit": "Costo materia prima / unidad",
"nav.downtime": "Downtime", "nav.downtime": "Downtime",
"nav.recap": "Resumen diario",
"settings.tabs.modules": "Módulos", "settings.tabs.modules": "Módulos",
"settings.modules.title": "Módulos", "settings.modules.title": "Módulos",
"settings.modules.subtitle": "Activa/desactiva módulos según cómo opera la planta.", "settings.modules.subtitle": "Activa/desactiva módulos según cómo opera la planta.",

661
lib/recap/getRecapData.ts Normal file
View File

@@ -0,0 +1,661 @@
import { unstable_cache } from "next/cache";
import { prisma } from "@/lib/prisma";
import { normalizeShiftOverrides, type ShiftOverrideDay } from "@/lib/settings";
import type { RecapMachine, RecapQuery, RecapResponse } from "@/lib/recap/types";
type ShiftLike = {
name: string;
startTime?: string | null;
endTime?: string | null;
start?: string | null;
end?: string | null;
enabled?: boolean;
};
const WEEKDAY_KEYS: ShiftOverrideDay[] = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"];
const WEEKDAY_KEY_MAP: Record<string, ShiftOverrideDay> = {
Mon: "mon",
Tue: "tue",
Wed: "wed",
Thu: "thu",
Fri: "fri",
Sat: "sat",
Sun: "sun",
};
const STOP_TYPES = new Set(["microstop", "macrostop"]);
const STOP_STATUS = new Set(["STOP", "DOWN", "OFFLINE"]);
const CACHE_TTL_SEC = 180;
const MOLD_IDLE_MIN = 10;
function safeNum(value: unknown) {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string") {
const n = Number(value);
if (Number.isFinite(n)) return n;
}
return null;
}
function toIso(value?: Date | null) {
return value ? value.toISOString() : null;
}
function round2(value: number) {
return Math.round(value * 100) / 100;
}
function parseDate(input?: string | null) {
if (!input) return null;
const n = Number(input);
if (!Number.isNaN(n)) return new Date(n);
const d = new Date(input);
return Number.isNaN(d.getTime()) ? null : d;
}
function normalizeRange(start?: Date, end?: Date) {
const now = new Date();
const safeEnd = end && Number.isFinite(end.getTime()) ? end : now;
const defaultStart = new Date(safeEnd.getTime() - 24 * 60 * 60 * 1000);
const safeStart = start && Number.isFinite(start.getTime()) ? start : defaultStart;
if (safeStart.getTime() > safeEnd.getTime()) {
return { start: new Date(safeEnd.getTime() - 24 * 60 * 60 * 1000), end: safeEnd };
}
return { start: safeStart, end: safeEnd };
}
function parseTimeMinutes(input?: string | null) {
if (!input) return null;
const match = /^(\d{2}):(\d{2})$/.exec(input.trim());
if (!match) return null;
const h = Number(match[1]);
const m = Number(match[2]);
if (!Number.isInteger(h) || !Number.isInteger(m) || h < 0 || h > 23 || m < 0 || m > 59) return null;
return h * 60 + m;
}
function getLocalMinutes(ts: Date, timeZone: string) {
try {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone,
hour12: false,
hour: "2-digit",
minute: "2-digit",
}).formatToParts(ts);
const h = Number(parts.find((p) => p.type === "hour")?.value ?? "0");
const m = Number(parts.find((p) => p.type === "minute")?.value ?? "0");
return h * 60 + m;
} catch {
return ts.getUTCHours() * 60 + ts.getUTCMinutes();
}
}
function getLocalDayKey(ts: Date, timeZone: string): ShiftOverrideDay {
try {
const weekday = new Intl.DateTimeFormat("en-US", { timeZone, weekday: "short" }).format(ts);
return WEEKDAY_KEY_MAP[weekday] ?? WEEKDAY_KEYS[ts.getUTCDay()];
} catch {
return WEEKDAY_KEYS[ts.getUTCDay()];
}
}
function resolveShiftName(
shifts: ShiftLike[],
overrides: Record<string, ShiftLike[]> | undefined,
ts: Date,
timeZone: string
) {
const dayKey = getLocalDayKey(ts, timeZone);
const dayOverrides = overrides?.[dayKey];
const activeShifts = dayOverrides ?? shifts;
if (!activeShifts.length) return null;
const nowMin = getLocalMinutes(ts, timeZone);
for (const shift of activeShifts) {
if (shift.enabled === false) continue;
const start = parseTimeMinutes(shift.startTime ?? shift.start ?? null);
const end = parseTimeMinutes(shift.endTime ?? shift.end ?? null);
if (start == null || end == null) continue;
if (start <= end) {
if (nowMin >= start && nowMin < end) return shift.name;
} else if (nowMin >= start || nowMin < end) {
return shift.name;
}
}
return null;
}
function normalizeShiftAlias(shift?: string | null) {
const normalized = String(shift ?? "").trim().toLowerCase();
if (!normalized) return null;
if (normalized === "shift1" || normalized === "shift2" || normalized === "shift3") return normalized;
return null;
}
function eventDurationSec(data: unknown) {
let blob = data;
if (typeof blob === "string") {
try {
blob = JSON.parse(blob);
} catch {
blob = null;
}
}
const record = typeof blob === "object" && blob ? (blob as Record<string, unknown>) : null;
const innerCandidate = record?.data ?? record ?? {};
const inner =
typeof innerCandidate === "object" && innerCandidate !== null
? (innerCandidate as Record<string, unknown>)
: {};
return (
safeNum(inner.stoppage_duration_seconds) ??
safeNum(inner.stop_duration_seconds) ??
safeNum(inner.duration_seconds) ??
safeNum(record?.durationSeconds) ??
0
);
}
function avg(sum: number, count: number) {
if (!count) return null;
return round2(sum / count);
}
export function parseRecapQuery(input: {
machineId?: string | null;
start?: string | null;
end?: string | null;
shift?: string | null;
}) {
return {
machineId: input.machineId ? String(input.machineId).trim() : undefined,
start: parseDate(input.start),
end: parseDate(input.end),
shift: normalizeShiftAlias(input.shift),
};
}
async function computeRecap(params: Required<Pick<RecapQuery, "orgId">> & {
machineId?: string;
start: Date;
end: Date;
shift?: string;
}): Promise<RecapResponse> {
const machineFilter = params.machineId ? { id: params.machineId } : {};
const machines = await prisma.machine.findMany({
where: { orgId: params.orgId, ...machineFilter },
orderBy: { name: "asc" },
select: { id: true, name: true, location: true },
});
if (!machines.length) {
return {
range: { start: params.start.toISOString(), end: params.end.toISOString() },
machines: [],
};
}
const machineIds = machines.map((m) => m.id);
const [settings, shifts, cyclesRaw, kpisRaw, eventsRaw, reasonsRaw, workOrdersRaw, hbRangeRaw, hbLatestRaw] =
await Promise.all([
prisma.orgSettings.findUnique({
where: { orgId: params.orgId },
select: { timezone: true, shiftScheduleOverridesJson: true },
}),
prisma.orgShift.findMany({
where: { orgId: params.orgId },
orderBy: { sortOrder: "asc" },
select: { name: true, startTime: true, endTime: true, enabled: true, sortOrder: true },
}),
prisma.machineCycle.findMany({
where: {
orgId: params.orgId,
machineId: { in: machineIds },
ts: { gte: params.start, lte: params.end },
},
select: {
machineId: true,
ts: true,
workOrderId: true,
sku: true,
goodDelta: true,
scrapDelta: true,
},
}),
prisma.machineKpiSnapshot.findMany({
where: {
orgId: params.orgId,
machineId: { in: machineIds },
ts: { gte: params.start, lte: params.end },
},
select: {
machineId: true,
ts: true,
oee: true,
availability: true,
performance: true,
quality: true,
},
}),
prisma.machineEvent.findMany({
where: {
orgId: params.orgId,
machineId: { in: machineIds },
ts: { gte: params.start, lte: params.end },
},
select: {
machineId: true,
ts: true,
eventType: true,
data: true,
},
}),
prisma.reasonEntry.findMany({
where: {
orgId: params.orgId,
machineId: { in: machineIds },
kind: "downtime",
capturedAt: { gte: params.start, lte: params.end },
},
select: {
machineId: true,
capturedAt: true,
reasonCode: true,
reasonLabel: true,
durationSeconds: true,
},
}),
prisma.machineWorkOrder.findMany({
where: {
orgId: params.orgId,
machineId: { in: machineIds },
},
orderBy: { updatedAt: "desc" },
select: {
machineId: true,
workOrderId: true,
sku: true,
targetQty: true,
status: true,
createdAt: true,
updatedAt: true,
},
}),
prisma.machineHeartbeat.findMany({
where: {
orgId: params.orgId,
machineId: { in: machineIds },
ts: { gte: params.start, lte: params.end },
},
orderBy: [{ machineId: "asc" }, { ts: "asc" }],
select: {
machineId: true,
ts: true,
tsServer: true,
status: true,
},
}),
prisma.machineHeartbeat.findMany({
where: {
orgId: params.orgId,
machineId: { in: machineIds },
ts: { lte: params.end },
},
orderBy: [{ machineId: "asc" }, { ts: "desc" }],
distinct: ["machineId"],
select: {
machineId: true,
ts: true,
tsServer: true,
status: true,
},
}),
]);
const timeZone = settings?.timezone || "UTC";
const shiftOverrides = normalizeShiftOverrides(settings?.shiftScheduleOverridesJson);
const orderedEnabledShifts = shifts.filter((s) => s.enabled !== false).sort((a, b) => a.sortOrder - b.sortOrder);
const shiftIndex = params.shift ? Number(params.shift.replace("shift", "")) - 1 : -1;
const targetShiftName = shiftIndex >= 0 ? orderedEnabledShifts[shiftIndex]?.name ?? "__missing_shift__" : null;
const inTargetShift = (ts: Date) => {
if (!targetShiftName) return true;
const resolved = resolveShiftName(shifts, shiftOverrides, ts, timeZone);
return resolved === targetShiftName;
};
const cycles = targetShiftName ? cyclesRaw.filter((row) => inTargetShift(row.ts)) : cyclesRaw;
const kpis = targetShiftName ? kpisRaw.filter((row) => inTargetShift(row.ts)) : kpisRaw;
const events = targetShiftName ? eventsRaw.filter((row) => inTargetShift(row.ts)) : eventsRaw;
const reasons = targetShiftName ? reasonsRaw.filter((row) => inTargetShift(row.capturedAt)) : reasonsRaw;
const hbRange = targetShiftName ? hbRangeRaw.filter((row) => inTargetShift(row.ts)) : hbRangeRaw;
const cyclesByMachine = new Map<string, typeof cycles>();
const cyclesAllByMachine = new Map<string, typeof cyclesRaw>();
const kpisByMachine = new Map<string, typeof kpis>();
const eventsByMachine = new Map<string, typeof events>();
const reasonsByMachine = new Map<string, typeof reasons>();
const workOrdersByMachine = new Map<string, typeof workOrdersRaw>();
const hbRangeByMachine = new Map<string, typeof hbRange>();
const hbLatestByMachine = new Map(hbLatestRaw.map((row) => [row.machineId, row]));
for (const row of cycles) {
const list = cyclesByMachine.get(row.machineId) ?? [];
list.push(row);
cyclesByMachine.set(row.machineId, list);
}
for (const row of cyclesRaw) {
const list = cyclesAllByMachine.get(row.machineId) ?? [];
list.push(row);
cyclesAllByMachine.set(row.machineId, list);
}
for (const row of kpis) {
const list = kpisByMachine.get(row.machineId) ?? [];
list.push(row);
kpisByMachine.set(row.machineId, list);
}
for (const row of events) {
const list = eventsByMachine.get(row.machineId) ?? [];
list.push(row);
eventsByMachine.set(row.machineId, list);
}
for (const row of reasons) {
const list = reasonsByMachine.get(row.machineId) ?? [];
list.push(row);
reasonsByMachine.set(row.machineId, list);
}
for (const row of workOrdersRaw) {
const list = workOrdersByMachine.get(row.machineId) ?? [];
list.push(row);
workOrdersByMachine.set(row.machineId, list);
}
for (const row of hbRange) {
const list = hbRangeByMachine.get(row.machineId) ?? [];
list.push(row);
hbRangeByMachine.set(row.machineId, list);
}
const machineRows: RecapMachine[] = machines.map((machine) => {
const machineCycles = cyclesByMachine.get(machine.id) ?? [];
const machineCyclesAll = cyclesAllByMachine.get(machine.id) ?? [];
const machineKpis = kpisByMachine.get(machine.id) ?? [];
const machineEvents = eventsByMachine.get(machine.id) ?? [];
const machineReasons = reasonsByMachine.get(machine.id) ?? [];
const machineWorkOrders = workOrdersByMachine.get(machine.id) ?? [];
const machineHbRange = hbRangeByMachine.get(machine.id) ?? [];
const latestHb = hbLatestByMachine.get(machine.id) ?? null;
const targetBySku = new Map<string, number>();
for (const wo of machineWorkOrders) {
if (!wo.sku || wo.targetQty == null) continue;
targetBySku.set(wo.sku, (targetBySku.get(wo.sku) ?? 0) + Number(wo.targetQty));
}
const skuMap = new Map<string, { sku: string; good: number; scrap: number; target: number | null }>();
let goodParts = 0;
let scrapParts = 0;
for (const cycle of machineCycles) {
const sku = cycle.sku || "N/A";
const good = safeNum(cycle.goodDelta) ?? 0;
const scrap = safeNum(cycle.scrapDelta) ?? 0;
goodParts += good;
scrapParts += scrap;
const row = skuMap.get(sku) ?? {
sku,
good: 0,
scrap: 0,
target: targetBySku.has(sku) ? targetBySku.get(sku) ?? null : null,
};
row.good += good;
row.scrap += scrap;
skuMap.set(sku, row);
}
const bySku = [...skuMap.values()]
.map((row) => {
const produced = row.good + row.scrap;
const progressPct = row.target && row.target > 0 ? round2((produced / row.target) * 100) : null;
return { ...row, progressPct };
})
.sort((a, b) => b.good - a.good);
let oeeSum = 0;
let oeeCount = 0;
let availabilitySum = 0;
let availabilityCount = 0;
let performanceSum = 0;
let performanceCount = 0;
let qualitySum = 0;
let qualityCount = 0;
for (const kpi of machineKpis) {
const oee = safeNum(kpi.oee);
const availability = safeNum(kpi.availability);
const performance = safeNum(kpi.performance);
const quality = safeNum(kpi.quality);
if (oee != null) {
oeeSum += oee;
oeeCount += 1;
}
if (availability != null) {
availabilitySum += availability;
availabilityCount += 1;
}
if (performance != null) {
performanceSum += performance;
performanceCount += 1;
}
if (quality != null) {
qualitySum += quality;
qualityCount += 1;
}
}
let stopDurSecFromEvents = 0;
let stopsCount = 0;
for (const event of machineEvents) {
const type = String(event.eventType || "").toLowerCase();
if (!STOP_TYPES.has(type)) continue;
stopsCount += 1;
stopDurSecFromEvents += eventDurationSec(event.data);
}
const reasonAgg = new Map<string, { reasonLabel: string; seconds: number; count: number }>();
let stopDurSecFromReasons = 0;
for (const reason of machineReasons) {
const label = reason.reasonLabel?.trim() || reason.reasonCode || "Sin razón";
const seconds = Math.max(0, safeNum(reason.durationSeconds) ?? 0);
stopDurSecFromReasons += seconds;
const agg = reasonAgg.get(label) ?? { reasonLabel: label, seconds: 0, count: 0 };
agg.seconds += seconds;
agg.count += 1;
reasonAgg.set(label, agg);
}
const topReasons = [...reasonAgg.values()]
.sort((a, b) => b.seconds - a.seconds)
.slice(0, 3)
.map((row) => ({
reasonLabel: row.reasonLabel,
minutes: round2(row.seconds / 60),
count: row.count,
}));
const totalMin = round2(Math.max(stopDurSecFromEvents, stopDurSecFromReasons) / 60);
let ongoingStopMin: number | null = null;
const latestStatus = String(latestHb?.status ?? "").toUpperCase();
const latestTs = latestHb?.tsServer ?? latestHb?.ts ?? null;
if (latestTs && STOP_STATUS.has(latestStatus)) {
let downStart = latestTs;
for (let i = machineHbRange.length - 1; i >= 0; i -= 1) {
const hb = machineHbRange[i];
const hbStatus = String(hb.status ?? "").toUpperCase();
if (!STOP_STATUS.has(hbStatus)) break;
downStart = hb.tsServer ?? hb.ts;
}
ongoingStopMin = round2(Math.max(0, (params.end.getTime() - downStart.getTime()) / 60000));
}
const cyclesByWorkOrder = new Map<
string,
{ goodParts: number; firstTs: Date | null; lastTs: Date | null }
>();
for (const cycle of machineCycles) {
if (!cycle.workOrderId) continue;
const current = cyclesByWorkOrder.get(cycle.workOrderId) ?? {
goodParts: 0,
firstTs: null,
lastTs: null,
};
current.goodParts += safeNum(cycle.goodDelta) ?? 0;
if (!current.firstTs || cycle.ts < current.firstTs) current.firstTs = cycle.ts;
if (!current.lastTs || cycle.ts > current.lastTs) current.lastTs = cycle.ts;
cyclesByWorkOrder.set(cycle.workOrderId, current);
}
const completed = machineWorkOrders
.filter((wo) => String(wo.status).toUpperCase() === "COMPLETED")
.filter((wo) => wo.updatedAt >= params.start && wo.updatedAt <= params.end)
.map((wo) => {
const progress = cyclesByWorkOrder.get(wo.workOrderId) ?? {
goodParts: 0,
firstTs: null,
lastTs: null,
};
const durationHrs =
progress.firstTs && progress.lastTs
? round2((progress.lastTs.getTime() - progress.firstTs.getTime()) / 3600000)
: 0;
return {
id: wo.workOrderId,
sku: wo.sku,
goodParts: progress.goodParts,
durationHrs,
};
})
.sort((a, b) => b.goodParts - a.goodParts);
const activeWo = machineWorkOrders.find((wo) => String(wo.status).toUpperCase() !== "COMPLETED") ?? null;
let activeProgressPct: number | null = null;
let activeStartedAt: string | null = null;
if (activeWo) {
const progress = cyclesByWorkOrder.get(activeWo.workOrderId);
const produced = (progress?.goodParts ?? 0) + (machineCycles
.filter((row) => row.workOrderId === activeWo.workOrderId)
.reduce((sum, row) => sum + (safeNum(row.scrapDelta) ?? 0), 0));
if (activeWo.targetQty && activeWo.targetQty > 0) {
activeProgressPct = round2((produced / activeWo.targetQty) * 100);
}
activeStartedAt = toIso(progress?.firstTs ?? activeWo.createdAt);
}
const cutoffTs = new Date(params.end.getTime() - MOLD_IDLE_MIN * 60000);
const hasRecentCycle = machineCyclesAll.some((cycle) => cycle.ts >= cutoffTs && cycle.ts <= params.end);
const moldChangeInProgress =
!!activeWo && String(activeWo.status).toUpperCase() === "PENDING" && !hasRecentCycle;
let uptimePct: number | null = null;
if (machineHbRange.length) {
let onlineCount = 0;
for (const hb of machineHbRange) {
const status = String(hb.status ?? "").toUpperCase();
if (!STOP_STATUS.has(status)) onlineCount += 1;
}
uptimePct = round2((onlineCount / machineHbRange.length) * 100);
}
return {
machineId: machine.id,
machineName: machine.name,
location: machine.location,
production: {
goodParts,
scrapParts,
totalCycles: machineCycles.length,
bySku,
},
oee: {
avg: avg(oeeSum, oeeCount),
availability: avg(availabilitySum, availabilityCount),
performance: avg(performanceSum, performanceCount),
quality: avg(qualitySum, qualityCount),
},
downtime: {
totalMin,
stopsCount,
topReasons,
ongoingStopMin,
},
workOrders: {
completed,
active: activeWo
? {
id: activeWo.workOrderId,
sku: activeWo.sku,
progressPct: activeProgressPct,
startedAt: activeStartedAt,
}
: null,
moldChangeInProgress,
},
heartbeat: {
lastSeenAt: toIso(latestTs),
uptimePct,
},
};
});
return {
range: {
start: params.start.toISOString(),
end: params.end.toISOString(),
},
machines: machineRows,
};
}
export async function getRecapDataCached(params: RecapQuery): Promise<RecapResponse> {
const { start, end } = normalizeRange(params.start, params.end);
const machineId = params.machineId?.trim() || undefined;
const shift = normalizeShiftAlias(params.shift) ?? undefined;
const cacheKey = [
"recap",
params.orgId,
machineId ?? "all",
String(start.getTime()),
String(end.getTime()),
shift ?? "all",
];
const cached = unstable_cache(
() =>
computeRecap({
orgId: params.orgId,
machineId,
start,
end,
shift,
}),
cacheKey,
{
revalidate: CACHE_TTL_SEC,
tags: [`recap:${params.orgId}`],
}
);
return cached();
}

70
lib/recap/types.ts Normal file
View File

@@ -0,0 +1,70 @@
export type RecapSkuRow = {
sku: string;
good: number;
scrap: number;
target: number | null;
progressPct: number | null;
};
export type RecapMachine = {
machineId: string;
machineName: string;
location: string | null;
production: {
goodParts: number;
scrapParts: number;
totalCycles: number;
bySku: RecapSkuRow[];
};
oee: {
avg: number | null;
availability: number | null;
performance: number | null;
quality: number | null;
};
downtime: {
totalMin: number;
stopsCount: number;
topReasons: Array<{
reasonLabel: string;
minutes: number;
count: number;
}>;
ongoingStopMin: number | null;
};
workOrders: {
completed: Array<{
id: string;
sku: string | null;
goodParts: number;
durationHrs: number;
}>;
active: {
id: string;
sku: string | null;
progressPct: number | null;
startedAt: string | null;
} | null;
moldChangeInProgress: boolean;
};
heartbeat: {
lastSeenAt: string | null;
uptimePct: number | null;
};
};
export type RecapResponse = {
range: {
start: string;
end: string;
};
machines: RecapMachine[];
};
export type RecapQuery = {
orgId: string;
machineId?: string;
start?: Date;
end?: Date;
shift?: string;
};