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>}
|
{loading && <div className="mb-4 text-sm text-zinc-400">{t("overview.loading")}</div>}
|
||||||
|
|
||||||
|
<div className="mb-4 rounded-2xl border border-white/10 bg-black/40 p-4">
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-semibold text-white">{t("overview.recap.title")}</div>
|
||||||
|
<div className="text-xs text-zinc-400">{t("overview.recap.subtitle")}</div>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href="/recap"
|
||||||
|
className="rounded-xl border border-emerald-500/30 bg-emerald-500/10 px-3 py-2 text-sm text-emerald-300 hover:bg-emerald-500/20"
|
||||||
|
>
|
||||||
|
{t("overview.recap.cta")}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4 xl:grid-cols-3">
|
<div className="grid grid-cols-1 gap-4 xl:grid-cols-3">
|
||||||
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
|
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
|
||||||
<div className="text-xs text-zinc-400">{t("overview.fleetHealth")}</div>
|
<div className="text-xs text-zinc-400">{t("overview.fleetHealth")}</div>
|
||||||
|
|||||||
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";
|
export default async function ReportsPage() {
|
||||||
import { useI18n } from "@/lib/i18n/useI18n";
|
const session = await requireSession();
|
||||||
|
if (!session) redirect("/login?next=/reports");
|
||||||
|
|
||||||
const ReportsCharts = lazy(() => import("./ReportsCharts"));
|
const machines = await prisma.machine.findMany({
|
||||||
|
where: { orgId: session.orgId },
|
||||||
type RangeKey = "24h" | "7d" | "30d" | "custom";
|
orderBy: { createdAt: "desc" },
|
||||||
|
select: { id: true, name: true },
|
||||||
type ReportSummary = {
|
|
||||||
oeeAvg: number | null;
|
|
||||||
availabilityAvg: number | null;
|
|
||||||
performanceAvg: number | null;
|
|
||||||
qualityAvg: number | null;
|
|
||||||
goodTotal: number | null;
|
|
||||||
scrapTotal: number | null;
|
|
||||||
targetTotal: number | null;
|
|
||||||
scrapRate: number | null;
|
|
||||||
topScrapSku?: string | null;
|
|
||||||
topScrapWorkOrder?: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ReportDowntime = {
|
|
||||||
macrostopSec: number;
|
|
||||||
microstopSec: number;
|
|
||||||
slowCycleCount: number;
|
|
||||||
qualitySpikeCount: number;
|
|
||||||
performanceDegradationCount: number;
|
|
||||||
oeeDropCount: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ReportTrendPoint = { t: string; v: number };
|
|
||||||
|
|
||||||
type ReportPayload = {
|
|
||||||
summary: ReportSummary;
|
|
||||||
downtime: ReportDowntime;
|
|
||||||
trend: {
|
|
||||||
oee: ReportTrendPoint[];
|
|
||||||
availability: ReportTrendPoint[];
|
|
||||||
performance: ReportTrendPoint[];
|
|
||||||
quality: ReportTrendPoint[];
|
|
||||||
scrapRate: ReportTrendPoint[];
|
|
||||||
};
|
|
||||||
distribution: {
|
|
||||||
cycleTime: {
|
|
||||||
label: string;
|
|
||||||
count: number;
|
|
||||||
rangeStart?: number;
|
|
||||||
rangeEnd?: number;
|
|
||||||
overflow?: "low" | "high";
|
|
||||||
minValue?: number;
|
|
||||||
maxValue?: number;
|
|
||||||
}[];
|
|
||||||
};
|
|
||||||
insights?: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
type MachineOption = { id: string; name: string };
|
|
||||||
type FilterOptions = { workOrders: string[]; skus: string[] };
|
|
||||||
type Translator = (key: string, vars?: Record<string, string | number>) => string;
|
|
||||||
|
|
||||||
function fmtPct(v?: number | null) {
|
|
||||||
if (v === null || v === undefined || Number.isNaN(v)) return "--";
|
|
||||||
return `${v.toFixed(1)}%`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function fmtDuration(sec?: number | null) {
|
|
||||||
if (!sec) return "--";
|
|
||||||
const h = Math.floor(sec / 3600);
|
|
||||||
const m = Math.floor((sec % 3600) / 60);
|
|
||||||
if (h > 0) return `${h}h ${m}m`;
|
|
||||||
return `${m}m`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function downsample<T>(rows: T[], max: number) {
|
|
||||||
if (rows.length <= max) return rows;
|
|
||||||
const step = Math.ceil(rows.length / max);
|
|
||||||
return rows.filter((_, idx) => idx % step === 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatTickLabel(ts: string, range: RangeKey) {
|
|
||||||
const d = new Date(ts);
|
|
||||||
if (Number.isNaN(d.getTime())) return ts;
|
|
||||||
const hh = String(d.getHours()).padStart(2, "0");
|
|
||||||
const mm = String(d.getMinutes()).padStart(2, "0");
|
|
||||||
const month = String(d.getMonth() + 1).padStart(2, "0");
|
|
||||||
const day = String(d.getDate()).padStart(2, "0");
|
|
||||||
if (range === "24h") return `${hh}:${mm}`;
|
|
||||||
return `${month}-${day}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ReportsChartsSkeleton() {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="mt-6 grid grid-cols-1 gap-4 xl:grid-cols-2">
|
|
||||||
{Array.from({ length: 2 }).map((_, idx) => (
|
|
||||||
<div key={idx} className="h-[320px] rounded-2xl border border-white/10 bg-white/5" />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="mt-6 grid grid-cols-1 gap-4 xl:grid-cols-3">
|
|
||||||
{Array.from({ length: 3 }).map((_, idx) => (
|
|
||||||
<div key={idx} className="h-[280px] rounded-2xl border border-white/10 bg-white/5" />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function toMachineOption(value: unknown): MachineOption | null {
|
|
||||||
if (!value || typeof value !== "object") return null;
|
|
||||||
const record = value as Record<string, unknown>;
|
|
||||||
const id = typeof record.id === "string" ? record.id : "";
|
|
||||||
const name = typeof record.name === "string" ? record.name : "";
|
|
||||||
if (!id || !name) return null;
|
|
||||||
return { id, name };
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildCsv(report: ReportPayload, t: Translator) {
|
|
||||||
const rows = new Map<string, Record<string, string | number>>();
|
|
||||||
const addSeries = (series: ReportTrendPoint[], key: string) => {
|
|
||||||
for (const p of series) {
|
|
||||||
const row = rows.get(p.t) ?? { timestamp: p.t };
|
|
||||||
row[key] = p.v;
|
|
||||||
rows.set(p.t, row);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
addSeries(report.trend.oee, "oee");
|
|
||||||
addSeries(report.trend.availability, "availability");
|
|
||||||
addSeries(report.trend.performance, "performance");
|
|
||||||
addSeries(report.trend.quality, "quality");
|
|
||||||
addSeries(report.trend.scrapRate, "scrapRate");
|
|
||||||
|
|
||||||
const ordered = [...rows.values()].sort((a, b) => {
|
|
||||||
const at = new Date(String(a.timestamp)).getTime();
|
|
||||||
const bt = new Date(String(b.timestamp)).getTime();
|
|
||||||
return at - bt;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const header = ["timestamp", "oee", "availability", "performance", "quality", "scrapRate"].join(",");
|
return <ReportsPageClient initialMachines={machines} />;
|
||||||
const lines = ordered.map((row) =>
|
|
||||||
[
|
|
||||||
row.timestamp,
|
|
||||||
row.oee ?? "",
|
|
||||||
row.availability ?? "",
|
|
||||||
row.performance ?? "",
|
|
||||||
row.quality ?? "",
|
|
||||||
row.scrapRate ?? "",
|
|
||||||
]
|
|
||||||
.map((v) => (v == null ? "" : String(v)))
|
|
||||||
.join(",")
|
|
||||||
);
|
|
||||||
|
|
||||||
const summary = report.summary;
|
|
||||||
const downtime = report.downtime;
|
|
||||||
|
|
||||||
const sectionLines: string[] = [];
|
|
||||||
sectionLines.push(
|
|
||||||
[t("reports.csv.section"), t("reports.csv.key"), t("reports.csv.value")].join(",")
|
|
||||||
);
|
|
||||||
const addRow = (section: string, key: string, value: string | number | null | undefined) => {
|
|
||||||
sectionLines.push(
|
|
||||||
[section, key, value == null ? "" : String(value)]
|
|
||||||
.map((v) => (v.includes(",") ? `"${v.replace(/\"/g, '""')}"` : v))
|
|
||||||
.join(",")
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
addRow("summary", "oeeAvg", summary.oeeAvg);
|
|
||||||
addRow("summary", "availabilityAvg", summary.availabilityAvg);
|
|
||||||
addRow("summary", "performanceAvg", summary.performanceAvg);
|
|
||||||
addRow("summary", "qualityAvg", summary.qualityAvg);
|
|
||||||
addRow("summary", "goodTotal", summary.goodTotal);
|
|
||||||
addRow("summary", "scrapTotal", summary.scrapTotal);
|
|
||||||
addRow("summary", "targetTotal", summary.targetTotal);
|
|
||||||
addRow("summary", "scrapRate", summary.scrapRate);
|
|
||||||
addRow("summary", "topScrapSku", summary.topScrapSku ?? "");
|
|
||||||
addRow("summary", "topScrapWorkOrder", summary.topScrapWorkOrder ?? "");
|
|
||||||
|
|
||||||
addRow("loss_drivers", "macrostopSec", downtime.macrostopSec);
|
|
||||||
addRow("loss_drivers", "microstopSec", downtime.microstopSec);
|
|
||||||
addRow("loss_drivers", "slowCycleCount", downtime.slowCycleCount);
|
|
||||||
addRow("loss_drivers", "qualitySpikeCount", downtime.qualitySpikeCount);
|
|
||||||
addRow("loss_drivers", "performanceDegradationCount", downtime.performanceDegradationCount);
|
|
||||||
addRow("loss_drivers", "oeeDropCount", downtime.oeeDropCount);
|
|
||||||
|
|
||||||
for (const bin of report.distribution.cycleTime) {
|
|
||||||
addRow("cycle_distribution", bin.label, bin.count);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (report.insights?.length) {
|
|
||||||
report.insights.forEach((note, idx) => addRow("insights", String(idx + 1), note));
|
|
||||||
}
|
|
||||||
|
|
||||||
return [header, ...lines, "", ...sectionLines].join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
function downloadText(filename: string, content: string) {
|
|
||||||
const blob = new Blob([content], { type: "text/csv;charset=utf-8;" });
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const link = document.createElement("a");
|
|
||||||
link.href = url;
|
|
||||||
link.setAttribute("download", filename);
|
|
||||||
document.body.appendChild(link);
|
|
||||||
link.click();
|
|
||||||
document.body.removeChild(link);
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildPdfHtml(
|
|
||||||
report: ReportPayload,
|
|
||||||
rangeLabel: string,
|
|
||||||
filters: { machine: string; workOrder: string; sku: string },
|
|
||||||
t: Translator
|
|
||||||
) {
|
|
||||||
const summary = report.summary;
|
|
||||||
const downtime = report.downtime;
|
|
||||||
const cycleBins = report.distribution.cycleTime;
|
|
||||||
const insights = report.insights ?? [];
|
|
||||||
|
|
||||||
return `
|
|
||||||
<!doctype html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<title>${t("reports.pdf.title")}</title>
|
|
||||||
<style>
|
|
||||||
body { font-family: Arial, sans-serif; color: #111; margin: 24px; }
|
|
||||||
h1 { margin: 0 0 6px; }
|
|
||||||
.meta { margin-bottom: 16px; color: #555; }
|
|
||||||
.grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
|
|
||||||
.card { border: 1px solid #ddd; border-radius: 8px; padding: 12px; }
|
|
||||||
.label { color: #666; font-size: 12px; text-transform: uppercase; letter-spacing: .03em; }
|
|
||||||
.value { font-size: 18px; font-weight: 600; margin-top: 6px; }
|
|
||||||
table { width: 100%; border-collapse: collapse; margin-top: 12px; }
|
|
||||||
th, td { border: 1px solid #ddd; padding: 6px 8px; font-size: 12px; }
|
|
||||||
th { background: #f5f5f5; text-align: left; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<h1>${t("reports.title")}</h1>
|
|
||||||
<div class="meta">${t("reports.pdf.range")}: ${rangeLabel} | ${t("reports.pdf.machine")}: ${filters.machine} | ${t("reports.pdf.workOrder")}: ${filters.workOrder} | ${t("reports.pdf.sku")}: ${filters.sku}</div>
|
|
||||||
|
|
||||||
<div class="grid">
|
|
||||||
<div class="card">
|
|
||||||
<div class="label">OEE (avg)</div>
|
|
||||||
<div class="value">${summary.oeeAvg != null ? summary.oeeAvg.toFixed(1) + "%" : "--"}</div>
|
|
||||||
</div>
|
|
||||||
<div class="card">
|
|
||||||
<div class="label">Availability (avg)</div>
|
|
||||||
<div class="value">${summary.availabilityAvg != null ? summary.availabilityAvg.toFixed(1) + "%" : "--"}</div>
|
|
||||||
</div>
|
|
||||||
<div class="card">
|
|
||||||
<div class="label">Performance (avg)</div>
|
|
||||||
<div class="value">${summary.performanceAvg != null ? summary.performanceAvg.toFixed(1) + "%" : "--"}</div>
|
|
||||||
</div>
|
|
||||||
<div class="card">
|
|
||||||
<div class="label">Quality (avg)</div>
|
|
||||||
<div class="value">${summary.qualityAvg != null ? summary.qualityAvg.toFixed(1) + "%" : "--"}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card" style="margin-top: 16px;">
|
|
||||||
<div class="label">${t("reports.pdf.topLoss")}</div>
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr><th>${t("reports.pdf.metric")}</th><th>${t("reports.pdf.value")}</th></tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr><td>${t("reports.loss.macrostop")} (sec)</td><td>${downtime.macrostopSec}</td></tr>
|
|
||||||
<tr><td>${t("reports.loss.microstop")} (sec)</td><td>${downtime.microstopSec}</td></tr>
|
|
||||||
<tr><td>${t("reports.loss.slowCycle")}</td><td>${downtime.slowCycleCount}</td></tr>
|
|
||||||
<tr><td>${t("reports.loss.qualitySpike")}</td><td>${downtime.qualitySpikeCount}</td></tr>
|
|
||||||
<tr><td>${t("reports.loss.perfDegradation")}</td><td>${downtime.performanceDegradationCount}</td></tr>
|
|
||||||
<tr><td>${t("reports.loss.oeeDrop")}</td><td>${downtime.oeeDropCount}</td></tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card" style="margin-top: 16px;">
|
|
||||||
<div class="label">${t("reports.pdf.qualitySummary")}</div>
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr><th>${t("reports.pdf.metric")}</th><th>${t("reports.pdf.value")}</th></tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr><td>${t("reports.scrapRate")}</td><td>${summary.scrapRate != null ? summary.scrapRate.toFixed(1) + "%" : "--"}</td></tr>
|
|
||||||
<tr><td>${t("overview.good")}</td><td>${summary.goodTotal ?? "--"}</td></tr>
|
|
||||||
<tr><td>${t("overview.scrap")}</td><td>${summary.scrapTotal ?? "--"}</td></tr>
|
|
||||||
<tr><td>${t("overview.target")}</td><td>${summary.targetTotal ?? "--"}</td></tr>
|
|
||||||
<tr><td>${t("reports.topScrapSku")}</td><td>${summary.topScrapSku ?? "--"}</td></tr>
|
|
||||||
<tr><td>${t("reports.topScrapWorkOrder")}</td><td>${summary.topScrapWorkOrder ?? "--"}</td></tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card" style="margin-top: 16px;">
|
|
||||||
<div class="label">${t("reports.pdf.cycleDistribution")}</div>
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr><th>${t("reports.tooltip.range")}</th><th>${t("reports.tooltip.cycles")}</th></tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
${cycleBins
|
|
||||||
.map((bin) => `<tr><td>${bin.label}</td><td>${bin.count}</td></tr>`)
|
|
||||||
.join("")}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card" style="margin-top: 16px;">
|
|
||||||
<div class="label">${t("reports.pdf.notes")}</div>
|
|
||||||
${insights.length ? `<ul>${insights.map((n) => `<li>${n}</li>`).join("")}</ul>` : `<div>${t("reports.pdf.none")}</div>`}
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
`.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ReportsPage() {
|
|
||||||
const { t, locale } = useI18n();
|
|
||||||
const [range, setRange] = useState<RangeKey>("24h");
|
|
||||||
const [report, setReport] = useState<ReportPayload | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [machines, setMachines] = useState<MachineOption[]>([]);
|
|
||||||
const [filterOptions, setFilterOptions] = useState<FilterOptions>({ workOrders: [], skus: [] });
|
|
||||||
const [machineId, setMachineId] = useState("");
|
|
||||||
const [workOrderId, setWorkOrderId] = useState("");
|
|
||||||
const [sku, setSku] = useState("");
|
|
||||||
|
|
||||||
const rangeLabel = useMemo(() => {
|
|
||||||
if (range === "24h") return t("reports.rangeLabel.last24");
|
|
||||||
if (range === "7d") return t("reports.rangeLabel.last7");
|
|
||||||
if (range === "30d") return t("reports.rangeLabel.last30");
|
|
||||||
return t("reports.rangeLabel.custom");
|
|
||||||
}, [range, t]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let alive = true;
|
|
||||||
|
|
||||||
async function loadMachines() {
|
|
||||||
try {
|
|
||||||
const res = await fetch("/api/machines", { cache: "no-store" });
|
|
||||||
const json = await res.json();
|
|
||||||
if (!alive) return;
|
|
||||||
const rows: unknown[] = Array.isArray(json?.machines) ? json.machines : [];
|
|
||||||
const options: MachineOption[] = [];
|
|
||||||
rows.forEach((row) => {
|
|
||||||
const option = toMachineOption(row);
|
|
||||||
if (option) options.push(option);
|
|
||||||
});
|
|
||||||
setMachines(options);
|
|
||||||
} catch {
|
|
||||||
if (!alive) return;
|
|
||||||
setMachines([]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
loadMachines();
|
|
||||||
return () => {
|
|
||||||
alive = false;
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let alive = true;
|
|
||||||
const controller = new AbortController();
|
|
||||||
|
|
||||||
async function load() {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const params = new URLSearchParams({ range });
|
|
||||||
if (machineId) params.set("machineId", machineId);
|
|
||||||
if (workOrderId) params.set("workOrderId", workOrderId);
|
|
||||||
if (sku) params.set("sku", sku);
|
|
||||||
|
|
||||||
const res = await fetch(`/api/reports?${params.toString()}`, {
|
|
||||||
cache: "no-store",
|
|
||||||
signal: controller.signal,
|
|
||||||
});
|
|
||||||
const json = await res.json();
|
|
||||||
if (!alive) return;
|
|
||||||
if (!res.ok || json?.ok === false) {
|
|
||||||
setError(json?.error ?? t("reports.error.failed"));
|
|
||||||
setReport(null);
|
|
||||||
} else {
|
|
||||||
setReport(json);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
if (!alive) return;
|
|
||||||
setError(t("reports.error.network"));
|
|
||||||
setReport(null);
|
|
||||||
} finally {
|
|
||||||
if (alive) setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
load();
|
|
||||||
return () => {
|
|
||||||
alive = false;
|
|
||||||
controller.abort();
|
|
||||||
};
|
|
||||||
}, [range, machineId, workOrderId, sku, t]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let alive = true;
|
|
||||||
const controller = new AbortController();
|
|
||||||
|
|
||||||
async function loadFilters() {
|
|
||||||
try {
|
|
||||||
const params = new URLSearchParams({ range });
|
|
||||||
if (machineId) params.set("machineId", machineId);
|
|
||||||
const res = await fetch(`/api/reports/filters?${params.toString()}`, {
|
|
||||||
cache: "no-store",
|
|
||||||
signal: controller.signal,
|
|
||||||
});
|
|
||||||
const json = await res.json();
|
|
||||||
if (!alive) return;
|
|
||||||
if (!res.ok || json?.ok === false) {
|
|
||||||
setFilterOptions({ workOrders: [], skus: [] });
|
|
||||||
} else {
|
|
||||||
setFilterOptions({
|
|
||||||
workOrders: json.workOrders ?? [],
|
|
||||||
skus: json.skus ?? [],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
if (!alive) return;
|
|
||||||
setFilterOptions({ workOrders: [], skus: [] });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
loadFilters();
|
|
||||||
return () => {
|
|
||||||
alive = false;
|
|
||||||
controller.abort();
|
|
||||||
};
|
|
||||||
}, [range, machineId]);
|
|
||||||
|
|
||||||
const summary = report?.summary;
|
|
||||||
const downtime = report?.downtime;
|
|
||||||
const trend = report?.trend;
|
|
||||||
const distribution = report?.distribution;
|
|
||||||
|
|
||||||
const oeeSeries = useMemo(() => {
|
|
||||||
const rows = trend?.oee ?? [];
|
|
||||||
const trimmed = downsample(rows, 600);
|
|
||||||
return trimmed.map((p) => ({
|
|
||||||
ts: p.t,
|
|
||||||
label: formatTickLabel(p.t, range),
|
|
||||||
value: p.v,
|
|
||||||
}));
|
|
||||||
}, [trend?.oee, range]);
|
|
||||||
|
|
||||||
const scrapSeries = useMemo(() => {
|
|
||||||
const rows = trend?.scrapRate ?? [];
|
|
||||||
const trimmed = downsample(rows, 600);
|
|
||||||
return trimmed.map((p) => ({
|
|
||||||
ts: p.t,
|
|
||||||
label: formatTickLabel(p.t, range),
|
|
||||||
value: p.v,
|
|
||||||
}));
|
|
||||||
}, [trend?.scrapRate, range]);
|
|
||||||
|
|
||||||
const cycleHistogram = useMemo(() => {
|
|
||||||
return distribution?.cycleTime ?? [];
|
|
||||||
}, [distribution?.cycleTime]);
|
|
||||||
|
|
||||||
const downtimeSeries = useMemo(() => {
|
|
||||||
if (!downtime) return [];
|
|
||||||
return [
|
|
||||||
{ name: "Macrostop", value: Math.round(downtime.macrostopSec / 60) },
|
|
||||||
{ name: "Microstop", value: Math.round(downtime.microstopSec / 60) },
|
|
||||||
];
|
|
||||||
}, [downtime]);
|
|
||||||
|
|
||||||
const downtimeColors: Record<string, string> = {
|
|
||||||
Macrostop: "#FF3B5C",
|
|
||||||
Microstop: "#FF7A00",
|
|
||||||
};
|
|
||||||
|
|
||||||
const lossRows = useMemo(
|
|
||||||
() => [
|
|
||||||
{ label: t("reports.loss.macrostop"), value: fmtDuration(downtime?.macrostopSec) },
|
|
||||||
{ label: t("reports.loss.microstop"), value: fmtDuration(downtime?.microstopSec) },
|
|
||||||
{ label: t("reports.loss.slowCycle"), value: downtime ? `${downtime.slowCycleCount}` : "--" },
|
|
||||||
{ label: t("reports.loss.qualitySpike"), value: downtime ? `${downtime.qualitySpikeCount}` : "--" },
|
|
||||||
{ label: t("reports.loss.oeeDrop"), value: downtime ? `${downtime.oeeDropCount}` : "--" },
|
|
||||||
{
|
|
||||||
label: t("reports.loss.perfDegradation"),
|
|
||||||
value: downtime ? `${downtime.performanceDegradationCount}` : "--",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[downtime, t]
|
|
||||||
);
|
|
||||||
|
|
||||||
const machineLabel = useMemo(() => {
|
|
||||||
if (!machineId) return t("reports.filter.allMachines");
|
|
||||||
return machines.find((m) => m.id === machineId)?.name ?? machineId;
|
|
||||||
}, [machineId, machines, t]);
|
|
||||||
|
|
||||||
const workOrderLabel = workOrderId || t("reports.filter.allWorkOrders");
|
|
||||||
const skuLabel = sku || t("reports.filter.allSkus");
|
|
||||||
|
|
||||||
const handleExportCsv = () => {
|
|
||||||
if (!report) return;
|
|
||||||
const csv = buildCsv(report, t);
|
|
||||||
downloadText("reports.csv", csv);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleExportPdf = () => {
|
|
||||||
if (!report) return;
|
|
||||||
const html = buildPdfHtml(
|
|
||||||
report,
|
|
||||||
rangeLabel,
|
|
||||||
{
|
|
||||||
machine: machineLabel,
|
|
||||||
workOrder: workOrderLabel,
|
|
||||||
sku: skuLabel,
|
|
||||||
},
|
|
||||||
t
|
|
||||||
);
|
|
||||||
|
|
||||||
const win = window.open("", "_blank", "width=900,height=650");
|
|
||||||
if (!win) return;
|
|
||||||
win.document.open();
|
|
||||||
win.document.write(html);
|
|
||||||
win.document.close();
|
|
||||||
win.focus();
|
|
||||||
setTimeout(() => win.print(), 300);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="p-4 sm:p-6">
|
|
||||||
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-semibold text-white">{t("reports.title")}</h1>
|
|
||||||
<p className="text-sm text-zinc-400">{t("reports.subtitle")}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex w-full flex-wrap items-center gap-2 sm:w-auto">
|
|
||||||
<button
|
|
||||||
onClick={handleExportCsv}
|
|
||||||
className="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-white hover:bg-white/10 sm:w-auto"
|
|
||||||
>
|
|
||||||
{t("reports.exportCsv")}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleExportPdf}
|
|
||||||
className="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-white hover:bg-white/10 sm:w-auto"
|
|
||||||
>
|
|
||||||
{t("reports.exportPdf")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
|
||||||
<div className="text-sm font-semibold text-white">{t("reports.filters")}</div>
|
|
||||||
<div className="text-xs text-zinc-400">{rangeLabel}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4 grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-4">
|
|
||||||
<div className="rounded-xl border border-white/10 bg-black/20 p-3">
|
|
||||||
<div className="text-[11px] text-zinc-400">{t("reports.filter.range")}</div>
|
|
||||||
<div className="mt-2 flex flex-wrap gap-2">
|
|
||||||
{(["24h", "7d", "30d", "custom"] as RangeKey[]).map((k) => (
|
|
||||||
<button
|
|
||||||
key={k}
|
|
||||||
onClick={() => setRange(k)}
|
|
||||||
className={`rounded-full border px-3 py-1 text-xs ${
|
|
||||||
range === k
|
|
||||||
? "border-emerald-500/30 bg-emerald-500/15 text-emerald-200"
|
|
||||||
: "border-white/10 bg-white/5 text-zinc-300 hover:bg-white/10"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{k.toUpperCase()}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-xl border border-white/10 bg-black/20 p-3">
|
|
||||||
<div className="text-[11px] text-zinc-400">{t("reports.filter.machine")}</div>
|
|
||||||
<select
|
|
||||||
value={machineId}
|
|
||||||
onChange={(e) => setMachineId(e.target.value)}
|
|
||||||
className="mt-2 w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm text-zinc-300"
|
|
||||||
>
|
|
||||||
<option value="">{t("reports.filter.allMachines")}</option>
|
|
||||||
{machines.map((m) => (
|
|
||||||
<option key={m.id} value={m.id}>
|
|
||||||
{m.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-xl border border-white/10 bg-black/20 p-3">
|
|
||||||
<div className="text-[11px] text-zinc-400">{t("reports.filter.workOrder")}</div>
|
|
||||||
<input
|
|
||||||
list="work-order-list"
|
|
||||||
value={workOrderId}
|
|
||||||
onChange={(e) => setWorkOrderId(e.target.value)}
|
|
||||||
placeholder={t("reports.filter.allWorkOrders")}
|
|
||||||
className="mt-2 w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm text-zinc-300 placeholder:text-zinc-500"
|
|
||||||
/>
|
|
||||||
<datalist id="work-order-list">
|
|
||||||
{filterOptions.workOrders.map((wo) => (
|
|
||||||
<option key={wo} value={wo} />
|
|
||||||
))}
|
|
||||||
</datalist>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-xl border border-white/10 bg-black/20 p-3">
|
|
||||||
<div className="text-[11px] text-zinc-400">{t("reports.filter.sku")}</div>
|
|
||||||
<input
|
|
||||||
list="sku-list"
|
|
||||||
value={sku}
|
|
||||||
onChange={(e) => setSku(e.target.value)}
|
|
||||||
placeholder={t("reports.filter.allSkus")}
|
|
||||||
className="mt-2 w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm text-zinc-300 placeholder:text-zinc-500"
|
|
||||||
/>
|
|
||||||
<datalist id="sku-list">
|
|
||||||
{filterOptions.skus.map((s) => (
|
|
||||||
<option key={s} value={s} />
|
|
||||||
))}
|
|
||||||
</datalist>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4">
|
|
||||||
{loading && <div className="text-sm text-zinc-400">{t("reports.loading")}</div>}
|
|
||||||
{error && !loading && (
|
|
||||||
<div className="rounded-2xl border border-red-500/20 bg-red-500/10 p-4 text-sm text-red-200">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4 grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
|
|
||||||
{[
|
|
||||||
{ label: "OEE", value: fmtPct(summary?.oeeAvg), tone: "text-emerald-300" },
|
|
||||||
{ label: "Availability", value: fmtPct(summary?.availabilityAvg), tone: "text-white" },
|
|
||||||
{ label: "Performance", value: fmtPct(summary?.performanceAvg), tone: "text-white" },
|
|
||||||
{ label: "Quality", value: fmtPct(summary?.qualityAvg), tone: "text-white" },
|
|
||||||
].map((kpi) => (
|
|
||||||
<div key={kpi.label} className="rounded-2xl border border-white/10 bg-white/5 p-5">
|
|
||||||
<div className="text-xs text-zinc-400">{kpi.label} (avg)</div>
|
|
||||||
<div className={`mt-2 text-3xl font-semibold ${kpi.tone}`}>{kpi.value}</div>
|
|
||||||
<div className="mt-2 text-xs text-zinc-500">
|
|
||||||
{summary ? t("reports.kpi.note.withData") : t("reports.kpi.note.noData")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Suspense fallback={<ReportsChartsSkeleton />}>
|
|
||||||
<ReportsCharts
|
|
||||||
oeeSeries={oeeSeries}
|
|
||||||
downtimeSeries={downtimeSeries}
|
|
||||||
downtimeColors={downtimeColors}
|
|
||||||
cycleHistogram={cycleHistogram}
|
|
||||||
scrapSeries={scrapSeries}
|
|
||||||
lossRows={lossRows}
|
|
||||||
locale={locale}
|
|
||||||
t={t}
|
|
||||||
/>
|
|
||||||
</Suspense>
|
|
||||||
|
|
||||||
<div className="mt-6 grid grid-cols-1 gap-4 xl:grid-cols-2">
|
|
||||||
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
|
|
||||||
<div className="mb-3 text-sm font-semibold text-white">{t("reports.qualitySummary")}</div>
|
|
||||||
<div className="space-y-3 text-sm text-zinc-300">
|
|
||||||
<div className="rounded-xl border border-white/10 bg-black/20 p-3">
|
|
||||||
<div className="text-xs text-zinc-400">{t("reports.scrapRate")}</div>
|
|
||||||
<div className="mt-1 text-lg font-semibold text-white">
|
|
||||||
{summary?.scrapRate != null ? fmtPct(summary.scrapRate) : "--"}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-xl border border-white/10 bg-black/20 p-3">
|
|
||||||
<div className="text-xs text-zinc-400">{t("reports.topScrapSku")}</div>
|
|
||||||
<div className="mt-1 text-sm text-zinc-300">{summary?.topScrapSku ?? "--"}</div>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-xl border border-white/10 bg-black/20 p-3">
|
|
||||||
<div className="text-xs text-zinc-400">{t("reports.topScrapWorkOrder")}</div>
|
|
||||||
<div className="mt-1 text-sm text-zinc-300">{summary?.topScrapWorkOrder ?? "--"}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
|
|
||||||
<div className="mb-3 text-sm font-semibold text-white">{t("reports.notes")}</div>
|
|
||||||
<div className="rounded-xl border border-white/10 bg-black/20 p-4 text-sm text-zinc-300">
|
|
||||||
<div className="mb-2 text-xs text-zinc-400">{t("reports.notes.suggested")}</div>
|
|
||||||
{report?.insights && report.insights.length > 0 ? (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{report.insights.map((note, idx) => (
|
|
||||||
<div key={idx}>{note}</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div>{t("reports.notes.none")}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -439,7 +439,9 @@ export async function POST(req: Request) {
|
|||||||
|
|
||||||
// If the payload carries a `reason`, create the corresponding ReasonEntry.
|
// If the payload carries a `reason`, create the corresponding ReasonEntry.
|
||||||
// If it doesn't, still create an "UNCLASSIFIED" downtime ReasonEntry for stop events so the dashboard can show coverage.
|
// If it doesn't, still create an "UNCLASSIFIED" downtime ReasonEntry for stop events so the dashboard can show coverage.
|
||||||
if (evReason || finalType === "microstop" || finalType === "macrostop" || finalType === "downtime-acknowledged") {
|
if (evRecord.is_update || evRecord.is_auto_ack || dataObj.is_update || dataObj.is_auto_ack){
|
||||||
|
// skip duplicate reasonEntry for refresh/ack
|
||||||
|
} else if (evReason || finalType === "microstop" || finalType === "macrostop" || finalType === "downtime-acknowledged"){
|
||||||
const reasonRaw: Record<string, unknown> =
|
const reasonRaw: Record<string, unknown> =
|
||||||
evReason ??
|
evReason ??
|
||||||
({
|
({
|
||||||
|
|||||||
29
app/api/recap/route.ts
Normal file
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 { NextResponse } from "next/server";
|
||||||
import type { NextRequest } from "next/server";
|
import type { NextRequest } from "next/server";
|
||||||
|
import { createHash } from "crypto";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { requireSession } from "@/lib/auth/requireSession";
|
import { requireSession } from "@/lib/auth/requireSession";
|
||||||
import { logLine } from "@/lib/logger";
|
import { logLine } from "@/lib/logger";
|
||||||
@@ -42,6 +43,10 @@ function pickRange(req: NextRequest) {
|
|||||||
return { start: new Date(now.getTime() - ms), end: now };
|
return { start: new Date(now.getTime() - ms), end: now };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toMs(value?: Date | null) {
|
||||||
|
return value ? value.getTime() : 0;
|
||||||
|
}
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
const perfEnabled = PERF_LOGS_ENABLED;
|
const perfEnabled = PERF_LOGS_ENABLED;
|
||||||
const totalStart = nowMs();
|
const totalStart = nowMs();
|
||||||
@@ -67,6 +72,32 @@ export async function GET(req: NextRequest) {
|
|||||||
|
|
||||||
if (perfEnabled) timings.preQuery = elapsedMs(preQueryStart);
|
if (perfEnabled) timings.preQuery = elapsedMs(preQueryStart);
|
||||||
|
|
||||||
|
const versionStart = nowMs();
|
||||||
|
const cycleMax = await prisma.machineCycle.aggregate({
|
||||||
|
where: baseWhere,
|
||||||
|
_max: { tsServer: true },
|
||||||
|
});
|
||||||
|
if (perfEnabled) timings.version = elapsedMs(versionStart);
|
||||||
|
|
||||||
|
const versionParts = [
|
||||||
|
session.orgId,
|
||||||
|
range,
|
||||||
|
machineId ?? "",
|
||||||
|
toMs(cycleMax._max.tsServer),
|
||||||
|
];
|
||||||
|
const etag = `W/"${createHash("sha1").update(versionParts.join("|")).digest("hex")}"`;
|
||||||
|
const responseHeaders = new Headers({
|
||||||
|
"Cache-Control": "private, no-cache, max-age=0, must-revalidate",
|
||||||
|
ETag: etag,
|
||||||
|
"Last-Modified": new Date(toMs(cycleMax._max.tsServer) || 0).toUTCString(),
|
||||||
|
Vary: "Cookie",
|
||||||
|
});
|
||||||
|
|
||||||
|
const ifNoneMatch = req.headers.get("if-none-match");
|
||||||
|
if (ifNoneMatch && ifNoneMatch === etag) {
|
||||||
|
return new NextResponse(null, { status: 304, headers: responseHeaders });
|
||||||
|
}
|
||||||
|
|
||||||
const workOrdersStart = nowMs();
|
const workOrdersStart = nowMs();
|
||||||
const workOrderRows = await prisma.machineCycle.findMany({
|
const workOrderRows = await prisma.machineCycle.findMany({
|
||||||
where: { ...baseWhere, workOrderId: { not: null } },
|
where: { ...baseWhere, workOrderId: { not: null } },
|
||||||
@@ -90,7 +121,6 @@ export async function GET(req: NextRequest) {
|
|||||||
|
|
||||||
const payload = { ok: true, workOrders, skus };
|
const payload = { ok: true, workOrders, skus };
|
||||||
|
|
||||||
const responseHeaders = new Headers();
|
|
||||||
if (perfEnabled) {
|
if (perfEnabled) {
|
||||||
timings.postQuery = elapsedMs(postQueryStart);
|
timings.postQuery = elapsedMs(postQueryStart);
|
||||||
timings.total = elapsedMs(totalStart);
|
timings.total = elapsedMs(totalStart);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import type { NextRequest } from "next/server";
|
import type { NextRequest } from "next/server";
|
||||||
|
import { createHash } from "crypto";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { requireSession } from "@/lib/auth/requireSession";
|
import { requireSession } from "@/lib/auth/requireSession";
|
||||||
import { logLine } from "@/lib/logger";
|
import { logLine } from "@/lib/logger";
|
||||||
@@ -46,6 +47,10 @@ function safeNum(v: unknown) {
|
|||||||
return typeof v === "number" && Number.isFinite(v) ? v : null;
|
return typeof v === "number" && Number.isFinite(v) ? v : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toMs(value?: Date | null) {
|
||||||
|
return value ? value.getTime() : 0;
|
||||||
|
}
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
const perfEnabled = PERF_LOGS_ENABLED;
|
const perfEnabled = PERF_LOGS_ENABLED;
|
||||||
const totalStart = nowMs();
|
const totalStart = nowMs();
|
||||||
@@ -73,6 +78,52 @@ export async function GET(req: NextRequest) {
|
|||||||
|
|
||||||
if (perfEnabled) timings.preQuery = elapsedMs(preQueryStart);
|
if (perfEnabled) timings.preQuery = elapsedMs(preQueryStart);
|
||||||
|
|
||||||
|
const versionStart = nowMs();
|
||||||
|
const [kpiMax, cycleMax, eventMax] = await Promise.all([
|
||||||
|
prisma.machineKpiSnapshot.aggregate({
|
||||||
|
where: { ...baseWhere, ts: { gte: start, lte: end } },
|
||||||
|
_max: { tsServer: true },
|
||||||
|
}),
|
||||||
|
prisma.machineCycle.aggregate({
|
||||||
|
where: { ...baseWhere, ts: { gte: start, lte: end } },
|
||||||
|
_max: { tsServer: true },
|
||||||
|
}),
|
||||||
|
prisma.machineEvent.aggregate({
|
||||||
|
where: { ...baseWhere, ts: { gte: start, lte: end } },
|
||||||
|
_max: { tsServer: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
if (perfEnabled) timings.version = elapsedMs(versionStart);
|
||||||
|
|
||||||
|
const lastModifiedMs = Math.max(
|
||||||
|
toMs(kpiMax._max.tsServer),
|
||||||
|
toMs(cycleMax._max.tsServer),
|
||||||
|
toMs(eventMax._max.tsServer)
|
||||||
|
);
|
||||||
|
|
||||||
|
const versionParts = [
|
||||||
|
session.orgId,
|
||||||
|
range,
|
||||||
|
machineId ?? "",
|
||||||
|
workOrderId ?? "",
|
||||||
|
sku ?? "",
|
||||||
|
toMs(kpiMax._max.tsServer),
|
||||||
|
toMs(cycleMax._max.tsServer),
|
||||||
|
toMs(eventMax._max.tsServer),
|
||||||
|
];
|
||||||
|
const etag = `W/"${createHash("sha1").update(versionParts.join("|")).digest("hex")}"`;
|
||||||
|
const responseHeaders = new Headers({
|
||||||
|
"Cache-Control": "private, no-cache, max-age=0, must-revalidate",
|
||||||
|
ETag: etag,
|
||||||
|
"Last-Modified": new Date(lastModifiedMs || 0).toUTCString(),
|
||||||
|
Vary: "Cookie",
|
||||||
|
});
|
||||||
|
|
||||||
|
const ifNoneMatch = req.headers.get("if-none-match");
|
||||||
|
if (ifNoneMatch && ifNoneMatch === etag) {
|
||||||
|
return new NextResponse(null, { status: 304, headers: responseHeaders });
|
||||||
|
}
|
||||||
|
|
||||||
const kpiStart = nowMs();
|
const kpiStart = nowMs();
|
||||||
const kpiRows = await prisma.machineKpiSnapshot.findMany({
|
const kpiRows = await prisma.machineKpiSnapshot.findMany({
|
||||||
where: { ...baseWhere, ts: { gte: start, lte: end } },
|
where: { ...baseWhere, ts: { gte: start, lte: end } },
|
||||||
@@ -405,7 +456,6 @@ export async function GET(req: NextRequest) {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const responseHeaders = new Headers();
|
|
||||||
if (perfEnabled) {
|
if (perfEnabled) {
|
||||||
timings.postQuery = elapsedMs(postQueryStart);
|
timings.postQuery = elapsedMs(postQueryStart);
|
||||||
timings.total = elapsedMs(totalStart);
|
timings.total = elapsedMs(totalStart);
|
||||||
|
|||||||
@@ -3,7 +3,18 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname, useRouter } from "next/navigation";
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
import { useEffect, useMemo, useState, useTransition } from "react";
|
import { useEffect, useMemo, useState, useTransition } from "react";
|
||||||
import { BarChart3, Bell, DollarSign, LayoutGrid, Loader2, LogOut, Settings, Wrench, X } from "lucide-react";
|
import {
|
||||||
|
BarChart3,
|
||||||
|
Bell,
|
||||||
|
DollarSign,
|
||||||
|
LayoutGrid,
|
||||||
|
Loader2,
|
||||||
|
LogOut,
|
||||||
|
Settings,
|
||||||
|
Sunrise,
|
||||||
|
Wrench,
|
||||||
|
X,
|
||||||
|
} from "lucide-react";
|
||||||
import type { LucideIcon } from "lucide-react";
|
import type { LucideIcon } from "lucide-react";
|
||||||
import { useI18n } from "@/lib/i18n/useI18n";
|
import { useI18n } from "@/lib/i18n/useI18n";
|
||||||
import { useScreenlessMode } from "@/lib/ui/screenlessMode";
|
import { useScreenlessMode } from "@/lib/ui/screenlessMode";
|
||||||
@@ -24,10 +35,10 @@ const items: NavItem[] = [
|
|||||||
{ href: "/reports", labelKey: "nav.reports", icon: BarChart3 },
|
{ href: "/reports", labelKey: "nav.reports", icon: BarChart3 },
|
||||||
{ href: "/alerts", labelKey: "nav.alerts", icon: Bell },
|
{ href: "/alerts", labelKey: "nav.alerts", icon: Bell },
|
||||||
{ href: "/financial", labelKey: "nav.financial", icon: DollarSign, ownerOnly: true },
|
{ href: "/financial", labelKey: "nav.financial", icon: DollarSign, ownerOnly: true },
|
||||||
{ href: "/settings", labelKey: "nav.settings", icon: Settings },
|
|
||||||
{ href: "/downtime", labelKey: "nav.downtime", icon: BarChart3 },
|
{ href: "/downtime", labelKey: "nav.downtime", icon: BarChart3 },
|
||||||
|
{ href: "/recap", labelKey: "nav.recap", icon: Sunrise },
|
||||||
];
|
];
|
||||||
|
const settingsItem: NavItem = { href: "/settings", labelKey: "nav.settings", icon: Settings };
|
||||||
|
|
||||||
type SidebarProps = {
|
type SidebarProps = {
|
||||||
variant?: "desktop" | "drawer";
|
variant?: "desktop" | "drawer";
|
||||||
@@ -97,16 +108,7 @@ export function Sidebar({ variant = "desktop", onNavigate, onClose }: SidebarPro
|
|||||||
}
|
}
|
||||||
}, [screenlessMode, pathname, router]);
|
}, [screenlessMode, pathname, router]);
|
||||||
|
|
||||||
useEffect(() => {
|
const markNavStart = (href: string, ts: number) => {
|
||||||
if (!pendingHref) return;
|
|
||||||
if (pathname === pendingHref || pathname.startsWith(`${pendingHref}/`)) {
|
|
||||||
setPendingHref(null);
|
|
||||||
} else if (!isPending) {
|
|
||||||
setPendingHref(null);
|
|
||||||
}
|
|
||||||
}, [pathname, pendingHref, isPending]);
|
|
||||||
|
|
||||||
const markNavStart = (href: string) => {
|
|
||||||
if (!PERF_ENABLED) return;
|
if (!PERF_ENABLED) return;
|
||||||
try {
|
try {
|
||||||
sessionStorage.setItem(
|
sessionStorage.setItem(
|
||||||
@@ -114,7 +116,7 @@ export function Sidebar({ variant = "desktop", onNavigate, onClose }: SidebarPro
|
|||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
href,
|
href,
|
||||||
from: pathname,
|
from: pathname,
|
||||||
ts: Date.now(),
|
ts,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -128,6 +130,58 @@ export function Sidebar({ variant = "desktop", onNavigate, onClose }: SidebarPro
|
|||||||
"relative z-20 flex flex-col border-r border-white/10 bg-black/40 shrink-0",
|
"relative z-20 flex flex-col border-r border-white/10 bg-black/40 shrink-0",
|
||||||
variant === "desktop" ? "hidden md:flex h-screen w-64" : "flex h-full w-72 max-w-[85vw]",
|
variant === "desktop" ? "hidden md:flex h-screen w-64" : "flex h-full w-72 max-w-[85vw]",
|
||||||
].join(" ");
|
].join(" ");
|
||||||
|
const navLocked = isPending;
|
||||||
|
|
||||||
|
const renderNavItem = (it: NavItem) => {
|
||||||
|
const isCurrent = pathname === it.href;
|
||||||
|
const active = isCurrent || pathname.startsWith(it.href + "/");
|
||||||
|
const isPendingItem = isPending && pendingHref === it.href;
|
||||||
|
const Icon = it.icon;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={it.href}
|
||||||
|
href={it.href}
|
||||||
|
prefetch={false}
|
||||||
|
aria-disabled={navLocked}
|
||||||
|
onClick={(event) => {
|
||||||
|
if (
|
||||||
|
navLocked ||
|
||||||
|
event.defaultPrevented ||
|
||||||
|
event.button !== 0 ||
|
||||||
|
event.metaKey ||
|
||||||
|
event.altKey ||
|
||||||
|
event.ctrlKey ||
|
||||||
|
event.shiftKey
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isCurrent) {
|
||||||
|
onNavigate?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
markNavStart(it.href, Math.round(performance.timeOrigin + event.timeStamp));
|
||||||
|
setPendingHref(it.href);
|
||||||
|
startTransition(() => {
|
||||||
|
router.push(it.href);
|
||||||
|
});
|
||||||
|
onNavigate?.();
|
||||||
|
}}
|
||||||
|
className={[
|
||||||
|
"flex items-center gap-3 rounded-xl px-3 py-2 text-sm transition",
|
||||||
|
active
|
||||||
|
? "bg-emerald-500/15 text-emerald-300 border border-emerald-500/20"
|
||||||
|
: "text-zinc-300 hover:bg-white/5 hover:text-white",
|
||||||
|
navLocked ? "pointer-events-none" : "",
|
||||||
|
navLocked && !isPendingItem ? "opacity-60" : "",
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
<span>{t(it.labelKey)}</span>
|
||||||
|
{isPendingItem ? <Loader2 className="ml-auto h-4 w-4 animate-spin text-emerald-300" /> : null}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className={shellClass} aria-label={t("sidebar.productTitle")}>
|
<aside className={shellClass} aria-label={t("sidebar.productTitle")}>
|
||||||
@@ -148,58 +202,9 @@ export function Sidebar({ variant = "desktop", onNavigate, onClose }: SidebarPro
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav className="px-3 py-2 flex-1 space-y-1">
|
<nav className="px-3 py-2 flex-1 flex flex-col gap-2">
|
||||||
{visibleItems.map((it) => {
|
<div className="space-y-1">{visibleItems.map(renderNavItem)}</div>
|
||||||
const isCurrent = pathname === it.href;
|
<div className="mt-auto space-y-1 border-t border-white/10 pt-2">{renderNavItem(settingsItem)}</div>
|
||||||
const active = isCurrent || pathname.startsWith(it.href + "/");
|
|
||||||
const isPendingItem = isPending && pendingHref === it.href;
|
|
||||||
const navLocked = isPending;
|
|
||||||
const Icon = it.icon;
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
key={it.href}
|
|
||||||
href={it.href}
|
|
||||||
prefetch={false}
|
|
||||||
aria-disabled={navLocked}
|
|
||||||
onClick={(event) => {
|
|
||||||
if (
|
|
||||||
navLocked ||
|
|
||||||
event.defaultPrevented ||
|
|
||||||
event.button !== 0 ||
|
|
||||||
event.metaKey ||
|
|
||||||
event.altKey ||
|
|
||||||
event.ctrlKey ||
|
|
||||||
event.shiftKey
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (isCurrent) {
|
|
||||||
onNavigate?.();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
event.preventDefault();
|
|
||||||
markNavStart(it.href);
|
|
||||||
setPendingHref(it.href);
|
|
||||||
startTransition(() => {
|
|
||||||
router.push(it.href);
|
|
||||||
});
|
|
||||||
onNavigate?.();
|
|
||||||
}}
|
|
||||||
className={[
|
|
||||||
"flex items-center gap-3 rounded-xl px-3 py-2 text-sm transition",
|
|
||||||
active
|
|
||||||
? "bg-emerald-500/15 text-emerald-300 border border-emerald-500/20"
|
|
||||||
: "text-zinc-300 hover:bg-white/5 hover:text-white",
|
|
||||||
navLocked ? "pointer-events-none" : "",
|
|
||||||
navLocked && !isPendingItem ? "opacity-60" : "",
|
|
||||||
].join(" ")}
|
|
||||||
>
|
|
||||||
<Icon className="h-4 w-4" />
|
|
||||||
<span>{t(it.labelKey)}</span>
|
|
||||||
{isPendingItem ? <Loader2 className="ml-auto h-4 w-4 animate-spin text-emerald-300" /> : null}
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="px-5 py-4 border-t border-white/10 space-y-3">
|
<div className="px-5 py-4 border-t border-white/10 space-y-3">
|
||||||
|
|||||||
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.close": "Close",
|
||||||
"common.save": "Save",
|
"common.save": "Save",
|
||||||
"common.copy": "Copy",
|
"common.copy": "Copy",
|
||||||
|
"common.yes": "Yes",
|
||||||
|
"common.no": "No",
|
||||||
"nav.overview": "Overview",
|
"nav.overview": "Overview",
|
||||||
"nav.machines": "Machines",
|
"nav.machines": "Machines",
|
||||||
"nav.reports": "Reports",
|
"nav.reports": "Reports",
|
||||||
@@ -104,6 +106,43 @@
|
|||||||
"overview.event.slow-cycle": "slow-cycle",
|
"overview.event.slow-cycle": "slow-cycle",
|
||||||
"overview.status.offline": "OFFLINE",
|
"overview.status.offline": "OFFLINE",
|
||||||
"overview.status.online": "ONLINE",
|
"overview.status.online": "ONLINE",
|
||||||
|
"overview.recap.title": "Daily recap",
|
||||||
|
"overview.recap.subtitle": "Production, downtime, and work orders in one glance.",
|
||||||
|
"overview.recap.cta": "Open daily recap",
|
||||||
|
"recap.title": "Recap",
|
||||||
|
"recap.subtitle": "Last 24h",
|
||||||
|
"recap.allMachines": "All machines",
|
||||||
|
"recap.range.shift": "Shift",
|
||||||
|
"recap.range.custom": "Custom range",
|
||||||
|
"recap.shift.1": "Shift 1",
|
||||||
|
"recap.shift.2": "Shift 2",
|
||||||
|
"recap.shift.3": "Shift 3",
|
||||||
|
"recap.kpi.oee": "Avg OEE",
|
||||||
|
"recap.kpi.good": "Good parts",
|
||||||
|
"recap.kpi.stops": "Total stops",
|
||||||
|
"recap.kpi.scrap": "Scrap",
|
||||||
|
"recap.production.title": "Production by SKU",
|
||||||
|
"recap.production.good": "Good",
|
||||||
|
"recap.production.scrap": "Scrap",
|
||||||
|
"recap.production.target": "Target",
|
||||||
|
"recap.production.progress": "Progress",
|
||||||
|
"recap.downtime.title": "Top downtime",
|
||||||
|
"recap.workOrders.title": "Work orders",
|
||||||
|
"recap.workOrders.active": "Active",
|
||||||
|
"recap.workOrders.completed": "Completed",
|
||||||
|
"recap.workOrders.none": "No production recorded",
|
||||||
|
"recap.workOrders.startedAt": "Started",
|
||||||
|
"recap.workOrders.goodParts": "Good parts",
|
||||||
|
"recap.workOrders.duration": "Duration",
|
||||||
|
"recap.machine.title": "Machine status",
|
||||||
|
"recap.machine.running": "Running",
|
||||||
|
"recap.machine.stopped": "Stopped",
|
||||||
|
"recap.machine.mold": "Mold change",
|
||||||
|
"recap.machine.lastHeartbeat": "Last heartbeat",
|
||||||
|
"recap.machine.uptime": "Uptime",
|
||||||
|
"recap.banner.mold": "Mold change in progress since",
|
||||||
|
"recap.banner.stopped": "Machine stopped for {minutes} min",
|
||||||
|
"recap.empty.production": "No production recorded",
|
||||||
"machines.title": "Machines",
|
"machines.title": "Machines",
|
||||||
"machines.subtitle": "Select a machine to view live KPIs.",
|
"machines.subtitle": "Select a machine to view live KPIs.",
|
||||||
"machines.cancel": "Cancel",
|
"machines.cancel": "Cancel",
|
||||||
@@ -526,6 +565,7 @@
|
|||||||
"financial.field.scrapCostPerUnit": "Scrap cost / unit",
|
"financial.field.scrapCostPerUnit": "Scrap cost / unit",
|
||||||
"financial.field.rawMaterialCostPerUnit": "Raw material / unit",
|
"financial.field.rawMaterialCostPerUnit": "Raw material / unit",
|
||||||
"nav.downtime": "Downtime",
|
"nav.downtime": "Downtime",
|
||||||
|
"nav.recap": "Daily recap",
|
||||||
"settings.tabs.modules": "Modules",
|
"settings.tabs.modules": "Modules",
|
||||||
"settings.modules.title": "Modules",
|
"settings.modules.title": "Modules",
|
||||||
"settings.modules.subtitle": "Enable/disable UI modules depending on how the plant operates.",
|
"settings.modules.subtitle": "Enable/disable UI modules depending on how the plant operates.",
|
||||||
|
|||||||
@@ -9,6 +9,8 @@
|
|||||||
"common.close": "Cerrar",
|
"common.close": "Cerrar",
|
||||||
"common.save": "Guardar",
|
"common.save": "Guardar",
|
||||||
"common.copy": "Copiar",
|
"common.copy": "Copiar",
|
||||||
|
"common.yes": "Sí",
|
||||||
|
"common.no": "No",
|
||||||
"nav.overview": "Resumen",
|
"nav.overview": "Resumen",
|
||||||
"nav.machines": "Máquinas",
|
"nav.machines": "Máquinas",
|
||||||
"nav.reports": "Reportes",
|
"nav.reports": "Reportes",
|
||||||
@@ -104,6 +106,43 @@
|
|||||||
"overview.event.slow-cycle": "ciclo lento",
|
"overview.event.slow-cycle": "ciclo lento",
|
||||||
"overview.status.offline": "FUERA DE LÍNEA",
|
"overview.status.offline": "FUERA DE LÍNEA",
|
||||||
"overview.status.online": "EN LÍNEA",
|
"overview.status.online": "EN LÍNEA",
|
||||||
|
"overview.recap.title": "Resumen diario de turno",
|
||||||
|
"overview.recap.subtitle": "Consulta producción, paros y órdenes en una sola vista.",
|
||||||
|
"overview.recap.cta": "Abrir resumen diario",
|
||||||
|
"recap.title": "Resumen",
|
||||||
|
"recap.subtitle": "Últimas 24h",
|
||||||
|
"recap.allMachines": "Todas las máquinas",
|
||||||
|
"recap.range.shift": "Turno",
|
||||||
|
"recap.range.custom": "Rango personalizado",
|
||||||
|
"recap.shift.1": "Turno 1",
|
||||||
|
"recap.shift.2": "Turno 2",
|
||||||
|
"recap.shift.3": "Turno 3",
|
||||||
|
"recap.kpi.oee": "OEE prom",
|
||||||
|
"recap.kpi.good": "Piezas buenas",
|
||||||
|
"recap.kpi.stops": "Paros totales",
|
||||||
|
"recap.kpi.scrap": "Scrap",
|
||||||
|
"recap.production.title": "Producción por SKU",
|
||||||
|
"recap.production.good": "Buenas",
|
||||||
|
"recap.production.scrap": "Scrap",
|
||||||
|
"recap.production.target": "Meta",
|
||||||
|
"recap.production.progress": "Avance",
|
||||||
|
"recap.downtime.title": "Top downtime",
|
||||||
|
"recap.workOrders.title": "Órdenes de trabajo",
|
||||||
|
"recap.workOrders.active": "Activa",
|
||||||
|
"recap.workOrders.completed": "Completadas",
|
||||||
|
"recap.workOrders.none": "Sin producción registrada",
|
||||||
|
"recap.workOrders.startedAt": "Inicio",
|
||||||
|
"recap.workOrders.goodParts": "Buenas",
|
||||||
|
"recap.workOrders.duration": "Duración",
|
||||||
|
"recap.machine.title": "Estado de máquina",
|
||||||
|
"recap.machine.running": "En marcha",
|
||||||
|
"recap.machine.stopped": "Detenida",
|
||||||
|
"recap.machine.mold": "Cambio de molde",
|
||||||
|
"recap.machine.lastHeartbeat": "Último heartbeat",
|
||||||
|
"recap.machine.uptime": "Uptime",
|
||||||
|
"recap.banner.mold": "Cambio de molde en curso desde",
|
||||||
|
"recap.banner.stopped": "Máquina detenida hace {minutes} min",
|
||||||
|
"recap.empty.production": "Sin producción registrada",
|
||||||
"machines.title": "Máquinas",
|
"machines.title": "Máquinas",
|
||||||
"machines.subtitle": "Selecciona una máquina para ver KPIs en vivo.",
|
"machines.subtitle": "Selecciona una máquina para ver KPIs en vivo.",
|
||||||
"machines.cancel": "Cancelar",
|
"machines.cancel": "Cancelar",
|
||||||
@@ -526,6 +565,7 @@
|
|||||||
"financial.field.scrapCostPerUnit": "Costo scrap / unidad",
|
"financial.field.scrapCostPerUnit": "Costo scrap / unidad",
|
||||||
"financial.field.rawMaterialCostPerUnit": "Costo materia prima / unidad",
|
"financial.field.rawMaterialCostPerUnit": "Costo materia prima / unidad",
|
||||||
"nav.downtime": "Downtime",
|
"nav.downtime": "Downtime",
|
||||||
|
"nav.recap": "Resumen diario",
|
||||||
"settings.tabs.modules": "Módulos",
|
"settings.tabs.modules": "Módulos",
|
||||||
"settings.modules.title": "Módulos",
|
"settings.modules.title": "Módulos",
|
||||||
"settings.modules.subtitle": "Activa/desactiva módulos según cómo opera la planta.",
|
"settings.modules.subtitle": "Activa/desactiva módulos según cómo opera la planta.",
|
||||||
|
|||||||
661
lib/recap/getRecapData.ts
Normal file
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