changes
This commit is contained in:
@@ -232,6 +232,21 @@ export default function OverviewClient({
|
||||
|
||||
{loading && <div className="mb-4 text-sm text-zinc-400">{t("overview.loading")}</div>}
|
||||
|
||||
<div className="mb-4 rounded-2xl border border-white/10 bg-black/40 p-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-white">{t("overview.recap.title")}</div>
|
||||
<div className="text-xs text-zinc-400">{t("overview.recap.subtitle")}</div>
|
||||
</div>
|
||||
<Link
|
||||
href="/recap"
|
||||
className="rounded-xl border border-emerald-500/30 bg-emerald-500/10 px-3 py-2 text-sm text-emerald-300 hover:bg-emerald-500/20"
|
||||
>
|
||||
{t("overview.recap.cta")}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 xl:grid-cols-3">
|
||||
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
|
||||
<div className="text-xs text-zinc-400">{t("overview.fleetHealth")}</div>
|
||||
|
||||
237
app/(app)/recap/RecapClient.tsx
Normal file
237
app/(app)/recap/RecapClient.tsx
Normal 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
46
app/(app)/recap/page.tsx
Normal 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() ?? "",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
681
app/(app)/reports/ReportsPageClient.tsx
Normal file
681
app/(app)/reports/ReportsPageClient.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,713 +1,17 @@
|
||||
"use client";
|
||||
import { redirect } from "next/navigation";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { requireSession } from "@/lib/auth/requireSession";
|
||||
import ReportsPageClient from "./ReportsPageClient";
|
||||
|
||||
import { Suspense, lazy, useEffect, useMemo, useState } from "react";
|
||||
import { useI18n } from "@/lib/i18n/useI18n";
|
||||
export default async function ReportsPage() {
|
||||
const session = await requireSession();
|
||||
if (!session) redirect("/login?next=/reports");
|
||||
|
||||
const ReportsCharts = lazy(() => import("./ReportsCharts"));
|
||||
|
||||
type RangeKey = "24h" | "7d" | "30d" | "custom";
|
||||
|
||||
type ReportSummary = {
|
||||
oeeAvg: number | null;
|
||||
availabilityAvg: number | null;
|
||||
performanceAvg: number | null;
|
||||
qualityAvg: number | null;
|
||||
goodTotal: number | null;
|
||||
scrapTotal: number | null;
|
||||
targetTotal: number | null;
|
||||
scrapRate: number | null;
|
||||
topScrapSku?: string | null;
|
||||
topScrapWorkOrder?: string | null;
|
||||
};
|
||||
|
||||
type ReportDowntime = {
|
||||
macrostopSec: number;
|
||||
microstopSec: number;
|
||||
slowCycleCount: number;
|
||||
qualitySpikeCount: number;
|
||||
performanceDegradationCount: number;
|
||||
oeeDropCount: number;
|
||||
};
|
||||
|
||||
type ReportTrendPoint = { t: string; v: number };
|
||||
|
||||
type ReportPayload = {
|
||||
summary: ReportSummary;
|
||||
downtime: ReportDowntime;
|
||||
trend: {
|
||||
oee: ReportTrendPoint[];
|
||||
availability: ReportTrendPoint[];
|
||||
performance: ReportTrendPoint[];
|
||||
quality: ReportTrendPoint[];
|
||||
scrapRate: ReportTrendPoint[];
|
||||
};
|
||||
distribution: {
|
||||
cycleTime: {
|
||||
label: string;
|
||||
count: number;
|
||||
rangeStart?: number;
|
||||
rangeEnd?: number;
|
||||
overflow?: "low" | "high";
|
||||
minValue?: number;
|
||||
maxValue?: number;
|
||||
}[];
|
||||
};
|
||||
insights?: string[];
|
||||
};
|
||||
|
||||
type MachineOption = { id: string; name: string };
|
||||
type FilterOptions = { workOrders: string[]; skus: string[] };
|
||||
type Translator = (key: string, vars?: Record<string, 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 machines = await prisma.machine.findMany({
|
||||
where: { orgId: session.orgId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
|
||||
const header = ["timestamp", "oee", "availability", "performance", "quality", "scrapRate"].join(",");
|
||||
const lines = ordered.map((row) =>
|
||||
[
|
||||
row.timestamp,
|
||||
row.oee ?? "",
|
||||
row.availability ?? "",
|
||||
row.performance ?? "",
|
||||
row.quality ?? "",
|
||||
row.scrapRate ?? "",
|
||||
]
|
||||
.map((v) => (v == null ? "" : String(v)))
|
||||
.join(",")
|
||||
);
|
||||
|
||||
const summary = report.summary;
|
||||
const downtime = report.downtime;
|
||||
|
||||
const sectionLines: string[] = [];
|
||||
sectionLines.push(
|
||||
[t("reports.csv.section"), t("reports.csv.key"), t("reports.csv.value")].join(",")
|
||||
);
|
||||
const addRow = (section: string, key: string, value: string | number | null | undefined) => {
|
||||
sectionLines.push(
|
||||
[section, key, value == null ? "" : String(value)]
|
||||
.map((v) => (v.includes(",") ? `"${v.replace(/\"/g, '""')}"` : v))
|
||||
.join(",")
|
||||
);
|
||||
};
|
||||
|
||||
addRow("summary", "oeeAvg", summary.oeeAvg);
|
||||
addRow("summary", "availabilityAvg", summary.availabilityAvg);
|
||||
addRow("summary", "performanceAvg", summary.performanceAvg);
|
||||
addRow("summary", "qualityAvg", summary.qualityAvg);
|
||||
addRow("summary", "goodTotal", summary.goodTotal);
|
||||
addRow("summary", "scrapTotal", summary.scrapTotal);
|
||||
addRow("summary", "targetTotal", summary.targetTotal);
|
||||
addRow("summary", "scrapRate", summary.scrapRate);
|
||||
addRow("summary", "topScrapSku", summary.topScrapSku ?? "");
|
||||
addRow("summary", "topScrapWorkOrder", summary.topScrapWorkOrder ?? "");
|
||||
|
||||
addRow("loss_drivers", "macrostopSec", downtime.macrostopSec);
|
||||
addRow("loss_drivers", "microstopSec", downtime.microstopSec);
|
||||
addRow("loss_drivers", "slowCycleCount", downtime.slowCycleCount);
|
||||
addRow("loss_drivers", "qualitySpikeCount", downtime.qualitySpikeCount);
|
||||
addRow("loss_drivers", "performanceDegradationCount", downtime.performanceDegradationCount);
|
||||
addRow("loss_drivers", "oeeDropCount", downtime.oeeDropCount);
|
||||
|
||||
for (const bin of report.distribution.cycleTime) {
|
||||
addRow("cycle_distribution", bin.label, bin.count);
|
||||
}
|
||||
|
||||
if (report.insights?.length) {
|
||||
report.insights.forEach((note, idx) => addRow("insights", String(idx + 1), note));
|
||||
}
|
||||
|
||||
return [header, ...lines, "", ...sectionLines].join("\n");
|
||||
}
|
||||
|
||||
function downloadText(filename: string, content: string) {
|
||||
const blob = new Blob([content], { type: "text/csv;charset=utf-8;" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.setAttribute("download", filename);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function buildPdfHtml(
|
||||
report: ReportPayload,
|
||||
rangeLabel: string,
|
||||
filters: { machine: string; workOrder: string; sku: string },
|
||||
t: Translator
|
||||
) {
|
||||
const summary = report.summary;
|
||||
const downtime = report.downtime;
|
||||
const cycleBins = report.distribution.cycleTime;
|
||||
const insights = report.insights ?? [];
|
||||
|
||||
return `
|
||||
<!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>
|
||||
);
|
||||
return <ReportsPageClient initialMachines={machines} />;
|
||||
}
|
||||
|
||||
@@ -439,7 +439,9 @@ export async function POST(req: Request) {
|
||||
|
||||
// If the payload carries a `reason`, create the corresponding ReasonEntry.
|
||||
// If it doesn't, still create an "UNCLASSIFIED" downtime ReasonEntry for stop events so the dashboard can show coverage.
|
||||
if (evReason || finalType === "microstop" || finalType === "macrostop" || finalType === "downtime-acknowledged") {
|
||||
if (evRecord.is_update || evRecord.is_auto_ack || dataObj.is_update || dataObj.is_auto_ack){
|
||||
// skip duplicate reasonEntry for refresh/ack
|
||||
} else if (evReason || finalType === "microstop" || finalType === "macrostop" || finalType === "downtime-acknowledged"){
|
||||
const reasonRaw: Record<string, unknown> =
|
||||
evReason ??
|
||||
({
|
||||
|
||||
29
app/api/recap/route.ts
Normal file
29
app/api/recap/route.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { requireSession } from "@/lib/auth/requireSession";
|
||||
import { getRecapDataCached, parseRecapQuery } from "@/lib/recap/getRecapData";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const session = await requireSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const url = new URL(req.url);
|
||||
const query = parseRecapQuery({
|
||||
machineId: url.searchParams.get("machineId"),
|
||||
start: url.searchParams.get("start"),
|
||||
end: url.searchParams.get("end"),
|
||||
shift: url.searchParams.get("shift"),
|
||||
});
|
||||
|
||||
const recap = await getRecapDataCached({
|
||||
orgId: session.orgId,
|
||||
machineId: query.machineId,
|
||||
start: query.start ?? undefined,
|
||||
end: query.end ?? undefined,
|
||||
shift: query.shift ?? undefined,
|
||||
});
|
||||
|
||||
return NextResponse.json(recap);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { createHash } from "crypto";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { requireSession } from "@/lib/auth/requireSession";
|
||||
import { logLine } from "@/lib/logger";
|
||||
@@ -42,6 +43,10 @@ function pickRange(req: NextRequest) {
|
||||
return { start: new Date(now.getTime() - ms), end: now };
|
||||
}
|
||||
|
||||
function toMs(value?: Date | null) {
|
||||
return value ? value.getTime() : 0;
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const perfEnabled = PERF_LOGS_ENABLED;
|
||||
const totalStart = nowMs();
|
||||
@@ -67,6 +72,32 @@ export async function GET(req: NextRequest) {
|
||||
|
||||
if (perfEnabled) timings.preQuery = elapsedMs(preQueryStart);
|
||||
|
||||
const versionStart = nowMs();
|
||||
const cycleMax = await prisma.machineCycle.aggregate({
|
||||
where: baseWhere,
|
||||
_max: { tsServer: true },
|
||||
});
|
||||
if (perfEnabled) timings.version = elapsedMs(versionStart);
|
||||
|
||||
const versionParts = [
|
||||
session.orgId,
|
||||
range,
|
||||
machineId ?? "",
|
||||
toMs(cycleMax._max.tsServer),
|
||||
];
|
||||
const etag = `W/"${createHash("sha1").update(versionParts.join("|")).digest("hex")}"`;
|
||||
const responseHeaders = new Headers({
|
||||
"Cache-Control": "private, no-cache, max-age=0, must-revalidate",
|
||||
ETag: etag,
|
||||
"Last-Modified": new Date(toMs(cycleMax._max.tsServer) || 0).toUTCString(),
|
||||
Vary: "Cookie",
|
||||
});
|
||||
|
||||
const ifNoneMatch = req.headers.get("if-none-match");
|
||||
if (ifNoneMatch && ifNoneMatch === etag) {
|
||||
return new NextResponse(null, { status: 304, headers: responseHeaders });
|
||||
}
|
||||
|
||||
const workOrdersStart = nowMs();
|
||||
const workOrderRows = await prisma.machineCycle.findMany({
|
||||
where: { ...baseWhere, workOrderId: { not: null } },
|
||||
@@ -90,7 +121,6 @@ export async function GET(req: NextRequest) {
|
||||
|
||||
const payload = { ok: true, workOrders, skus };
|
||||
|
||||
const responseHeaders = new Headers();
|
||||
if (perfEnabled) {
|
||||
timings.postQuery = elapsedMs(postQueryStart);
|
||||
timings.total = elapsedMs(totalStart);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { createHash } from "crypto";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { requireSession } from "@/lib/auth/requireSession";
|
||||
import { logLine } from "@/lib/logger";
|
||||
@@ -46,6 +47,10 @@ function safeNum(v: unknown) {
|
||||
return typeof v === "number" && Number.isFinite(v) ? v : null;
|
||||
}
|
||||
|
||||
function toMs(value?: Date | null) {
|
||||
return value ? value.getTime() : 0;
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const perfEnabled = PERF_LOGS_ENABLED;
|
||||
const totalStart = nowMs();
|
||||
@@ -73,6 +78,52 @@ export async function GET(req: NextRequest) {
|
||||
|
||||
if (perfEnabled) timings.preQuery = elapsedMs(preQueryStart);
|
||||
|
||||
const versionStart = nowMs();
|
||||
const [kpiMax, cycleMax, eventMax] = await Promise.all([
|
||||
prisma.machineKpiSnapshot.aggregate({
|
||||
where: { ...baseWhere, ts: { gte: start, lte: end } },
|
||||
_max: { tsServer: true },
|
||||
}),
|
||||
prisma.machineCycle.aggregate({
|
||||
where: { ...baseWhere, ts: { gte: start, lte: end } },
|
||||
_max: { tsServer: true },
|
||||
}),
|
||||
prisma.machineEvent.aggregate({
|
||||
where: { ...baseWhere, ts: { gte: start, lte: end } },
|
||||
_max: { tsServer: true },
|
||||
}),
|
||||
]);
|
||||
if (perfEnabled) timings.version = elapsedMs(versionStart);
|
||||
|
||||
const lastModifiedMs = Math.max(
|
||||
toMs(kpiMax._max.tsServer),
|
||||
toMs(cycleMax._max.tsServer),
|
||||
toMs(eventMax._max.tsServer)
|
||||
);
|
||||
|
||||
const versionParts = [
|
||||
session.orgId,
|
||||
range,
|
||||
machineId ?? "",
|
||||
workOrderId ?? "",
|
||||
sku ?? "",
|
||||
toMs(kpiMax._max.tsServer),
|
||||
toMs(cycleMax._max.tsServer),
|
||||
toMs(eventMax._max.tsServer),
|
||||
];
|
||||
const etag = `W/"${createHash("sha1").update(versionParts.join("|")).digest("hex")}"`;
|
||||
const responseHeaders = new Headers({
|
||||
"Cache-Control": "private, no-cache, max-age=0, must-revalidate",
|
||||
ETag: etag,
|
||||
"Last-Modified": new Date(lastModifiedMs || 0).toUTCString(),
|
||||
Vary: "Cookie",
|
||||
});
|
||||
|
||||
const ifNoneMatch = req.headers.get("if-none-match");
|
||||
if (ifNoneMatch && ifNoneMatch === etag) {
|
||||
return new NextResponse(null, { status: 304, headers: responseHeaders });
|
||||
}
|
||||
|
||||
const kpiStart = nowMs();
|
||||
const kpiRows = await prisma.machineKpiSnapshot.findMany({
|
||||
where: { ...baseWhere, ts: { gte: start, lte: end } },
|
||||
@@ -405,7 +456,6 @@ export async function GET(req: NextRequest) {
|
||||
},
|
||||
};
|
||||
|
||||
const responseHeaders = new Headers();
|
||||
if (perfEnabled) {
|
||||
timings.postQuery = elapsedMs(postQueryStart);
|
||||
timings.total = elapsedMs(totalStart);
|
||||
|
||||
@@ -3,7 +3,18 @@
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useEffect, useMemo, useState, useTransition } from "react";
|
||||
import { BarChart3, Bell, DollarSign, LayoutGrid, Loader2, LogOut, Settings, Wrench, X } from "lucide-react";
|
||||
import {
|
||||
BarChart3,
|
||||
Bell,
|
||||
DollarSign,
|
||||
LayoutGrid,
|
||||
Loader2,
|
||||
LogOut,
|
||||
Settings,
|
||||
Sunrise,
|
||||
Wrench,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { useI18n } from "@/lib/i18n/useI18n";
|
||||
import { useScreenlessMode } from "@/lib/ui/screenlessMode";
|
||||
@@ -24,10 +35,10 @@ const items: NavItem[] = [
|
||||
{ href: "/reports", labelKey: "nav.reports", icon: BarChart3 },
|
||||
{ href: "/alerts", labelKey: "nav.alerts", icon: Bell },
|
||||
{ href: "/financial", labelKey: "nav.financial", icon: DollarSign, ownerOnly: true },
|
||||
{ href: "/settings", labelKey: "nav.settings", icon: Settings },
|
||||
{ href: "/downtime", labelKey: "nav.downtime", icon: BarChart3 },
|
||||
|
||||
{ href: "/recap", labelKey: "nav.recap", icon: Sunrise },
|
||||
];
|
||||
const settingsItem: NavItem = { href: "/settings", labelKey: "nav.settings", icon: Settings };
|
||||
|
||||
type SidebarProps = {
|
||||
variant?: "desktop" | "drawer";
|
||||
@@ -97,16 +108,7 @@ export function Sidebar({ variant = "desktop", onNavigate, onClose }: SidebarPro
|
||||
}
|
||||
}, [screenlessMode, pathname, router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingHref) return;
|
||||
if (pathname === pendingHref || pathname.startsWith(`${pendingHref}/`)) {
|
||||
setPendingHref(null);
|
||||
} else if (!isPending) {
|
||||
setPendingHref(null);
|
||||
}
|
||||
}, [pathname, pendingHref, isPending]);
|
||||
|
||||
const markNavStart = (href: string) => {
|
||||
const markNavStart = (href: string, ts: number) => {
|
||||
if (!PERF_ENABLED) return;
|
||||
try {
|
||||
sessionStorage.setItem(
|
||||
@@ -114,7 +116,7 @@ export function Sidebar({ variant = "desktop", onNavigate, onClose }: SidebarPro
|
||||
JSON.stringify({
|
||||
href,
|
||||
from: pathname,
|
||||
ts: Date.now(),
|
||||
ts,
|
||||
})
|
||||
);
|
||||
} catch {
|
||||
@@ -128,32 +130,12 @@ export function Sidebar({ variant = "desktop", onNavigate, onClose }: SidebarPro
|
||||
"relative z-20 flex flex-col border-r border-white/10 bg-black/40 shrink-0",
|
||||
variant === "desktop" ? "hidden md:flex h-screen w-64" : "flex h-full w-72 max-w-[85vw]",
|
||||
].join(" ");
|
||||
const navLocked = isPending;
|
||||
|
||||
return (
|
||||
<aside className={shellClass} aria-label={t("sidebar.productTitle")}>
|
||||
<div className="px-5 py-4 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-white font-semibold tracking-wide">{t("sidebar.productTitle")}</div>
|
||||
<div className="text-xs text-zinc-500">{t("sidebar.productSubtitle")}</div>
|
||||
</div>
|
||||
{variant === "drawer" && onClose && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label={t("common.close")}
|
||||
className="rounded-lg border border-white/10 bg-white/5 p-2 text-zinc-300 hover:bg-white/10 hover:text-white md:hidden"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<nav className="px-3 py-2 flex-1 space-y-1">
|
||||
{visibleItems.map((it) => {
|
||||
const renderNavItem = (it: NavItem) => {
|
||||
const isCurrent = pathname === it.href;
|
||||
const active = isCurrent || pathname.startsWith(it.href + "/");
|
||||
const isPendingItem = isPending && pendingHref === it.href;
|
||||
const navLocked = isPending;
|
||||
const Icon = it.icon;
|
||||
return (
|
||||
<Link
|
||||
@@ -178,7 +160,7 @@ export function Sidebar({ variant = "desktop", onNavigate, onClose }: SidebarPro
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
markNavStart(it.href);
|
||||
markNavStart(it.href, Math.round(performance.timeOrigin + event.timeStamp));
|
||||
setPendingHref(it.href);
|
||||
startTransition(() => {
|
||||
router.push(it.href);
|
||||
@@ -199,7 +181,30 @@ export function Sidebar({ variant = "desktop", onNavigate, onClose }: SidebarPro
|
||||
{isPendingItem ? <Loader2 className="ml-auto h-4 w-4 animate-spin text-emerald-300" /> : null}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
};
|
||||
|
||||
return (
|
||||
<aside className={shellClass} aria-label={t("sidebar.productTitle")}>
|
||||
<div className="px-5 py-4 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-white font-semibold tracking-wide">{t("sidebar.productTitle")}</div>
|
||||
<div className="text-xs text-zinc-500">{t("sidebar.productSubtitle")}</div>
|
||||
</div>
|
||||
{variant === "drawer" && onClose && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label={t("common.close")}
|
||||
className="rounded-lg border border-white/10 bg-white/5 p-2 text-zinc-300 hover:bg-white/10 hover:text-white md:hidden"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<nav className="px-3 py-2 flex-1 flex flex-col gap-2">
|
||||
<div className="space-y-1">{visibleItems.map(renderNavItem)}</div>
|
||||
<div className="mt-auto space-y-1 border-t border-white/10 pt-2">{renderNavItem(settingsItem)}</div>
|
||||
</nav>
|
||||
|
||||
<div className="px-5 py-4 border-t border-white/10 space-y-3">
|
||||
|
||||
55
components/recap/RecapDowntimeTop.tsx
Normal file
55
components/recap/RecapDowntimeTop.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
37
components/recap/RecapKpiRow.tsx
Normal file
37
components/recap/RecapKpiRow.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
46
components/recap/RecapMachineStatus.tsx
Normal file
46
components/recap/RecapMachineStatus.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
45
components/recap/RecapProductionBySku.tsx
Normal file
45
components/recap/RecapProductionBySku.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
57
components/recap/RecapWorkOrderStatus.tsx
Normal file
57
components/recap/RecapWorkOrderStatus.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,8 @@
|
||||
"common.close": "Close",
|
||||
"common.save": "Save",
|
||||
"common.copy": "Copy",
|
||||
"common.yes": "Yes",
|
||||
"common.no": "No",
|
||||
"nav.overview": "Overview",
|
||||
"nav.machines": "Machines",
|
||||
"nav.reports": "Reports",
|
||||
@@ -104,6 +106,43 @@
|
||||
"overview.event.slow-cycle": "slow-cycle",
|
||||
"overview.status.offline": "OFFLINE",
|
||||
"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.subtitle": "Select a machine to view live KPIs.",
|
||||
"machines.cancel": "Cancel",
|
||||
@@ -526,6 +565,7 @@
|
||||
"financial.field.scrapCostPerUnit": "Scrap cost / unit",
|
||||
"financial.field.rawMaterialCostPerUnit": "Raw material / unit",
|
||||
"nav.downtime": "Downtime",
|
||||
"nav.recap": "Daily recap",
|
||||
"settings.tabs.modules": "Modules",
|
||||
"settings.modules.title": "Modules",
|
||||
"settings.modules.subtitle": "Enable/disable UI modules depending on how the plant operates.",
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
"common.close": "Cerrar",
|
||||
"common.save": "Guardar",
|
||||
"common.copy": "Copiar",
|
||||
"common.yes": "Sí",
|
||||
"common.no": "No",
|
||||
"nav.overview": "Resumen",
|
||||
"nav.machines": "Máquinas",
|
||||
"nav.reports": "Reportes",
|
||||
@@ -104,6 +106,43 @@
|
||||
"overview.event.slow-cycle": "ciclo lento",
|
||||
"overview.status.offline": "FUERA DE 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.subtitle": "Selecciona una máquina para ver KPIs en vivo.",
|
||||
"machines.cancel": "Cancelar",
|
||||
@@ -526,6 +565,7 @@
|
||||
"financial.field.scrapCostPerUnit": "Costo scrap / unidad",
|
||||
"financial.field.rawMaterialCostPerUnit": "Costo materia prima / unidad",
|
||||
"nav.downtime": "Downtime",
|
||||
"nav.recap": "Resumen diario",
|
||||
"settings.tabs.modules": "Módulos",
|
||||
"settings.modules.title": "Módulos",
|
||||
"settings.modules.subtitle": "Activa/desactiva módulos según cómo opera la planta.",
|
||||
|
||||
661
lib/recap/getRecapData.ts
Normal file
661
lib/recap/getRecapData.ts
Normal 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
70
lib/recap/types.ts
Normal 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;
|
||||
};
|
||||
Reference in New Issue
Block a user