recent changes
This commit is contained in:
@@ -31,7 +31,7 @@ function secondsAgo(ts: string | undefined, locale: string, fallback: string) {
|
||||
|
||||
function isOffline(ts?: string) {
|
||||
if (!ts) return true;
|
||||
return Date.now() - new Date(ts).getTime() > 30000; // 30s threshold
|
||||
return Date.now() - new Date(ts).getTime() > 10 * 60 * 1000; // 10 min (sincronizado con RECAP_HEARTBEAT_STALE_MS)
|
||||
}
|
||||
|
||||
function normalizeStatus(status?: string) {
|
||||
|
||||
347
app/(app)/machines/MachinesClient.tsx.bak
Normal file
347
app/(app)/machines/MachinesClient.tsx.bak
Normal file
@@ -0,0 +1,347 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState, type KeyboardEvent } from "react";
|
||||
import { useI18n } from "@/lib/i18n/useI18n";
|
||||
|
||||
type MachineRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
code?: string | null;
|
||||
location?: string | null;
|
||||
latestHeartbeat: null | {
|
||||
ts: string;
|
||||
tsServer?: string | null;
|
||||
status: string;
|
||||
message?: string | null;
|
||||
ip?: string | null;
|
||||
fwVersion?: string | null;
|
||||
};
|
||||
};
|
||||
const LIVE_REFRESH_MS = 5000;
|
||||
|
||||
function secondsAgo(ts: string | undefined, locale: string, fallback: string) {
|
||||
if (!ts) return fallback;
|
||||
const diff = Math.floor((Date.now() - new Date(ts).getTime()) / 1000);
|
||||
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: "auto" });
|
||||
if (diff < 60) return rtf.format(-diff, "second");
|
||||
return rtf.format(-Math.floor(diff / 60), "minute");
|
||||
}
|
||||
|
||||
function isOffline(ts?: string) {
|
||||
if (!ts) return true;
|
||||
return Date.now() - new Date(ts).getTime() > 30000; // 30s threshold
|
||||
}
|
||||
|
||||
function normalizeStatus(status?: string) {
|
||||
const s = (status ?? "").toUpperCase();
|
||||
if (s === "ONLINE") return "RUN";
|
||||
return s;
|
||||
}
|
||||
|
||||
function badgeClass(status?: string, offline?: boolean) {
|
||||
if (offline) return "bg-white/10 text-zinc-300";
|
||||
const s = (status ?? "").toUpperCase();
|
||||
if (s === "RUN") return "bg-emerald-500/15 text-emerald-300";
|
||||
if (s === "IDLE") return "bg-yellow-500/15 text-yellow-300";
|
||||
if (s === "STOP" || s === "DOWN") return "bg-red-500/15 text-red-300";
|
||||
return "bg-white/10 text-white";
|
||||
}
|
||||
|
||||
export default function MachinesClient({ initialMachines = [] }: { initialMachines?: MachineRow[] }) {
|
||||
const { t, locale } = useI18n();
|
||||
const router = useRouter();
|
||||
const [machines, setMachines] = useState<MachineRow[]>(() => initialMachines);
|
||||
const [loading, setLoading] = useState(() => initialMachines.length === 0);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [createName, setCreateName] = useState("");
|
||||
const [createCode, setCreateCode] = useState("");
|
||||
const [createLocation, setCreateLocation] = useState("");
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [createError, setCreateError] = useState<string | null>(null);
|
||||
const [createdMachine, setCreatedMachine] = useState<{
|
||||
id: string;
|
||||
name: string;
|
||||
pairingCode: string;
|
||||
pairingExpiresAt: string;
|
||||
} | null>(null);
|
||||
const [copyStatus, setCopyStatus] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
async function load(initial: boolean) {
|
||||
try {
|
||||
if (!initial && typeof document !== "undefined" && document.hidden) {
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await fetch("/api/machines", { cache: "no-store" });
|
||||
const json = await res.json();
|
||||
if (alive) {
|
||||
setMachines(json.machines ?? []);
|
||||
if (initial) setLoading(false);
|
||||
}
|
||||
} catch {
|
||||
if (alive && initial) setLoading(false);
|
||||
} finally {
|
||||
if (!alive) return;
|
||||
timer = setTimeout(() => {
|
||||
void load(false);
|
||||
}, LIVE_REFRESH_MS);
|
||||
}
|
||||
}
|
||||
|
||||
void load(initialMachines.length === 0);
|
||||
return () => {
|
||||
alive = false;
|
||||
if (timer) clearTimeout(timer);
|
||||
};
|
||||
}, [initialMachines.length]);
|
||||
|
||||
async function createMachine() {
|
||||
if (!createName.trim()) {
|
||||
setCreateError(t("machines.create.error.nameRequired"));
|
||||
return;
|
||||
}
|
||||
|
||||
setCreating(true);
|
||||
setCreateError(null);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/machines", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: createName,
|
||||
code: createCode,
|
||||
location: createLocation,
|
||||
}),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok || !data.ok) {
|
||||
throw new Error(data.error || t("machines.create.error.failed"));
|
||||
}
|
||||
|
||||
const nextMachine = {
|
||||
...data.machine,
|
||||
latestHeartbeat: null,
|
||||
};
|
||||
setMachines((prev) => [nextMachine, ...prev]);
|
||||
setCreatedMachine({
|
||||
id: data.machine.id,
|
||||
name: data.machine.name,
|
||||
pairingCode: data.machine.pairingCode,
|
||||
pairingExpiresAt: data.machine.pairingCodeExpiresAt,
|
||||
});
|
||||
setCreateName("");
|
||||
setCreateCode("");
|
||||
setCreateLocation("");
|
||||
setShowCreate(false);
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : null;
|
||||
setCreateError(message || t("machines.create.error.failed"));
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function copyText(text: string) {
|
||||
try {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopyStatus(t("machines.pairing.copied"));
|
||||
} else {
|
||||
setCopyStatus(t("machines.pairing.copyUnsupported"));
|
||||
}
|
||||
} catch {
|
||||
setCopyStatus(t("machines.pairing.copyFailed"));
|
||||
}
|
||||
setTimeout(() => setCopyStatus(null), 2000);
|
||||
}
|
||||
|
||||
function handleCardKeyDown(event: KeyboardEvent<HTMLDivElement>, machineId: string) {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
router.push(`/machines/${machineId}`);
|
||||
}
|
||||
}
|
||||
|
||||
const showCreateCard = showCreate || (!loading && machines.length === 0);
|
||||
|
||||
return (
|
||||
<div className="p-4 sm:p-6">
|
||||
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-white">{t("machines.title")}</h1>
|
||||
<p className="text-sm text-zinc-400">{t("machines.subtitle")}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full flex-wrap items-center gap-2 sm:w-auto">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCreate((prev) => !prev)}
|
||||
className="w-full rounded-xl border border-emerald-400/40 bg-emerald-500/20 px-4 py-2 text-sm text-emerald-100 hover:bg-emerald-500/30 sm:w-auto"
|
||||
>
|
||||
{showCreate ? t("machines.cancel") : t("machines.addMachine")}
|
||||
</button>
|
||||
<Link
|
||||
href="/overview"
|
||||
className="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-center text-sm text-white hover:bg-white/10 sm:w-auto"
|
||||
>
|
||||
{t("machines.backOverview")}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showCreateCard && (
|
||||
<div className="mb-6 rounded-2xl border border-white/10 bg-white/5 p-5">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-white">{t("machines.addCardTitle")}</div>
|
||||
<div className="text-xs text-zinc-400">{t("machines.addCardSubtitle")}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid grid-cols-1 gap-3 md:grid-cols-3">
|
||||
<label className="rounded-xl border border-white/10 bg-black/20 p-3 text-xs text-zinc-400">
|
||||
{t("machines.field.name")}
|
||||
<input
|
||||
value={createName}
|
||||
onChange={(event) => setCreateName(event.target.value)}
|
||||
className="mt-2 w-full rounded-lg border border-white/10 bg-black/30 px-3 py-2 text-sm text-white"
|
||||
/>
|
||||
</label>
|
||||
<label className="rounded-xl border border-white/10 bg-black/20 p-3 text-xs text-zinc-400">
|
||||
{t("machines.field.code")}
|
||||
<input
|
||||
value={createCode}
|
||||
onChange={(event) => setCreateCode(event.target.value)}
|
||||
className="mt-2 w-full rounded-lg border border-white/10 bg-black/30 px-3 py-2 text-sm text-white"
|
||||
/>
|
||||
</label>
|
||||
<label className="rounded-xl border border-white/10 bg-black/20 p-3 text-xs text-zinc-400">
|
||||
{t("machines.field.location")}
|
||||
<input
|
||||
value={createLocation}
|
||||
onChange={(event) => setCreateLocation(event.target.value)}
|
||||
className="mt-2 w-full rounded-lg border border-white/10 bg-black/30 px-3 py-2 text-sm text-white"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={createMachine}
|
||||
disabled={creating}
|
||||
className="rounded-xl border border-emerald-400/40 bg-emerald-500/20 px-4 py-2 text-sm text-emerald-100 hover:bg-emerald-500/30 disabled:opacity-60"
|
||||
>
|
||||
{creating ? t("machines.create.loading") : t("machines.create.default")}
|
||||
</button>
|
||||
{createError && <div className="text-xs text-red-200">{createError}</div>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{createdMachine && (
|
||||
<div className="mb-6 rounded-2xl border border-emerald-500/20 bg-emerald-500/10 p-5">
|
||||
<div className="text-sm font-semibold text-white">{t("machines.pairing.title")}</div>
|
||||
<div className="mt-2 text-xs text-zinc-300">
|
||||
{t("machines.pairing.machine")} <span className="text-white">{createdMachine.name}</span>
|
||||
</div>
|
||||
<div className="mt-3 rounded-xl border border-white/10 bg-black/30 p-4">
|
||||
<div className="text-xs uppercase tracking-wide text-zinc-400">{t("machines.pairing.codeLabel")}</div>
|
||||
<div className="mt-2 text-3xl font-semibold text-white">{createdMachine.pairingCode}</div>
|
||||
<div className="mt-2 text-xs text-zinc-400">
|
||||
{t("machines.pairing.expires")}{" "}
|
||||
{createdMachine.pairingExpiresAt
|
||||
? new Date(createdMachine.pairingExpiresAt).toLocaleString(locale)
|
||||
: t("machines.pairing.soon")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 text-xs text-zinc-300">
|
||||
{t("machines.pairing.instructions")}
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyText(createdMachine.pairingCode)}
|
||||
className="rounded-xl border border-white/10 bg-white/5 px-3 py-2 text-sm text-white hover:bg-white/10"
|
||||
>
|
||||
{t("machines.pairing.copy")}
|
||||
</button>
|
||||
{copyStatus && <div className="text-xs text-zinc-300">{copyStatus}</div>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && <div className="mb-4 text-sm text-zinc-400">{t("machines.loading")}</div>}
|
||||
|
||||
{!loading && machines.length === 0 && (
|
||||
<div className="mb-4 text-sm text-zinc-400">{t("machines.empty")}</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{(!loading ? machines : []).map((m) => {
|
||||
const hb = m.latestHeartbeat;
|
||||
const hbTs = hb?.tsServer ?? hb?.ts;
|
||||
const offline = isOffline(hbTs);
|
||||
const normalizedStatus = normalizeStatus(hb?.status);
|
||||
const statusLabel = offline ? t("machines.status.offline") : (normalizedStatus || t("machines.status.unknown"));
|
||||
const lastSeen = secondsAgo(hbTs, locale, t("common.never"));
|
||||
|
||||
return (
|
||||
<div
|
||||
key={m.id}
|
||||
role="link"
|
||||
tabIndex={0}
|
||||
onClick={() => router.push(`/machines/${m.id}`)}
|
||||
onKeyDown={(event) => handleCardKeyDown(event, m.id)}
|
||||
className="cursor-pointer rounded-2xl border border-white/10 bg-white/5 p-5 hover:bg-white/10"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-lg font-semibold text-white">{m.name}</div>
|
||||
<div className="mt-1 text-xs text-zinc-400">
|
||||
{m.code ? m.code : t("common.na")} - {t("machines.lastSeen", { time: lastSeen })}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span
|
||||
className={`shrink-0 rounded-full px-3 py-1 text-xs ${badgeClass(
|
||||
normalizedStatus,
|
||||
offline
|
||||
)}`}
|
||||
>
|
||||
{statusLabel}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 text-sm text-zinc-400">{t("machines.status")}</div>
|
||||
<div className="mt-1 flex items-center gap-2 text-sm font-semibold text-white">
|
||||
{offline ? (
|
||||
<>
|
||||
<span className="inline-flex h-2.5 w-2.5 rounded-full bg-zinc-500" aria-hidden="true" />
|
||||
<span>{t("machines.status.noHeartbeat")}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="relative flex h-2.5 w-2.5" aria-hidden="true">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75" />
|
||||
<span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-emerald-400" />
|
||||
</span>
|
||||
<span>{t("machines.status.ok")}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
formatDuration,
|
||||
formatTime,
|
||||
normalizeTimelineSegments,
|
||||
SEGMENT_MIN_WIDTH_PCT,
|
||||
TIMELINE_COLORS,
|
||||
} from "@/components/recap/timelineRender";
|
||||
|
||||
@@ -106,6 +107,9 @@ type WorkOrderUpload = {
|
||||
sku?: string;
|
||||
targetQty?: number;
|
||||
cycleTime?: number;
|
||||
mold?: string;
|
||||
cavitiesTotal?: number;
|
||||
cavitiesActive?: number;
|
||||
};
|
||||
|
||||
type WorkOrderRow = Record<string, string | number | boolean>;
|
||||
@@ -192,8 +196,31 @@ const WORK_ORDER_KEYS = {
|
||||
"theoretical_cycle_time",
|
||||
]),
|
||||
target: new Set(["targetquantity", "targetqty", "target", "target_qty"]),
|
||||
mold: new Set(["mold", "molde", "moldid", "mold_id"]),
|
||||
cavitiesTotal: new Set([
|
||||
"totalcavities",
|
||||
"cavitiestotal",
|
||||
"cavities_total",
|
||||
"total_cavities",
|
||||
]),
|
||||
cavitiesActive: new Set([
|
||||
"activecavities",
|
||||
"cavitiesactive",
|
||||
"cavities_active",
|
||||
"active_cavities",
|
||||
]),
|
||||
};
|
||||
|
||||
const WORK_ORDER_TEMPLATE_HEADERS = [
|
||||
"Work Order ID",
|
||||
"SKU",
|
||||
"Theoretical Cycle Time (Seconds)",
|
||||
"Target Quantity",
|
||||
"Mold",
|
||||
"Total Cavities",
|
||||
"Active Cavities",
|
||||
] as const;
|
||||
|
||||
function normalizeKey(value: string) {
|
||||
return value.toLowerCase().replace(/[^a-z0-9]/g, "");
|
||||
}
|
||||
@@ -278,7 +305,26 @@ function rowsToWorkOrders(rows: WorkOrderRow[]): WorkOrderUpload[] {
|
||||
const targetQty = Number.isFinite(Number(targetRaw)) ? Math.trunc(Number(targetRaw)) : undefined;
|
||||
const cycleTime = Number.isFinite(Number(cycleRaw)) ? Number(cycleRaw) : undefined;
|
||||
|
||||
out.push({ workOrderId, sku: sku || undefined, targetQty, cycleTime });
|
||||
const moldRaw = pickRowValue(row, WORK_ORDER_KEYS.mold);
|
||||
const mold = String(moldRaw ?? "").trim();
|
||||
const totalCavRaw = pickRowValue(row, WORK_ORDER_KEYS.cavitiesTotal);
|
||||
const activeCavRaw = pickRowValue(row, WORK_ORDER_KEYS.cavitiesActive);
|
||||
const cavitiesTotal = Number.isFinite(Number(totalCavRaw))
|
||||
? Math.trunc(Number(totalCavRaw))
|
||||
: undefined;
|
||||
const cavitiesActive = Number.isFinite(Number(activeCavRaw))
|
||||
? Math.trunc(Number(activeCavRaw))
|
||||
: undefined;
|
||||
|
||||
out.push({
|
||||
workOrderId,
|
||||
sku: sku || undefined,
|
||||
targetQty,
|
||||
cycleTime,
|
||||
mold: mold || undefined,
|
||||
cavitiesTotal,
|
||||
cavitiesActive,
|
||||
});
|
||||
});
|
||||
|
||||
return out;
|
||||
@@ -308,6 +354,14 @@ type MachineActivityTimelineProps = {
|
||||
t: (key: string, vars?: Record<string, string | number>) => string;
|
||||
};
|
||||
|
||||
function getMinuteFlooredOneHourRange(referenceMs = Date.now()) {
|
||||
const endMs = Math.floor(referenceMs / 60000) * 60000;
|
||||
return {
|
||||
startMs: endMs - 60 * 60 * 1000,
|
||||
endMs,
|
||||
};
|
||||
}
|
||||
|
||||
function MachineActivityTimeline({ machineId, locale, t }: MachineActivityTimelineProps) {
|
||||
const [timeline, setTimeline] = useState<RecapTimelineResponse | null>(null);
|
||||
const [timelineLoading, setTimelineLoading] = useState(true);
|
||||
@@ -321,11 +375,18 @@ function MachineActivityTimeline({ machineId, locale, t }: MachineActivityTimeli
|
||||
|
||||
async function loadTimeline() {
|
||||
try {
|
||||
const res = await fetch(`/api/recap/${machineId}/timeline?range=1h`, { cache: "no-store" });
|
||||
const range = getMinuteFlooredOneHourRange();
|
||||
const params = new URLSearchParams({
|
||||
start: String(range.startMs),
|
||||
end: String(range.endMs),
|
||||
});
|
||||
const res = await fetch(`/api/recap/${machineId}/timeline?${params.toString()}`, { cache: "no-store" });
|
||||
const json = await res.json().catch(() => null);
|
||||
if (!alive || !res.ok || !json) return;
|
||||
const nextTimeline = json as RecapTimelineResponse;
|
||||
const nextHash = JSON.stringify({
|
||||
start: nextTimeline.range.start,
|
||||
end: nextTimeline.range.end,
|
||||
hasData: nextTimeline.hasData,
|
||||
segments: nextTimeline.segments.map((segment) => ({
|
||||
type: segment.type,
|
||||
@@ -353,14 +414,18 @@ function MachineActivityTimeline({ machineId, locale, t }: MachineActivityTimeli
|
||||
}, [machineId]);
|
||||
|
||||
const hasData = timeline?.hasData ?? false;
|
||||
const startMs = timeline ? new Date(timeline.range.start).getTime() : Date.now() - 60 * 60 * 1000;
|
||||
const endMs = timeline ? new Date(timeline.range.end).getTime() : Date.now();
|
||||
const fallbackRange = getMinuteFlooredOneHourRange();
|
||||
const startMs = timeline ? new Date(timeline.range.start).getTime() : fallbackRange.startMs;
|
||||
const endMs = timeline ? new Date(timeline.range.end).getTime() : fallbackRange.endMs;
|
||||
const totalMs = Math.max(1, endMs - startMs);
|
||||
const normalized = useMemo(() => {
|
||||
if (!timeline || !hasData) return [] as RecapTimelineSegment[];
|
||||
return normalizeTimelineSegments(timeline.segments, startMs, endMs);
|
||||
}, [timeline, hasData, startMs, endMs]);
|
||||
const widths = useMemo(() => computeWidths(normalized, totalMs, 1.5), [normalized, totalMs]);
|
||||
const widths = useMemo(
|
||||
() => computeWidths(normalized, totalMs, SEGMENT_MIN_WIDTH_PCT),
|
||||
[normalized, totalMs]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
|
||||
@@ -586,6 +651,23 @@ export default function MachineDetailClient() {
|
||||
return null;
|
||||
}
|
||||
|
||||
async function downloadWorkOrderTemplate() {
|
||||
const xlsx = await import("xlsx");
|
||||
const wb = xlsx.utils.book_new();
|
||||
const ws = xlsx.utils.aoa_to_sheet([Array.from(WORK_ORDER_TEMPLATE_HEADERS)]);
|
||||
xlsx.utils.book_append_sheet(wb, ws, "Work Orders");
|
||||
const wbout = xlsx.write(wb, { bookType: "xlsx", type: "array" });
|
||||
const blob = new Blob([wbout], {
|
||||
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "work-orders-template.xlsx";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
async function handleWorkOrderUpload(event: ChangeEvent<HTMLInputElement>) {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
@@ -1005,6 +1087,13 @@ export default function MachineDetailClient() {
|
||||
className="hidden"
|
||||
onChange={handleWorkOrderUpload}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void downloadWorkOrderTemplate()}
|
||||
className="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-white transition hover:bg-white/10 sm:w-auto"
|
||||
>
|
||||
{t("machine.detail.workOrders.downloadTemplate")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Suspense, lazy, useEffect, useMemo, useState } from "react";
|
||||
import { useI18n } from "@/lib/i18n/useI18n";
|
||||
import type { EventRow, Heartbeat, MachineRow } from "./types";
|
||||
|
||||
const OFFLINE_MS = 30000;
|
||||
const OFFLINE_MS = 10 * 60 * 1000; // 10 min (sincronizado con RECAP_HEARTBEAT_STALE_MS)
|
||||
const MAX_EVENT_MACHINES = 6;
|
||||
const OverviewTimeline = lazy(() => import("./OverviewTimeline"));
|
||||
|
||||
|
||||
388
app/(app)/overview/OverviewClient.tsx.bak
Normal file
388
app/(app)/overview/OverviewClient.tsx.bak
Normal file
@@ -0,0 +1,388 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { Suspense, lazy, useEffect, useMemo, useState } from "react";
|
||||
import { useI18n } from "@/lib/i18n/useI18n";
|
||||
import type { EventRow, Heartbeat, MachineRow } from "./types";
|
||||
|
||||
const OFFLINE_MS = 30000;
|
||||
const MAX_EVENT_MACHINES = 6;
|
||||
const OverviewTimeline = lazy(() => import("./OverviewTimeline"));
|
||||
|
||||
function secondsAgo(ts: string | undefined, locale: string, fallback: string) {
|
||||
if (!ts) return fallback;
|
||||
const diff = Math.floor((Date.now() - new Date(ts).getTime()) / 1000);
|
||||
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: "auto" });
|
||||
if (diff < 60) return rtf.format(-diff, "second");
|
||||
if (diff < 3600) return rtf.format(-Math.floor(diff / 60), "minute");
|
||||
return rtf.format(-Math.floor(diff / 3600), "hour");
|
||||
}
|
||||
|
||||
function isOffline(ts?: string) {
|
||||
if (!ts) return true;
|
||||
return Date.now() - new Date(ts).getTime() > OFFLINE_MS;
|
||||
}
|
||||
|
||||
function normalizeStatus(status?: string) {
|
||||
const s = (status ?? "").toUpperCase();
|
||||
if (s === "ONLINE") return "RUN";
|
||||
return s;
|
||||
}
|
||||
|
||||
function heartbeatTime(hb?: Heartbeat | null) {
|
||||
return hb?.tsServer ?? hb?.ts;
|
||||
}
|
||||
|
||||
function fmtPct(v?: number | null) {
|
||||
if (v === null || v === undefined || Number.isNaN(v)) return "--";
|
||||
return `${v.toFixed(1)}%`;
|
||||
}
|
||||
|
||||
function fmtNum(v?: number | null) {
|
||||
if (v === null || v === undefined || Number.isNaN(v)) return "--";
|
||||
return `${Math.round(v)}`;
|
||||
}
|
||||
|
||||
function OverviewTimelineSkeleton() {
|
||||
return (
|
||||
<div className="rounded-2xl border border-white/10 bg-white/5 p-5 xl:col-span-2">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="h-4 w-32 rounded bg-white/10" />
|
||||
<div className="h-3 w-20 rounded bg-white/5" />
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 4 }).map((_, idx) => (
|
||||
<div key={idx} className="h-20 rounded-xl border border-white/10 bg-black/20" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function OverviewClient({
|
||||
initialMachines = [],
|
||||
initialEvents = [],
|
||||
}: {
|
||||
initialMachines?: MachineRow[];
|
||||
initialEvents?: EventRow[];
|
||||
}) {
|
||||
const { t, locale } = useI18n();
|
||||
const [machines, setMachines] = useState<MachineRow[]>(() => initialMachines);
|
||||
const [events, setEvents] = useState<EventRow[]>(() => initialEvents);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [eventsLoading, setEventsLoading] = useState(() => initialEvents.length === 0);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
setEventsLoading(true);
|
||||
const res = await fetch(
|
||||
`/api/overview?detail=1&events=critical&eventMachines=${MAX_EVENT_MACHINES}`,
|
||||
{
|
||||
cache: "no-cache",
|
||||
}
|
||||
);
|
||||
if (res.status === 304) {
|
||||
if (alive) setLoading(false);
|
||||
return;
|
||||
}
|
||||
const json = await res.json().catch(() => ({}));
|
||||
if (!alive) return;
|
||||
setMachines(json.machines ?? []);
|
||||
setEvents(json.events ?? []);
|
||||
setLoading(false);
|
||||
} catch {
|
||||
if (!alive) return;
|
||||
setMachines([]);
|
||||
setEvents([]);
|
||||
setLoading(false);
|
||||
} finally {
|
||||
if (alive) setEventsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
load();
|
||||
const t = setInterval(load, 30000);
|
||||
return () => {
|
||||
alive = false;
|
||||
clearInterval(t);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const total = machines.length;
|
||||
let online = 0;
|
||||
let running = 0;
|
||||
let idle = 0;
|
||||
let stopped = 0;
|
||||
let oeeSum = 0;
|
||||
let oeeCount = 0;
|
||||
let availSum = 0;
|
||||
let availCount = 0;
|
||||
let perfSum = 0;
|
||||
let perfCount = 0;
|
||||
let qualSum = 0;
|
||||
let qualCount = 0;
|
||||
let goodSum = 0;
|
||||
let scrapSum = 0;
|
||||
let targetSum = 0;
|
||||
let hasKpi = false;
|
||||
|
||||
for (const m of machines) {
|
||||
const hb = m.latestHeartbeat;
|
||||
const offline = isOffline(heartbeatTime(hb));
|
||||
if (!offline) online += 1;
|
||||
|
||||
const status = normalizeStatus(hb?.status);
|
||||
if (!offline) {
|
||||
if (status === "RUN") running += 1;
|
||||
else if (status === "IDLE") idle += 1;
|
||||
else if (status === "STOP" || status === "DOWN") stopped += 1;
|
||||
}
|
||||
|
||||
const k = m.latestKpi;
|
||||
if (k?.oee != null) {
|
||||
oeeSum += Number(k.oee);
|
||||
oeeCount += 1;
|
||||
hasKpi = true;
|
||||
}
|
||||
if (k?.availability != null) {
|
||||
availSum += Number(k.availability);
|
||||
availCount += 1;
|
||||
hasKpi = true;
|
||||
}
|
||||
if (k?.performance != null) {
|
||||
perfSum += Number(k.performance);
|
||||
perfCount += 1;
|
||||
hasKpi = true;
|
||||
}
|
||||
if (k?.quality != null) {
|
||||
qualSum += Number(k.quality);
|
||||
qualCount += 1;
|
||||
hasKpi = true;
|
||||
}
|
||||
if (k?.good != null) {
|
||||
goodSum += Number(k.good);
|
||||
hasKpi = true;
|
||||
}
|
||||
if (k?.scrap != null) {
|
||||
scrapSum += Number(k.scrap);
|
||||
hasKpi = true;
|
||||
}
|
||||
if (k?.target != null) {
|
||||
targetSum += Number(k.target);
|
||||
hasKpi = true;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
total,
|
||||
online,
|
||||
offline: total - online,
|
||||
running,
|
||||
idle,
|
||||
stopped,
|
||||
oee: oeeCount ? oeeSum / oeeCount : null,
|
||||
availability: availCount ? availSum / availCount : null,
|
||||
performance: perfCount ? perfSum / perfCount : null,
|
||||
quality: qualCount ? qualSum / qualCount : null,
|
||||
goodSum: hasKpi ? goodSum : null,
|
||||
scrapSum: hasKpi ? scrapSum : null,
|
||||
targetSum: hasKpi ? targetSum : null,
|
||||
};
|
||||
}, [machines]);
|
||||
|
||||
const attention = useMemo(() => {
|
||||
const list = machines
|
||||
.map((m) => {
|
||||
const hb = m.latestHeartbeat;
|
||||
const offline = isOffline(heartbeatTime(hb));
|
||||
const k = m.latestKpi;
|
||||
const oee = k?.oee ?? null;
|
||||
let score = 0;
|
||||
if (offline) score += 100;
|
||||
if (oee != null && oee < 75) score += 50;
|
||||
if (oee != null && oee < 85) score += 25;
|
||||
return { machine: m, offline, oee, score };
|
||||
})
|
||||
.filter((x) => x.score > 0)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, 6);
|
||||
|
||||
return list;
|
||||
}, [machines]);
|
||||
|
||||
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("overview.title")}</h1>
|
||||
<p className="text-sm text-zinc-400">{t("overview.subtitle")}</p>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href="/machines"
|
||||
className="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-center text-sm text-white hover:bg-white/10 sm:w-auto"
|
||||
>
|
||||
{t("overview.viewMachines")}
|
||||
</Link>
|
||||
</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="rounded-2xl border border-white/10 bg-white/5 p-5">
|
||||
<div className="text-xs text-zinc-400">{t("overview.fleetHealth")}</div>
|
||||
<div className="mt-2 text-3xl font-semibold text-white">{stats.total}</div>
|
||||
<div className="mt-2 text-xs text-zinc-400">{t("overview.machinesTotal")}</div>
|
||||
<div className="mt-4 flex flex-wrap gap-2 text-xs">
|
||||
<span className="rounded-full bg-emerald-500/15 px-2 py-0.5 text-emerald-300">
|
||||
{t("overview.online")} {stats.online}
|
||||
</span>
|
||||
<span className="rounded-full bg-white/10 px-2 py-0.5 text-zinc-300">
|
||||
{t("overview.offline")} {stats.offline}
|
||||
</span>
|
||||
<span className="rounded-full bg-emerald-500/10 px-2 py-0.5 text-emerald-200">
|
||||
{t("overview.run")} {stats.running}
|
||||
</span>
|
||||
<span className="rounded-full bg-yellow-500/15 px-2 py-0.5 text-yellow-300">
|
||||
{t("overview.idle")} {stats.idle}
|
||||
</span>
|
||||
<span className="rounded-full bg-red-500/15 px-2 py-0.5 text-red-300">
|
||||
{t("overview.stop")} {stats.stopped}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
|
||||
<div className="text-xs text-zinc-400">{t("overview.productionTotals")}</div>
|
||||
<div className="mt-2 grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
<div className="rounded-xl border border-white/10 bg-black/20 p-3">
|
||||
<div className="text-[11px] text-zinc-400">{t("overview.good")}</div>
|
||||
<div className="mt-1 text-sm font-semibold text-white">{fmtNum(stats.goodSum)}</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-white/10 bg-black/20 p-3">
|
||||
<div className="text-[11px] text-zinc-400">{t("overview.scrap")}</div>
|
||||
<div className="mt-1 text-sm font-semibold text-white">{fmtNum(stats.scrapSum)}</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-white/10 bg-black/20 p-3">
|
||||
<div className="text-[11px] text-zinc-400">{t("overview.target")}</div>
|
||||
<div className="mt-1 text-sm font-semibold text-white">{fmtNum(stats.targetSum)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 text-xs text-zinc-400">{t("overview.kpiSumNote")}</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
|
||||
<div className="text-xs text-zinc-400">{t("overview.activityFeed")}</div>
|
||||
<div className="mt-2 text-3xl font-semibold text-white">{events.length}</div>
|
||||
<div className="mt-2 text-xs text-zinc-400">
|
||||
{eventsLoading ? t("overview.eventsRefreshing") : t("overview.eventsLast30")}
|
||||
</div>
|
||||
<div className="mt-4 space-y-2">
|
||||
{events.slice(0, 3).map((e) => (
|
||||
<div key={e.id} className="flex items-center justify-between text-xs text-zinc-300">
|
||||
<div className="truncate">
|
||||
{e.machineName ? `${e.machineName}: ` : ""}
|
||||
{e.title}
|
||||
</div>
|
||||
<div className="shrink-0 text-zinc-500">
|
||||
{secondsAgo(e.ts, locale, t("common.never"))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{events.length === 0 && !eventsLoading ? (
|
||||
<div className="text-xs text-zinc-500">{t("overview.eventsNone")}</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
|
||||
<div className="text-xs text-zinc-400">{t("overview.oeeAvg")}</div>
|
||||
<div className="mt-2 text-3xl font-semibold text-emerald-300">{fmtPct(stats.oee)}</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
|
||||
<div className="text-xs text-zinc-400">{t("overview.availabilityAvg")}</div>
|
||||
<div className="mt-2 text-2xl font-semibold text-white">{fmtPct(stats.availability)}</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
|
||||
<div className="text-xs text-zinc-400">{t("overview.performanceAvg")}</div>
|
||||
<div className="mt-2 text-2xl font-semibold text-white">{fmtPct(stats.performance)}</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
|
||||
<div className="text-xs text-zinc-400">{t("overview.qualityAvg")}</div>
|
||||
<div className="mt-2 text-2xl font-semibold text-white">{fmtPct(stats.quality)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid grid-cols-1 gap-4 xl:grid-cols-3">
|
||||
<div className="rounded-2xl border border-white/10 bg-white/5 p-5 xl:col-span-1">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="text-sm font-semibold text-white">{t("overview.attentionList")}</div>
|
||||
<div className="text-xs text-zinc-400">
|
||||
{attention.length} {t("overview.shown")}
|
||||
</div>
|
||||
</div>
|
||||
{attention.length === 0 ? (
|
||||
<div className="text-sm text-zinc-400">{t("overview.noUrgent")}</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{attention.map(({ machine, offline, oee }) => (
|
||||
<div key={machine.id} className="rounded-xl border border-white/10 bg-black/20 p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-semibold text-white">{machine.name}</div>
|
||||
<div className="mt-1 text-xs text-zinc-400">
|
||||
{machine.code ?? ""} {machine.location ? `- ${machine.location}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-zinc-400">
|
||||
{secondsAgo(heartbeatTime(machine.latestHeartbeat), locale, t("common.never"))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-2 text-xs">
|
||||
<span
|
||||
className={`rounded-full px-2 py-0.5 ${
|
||||
offline ? "bg-white/10 text-zinc-300" : "bg-emerald-500/15 text-emerald-300"
|
||||
}`}
|
||||
>
|
||||
{offline ? t("overview.status.offline") : t("overview.status.online")}
|
||||
</span>
|
||||
{oee != null && (
|
||||
<span className="rounded-full bg-yellow-500/15 px-2 py-0.5 text-yellow-300">
|
||||
OEE {fmtPct(oee)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Suspense fallback={<OverviewTimelineSkeleton />}>
|
||||
<OverviewTimeline events={events} eventsLoading={eventsLoading} locale={locale} t={t} />
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -37,6 +37,7 @@ export default function RecapDetailClient({ machineId, initialData }: Props) {
|
||||
const searchParams = useSearchParams();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [timeline, setTimeline] = useState<RecapTimelineResponse | null>(null);
|
||||
const [timelineLoading, setTimelineLoading] = useState(true);
|
||||
const [nowMs, setNowMs] = useState(() => Date.now());
|
||||
|
||||
const [customStart, setCustomStart] = useState(toInputDate(initialData.range.start));
|
||||
@@ -78,11 +79,13 @@ export default function RecapDetailClient({ machineId, initialData }: Props) {
|
||||
const freshAgeSec = Number.isFinite(generatedAtMs) ? Math.max(0, Math.floor((nowMs - generatedAtMs) / 1000)) : null;
|
||||
const timelineStart = timeline?.range.start ?? initialData.range.start;
|
||||
const timelineEnd = timeline?.range.end ?? initialData.range.end;
|
||||
const timelineSegments = timeline?.segments ?? machine.timeline;
|
||||
const timelineHasData = timeline?.hasData ?? true;
|
||||
const timelineSegments = timeline?.segments ?? [];
|
||||
const timelineHasData = timeline?.hasData ?? false;
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
setTimeline(null);
|
||||
setTimelineLoading(true);
|
||||
|
||||
async function loadTimeline() {
|
||||
try {
|
||||
@@ -95,6 +98,8 @@ export default function RecapDetailClient({ machineId, initialData }: Props) {
|
||||
if (!alive || !res.ok || !json) return;
|
||||
setTimeline(json as RecapTimelineResponse);
|
||||
} catch {
|
||||
} finally {
|
||||
if (alive) setTimelineLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,6 +217,7 @@ export default function RecapDetailClient({ machineId, initialData }: Props) {
|
||||
rangeEnd={timelineEnd}
|
||||
segments={timelineSegments}
|
||||
hasData={timelineHasData}
|
||||
loading={timelineLoading}
|
||||
locale={locale}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -63,7 +63,12 @@ const MAX_EVENTS = 100;
|
||||
|
||||
//when no cycle time is configed
|
||||
const DEFAULT_MACROSTOP_SEC = 300;
|
||||
const NON_AUTHORITATIVE_REASON_CODES = new Set(["PENDIENTE", "UNCLASSIFIED"]);
|
||||
|
||||
function isNonAuthoritativeReasonCode(code: unknown) {
|
||||
const normalized = clampText(code, 64)?.toUpperCase();
|
||||
return !!normalized && NON_AUTHORITATIVE_REASON_CODES.has(normalized);
|
||||
}
|
||||
|
||||
function clampText(value: unknown, maxLen: number) {
|
||||
if (value === null || value === undefined) return null;
|
||||
@@ -271,8 +276,10 @@ export async function POST(req: Request) {
|
||||
|
||||
const machine = await getMachineAuth(String(machineId), apiKey);
|
||||
if (!machine) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const bodySeq = parseSeqToBigInt(bodyRecord.seq);
|
||||
const bodySchemaVersion = clampText(bodyRecord.schemaVersion, 16);
|
||||
|
||||
const orgSettings = await prisma.orgSettings.findUnique({
|
||||
where: { orgId: machine.orgId },
|
||||
select: { stoppageMultiplier: true, macroStoppageMultiplier: true, defaultsJson: true },
|
||||
@@ -602,6 +609,62 @@ export async function POST(req: Request) {
|
||||
numberFrom(evDowntime?.acknowledgedAtMs) ??
|
||||
null;
|
||||
|
||||
let guardedWrite = commonWrite;
|
||||
const incomingIsNonAuthoritative = isNonAuthoritativeReasonCode(resolved.reasonCode);
|
||||
const isManualAckEvent = finalType === "downtime-acknowledged";
|
||||
if (!isManualAckEvent && incomingIsNonAuthoritative) {
|
||||
const existingEpisode = await prisma.reasonEntry.findFirst({
|
||||
where: {
|
||||
orgId: machine.orgId,
|
||||
kind: "downtime",
|
||||
episodeId: incidentKey,
|
||||
},
|
||||
select: {
|
||||
reasonCode: true,
|
||||
reasonLabel: true,
|
||||
reasonText: true,
|
||||
meta: true,
|
||||
},
|
||||
});
|
||||
if (existingEpisode && !isNonAuthoritativeReasonCode(existingEpisode.reasonCode)) {
|
||||
const existingMeta = asRecord(existingEpisode.meta);
|
||||
const existingMetaReason = asRecord(existingMeta?.reason);
|
||||
guardedWrite = {
|
||||
...commonWrite,
|
||||
reasonCode: existingEpisode.reasonCode,
|
||||
reasonLabel: existingEpisode.reasonLabel ?? existingEpisode.reasonCode,
|
||||
reasonText:
|
||||
existingEpisode.reasonText ??
|
||||
existingEpisode.reasonLabel ??
|
||||
existingEpisode.reasonCode,
|
||||
meta: toJsonValue({
|
||||
source: "ingest:event",
|
||||
eventId: row.id,
|
||||
eventType: row.eventType,
|
||||
incidentKey: clampText((reasonRaw as any).incidentKey ?? evDowntime?.incidentKey, 128),
|
||||
anomalyType:
|
||||
clampText(evRecord.anomalyType, 64) ??
|
||||
clampText(evDowntime?.anomalyType, 64) ??
|
||||
clampText(evRecord.anomaly_type, 64),
|
||||
reason: existingMetaReason ?? {
|
||||
type: resolved.type,
|
||||
categoryId: resolved.categoryId,
|
||||
categoryLabel: resolved.categoryLabel,
|
||||
detailId: resolved.detailId,
|
||||
detailLabel: resolved.detailLabel,
|
||||
reasonText:
|
||||
existingEpisode.reasonText ??
|
||||
existingEpisode.reasonLabel ??
|
||||
existingEpisode.reasonCode,
|
||||
catalogVersion: resolved.catalogVersion,
|
||||
},
|
||||
reasonPreservedFromManual: true,
|
||||
incomingReasonCode: resolved.reasonCode,
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.reasonEntry.upsert({
|
||||
where: { reasonId },
|
||||
create: {
|
||||
@@ -612,14 +675,14 @@ export async function POST(req: Request) {
|
||||
episodeId: incidentKey,
|
||||
durationSeconds: durationSeconds != null ? Math.max(0, Math.trunc(durationSeconds)) : null,
|
||||
episodeEndTs: episodeEndTsMs != null ? new Date(episodeEndTsMs) : null,
|
||||
...commonWrite,
|
||||
...guardedWrite,
|
||||
},
|
||||
update: {
|
||||
kind: "downtime",
|
||||
episodeId: incidentKey,
|
||||
durationSeconds: durationSeconds != null ? Math.max(0, Math.trunc(durationSeconds)) : null,
|
||||
episodeEndTs: episodeEndTsMs != null ? new Date(episodeEndTsMs) : null,
|
||||
...commonWrite,
|
||||
...guardedWrite,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
|
||||
@@ -43,6 +43,9 @@ export async function GET(
|
||||
sku: row.sku,
|
||||
targetQty: row.targetQty,
|
||||
cycleTime: row.cycleTime,
|
||||
mold: row.mold,
|
||||
cavitiesTotal: row.cavitiesTotal,
|
||||
cavitiesActive: row.cavitiesActive,
|
||||
status: row.status,
|
||||
})),
|
||||
});
|
||||
|
||||
@@ -12,8 +12,10 @@ function canManage(role?: string | null) {
|
||||
const MAX_WORK_ORDERS = 2000;
|
||||
const MAX_WORK_ORDER_ID_LENGTH = 64;
|
||||
const MAX_SKU_LENGTH = 64;
|
||||
const MAX_MOLD_LENGTH = 256;
|
||||
const MAX_TARGET_QTY = 2_000_000_000;
|
||||
const MAX_CYCLE_TIME = 86_400;
|
||||
const MAX_CAVITIES = 100_000;
|
||||
const WORK_ORDER_ID_RE = /^[A-Za-z0-9._-]+$/;
|
||||
|
||||
const uploadBodySchema = z.object({
|
||||
@@ -51,6 +53,15 @@ type WorkOrderInput = {
|
||||
sku?: string | null;
|
||||
targetQty?: number | null;
|
||||
cycleTime?: number | null;
|
||||
mold?: string | null;
|
||||
cavitiesTotal?: number | null;
|
||||
cavitiesActive?: number | null;
|
||||
};
|
||||
|
||||
type RowIssue = {
|
||||
row: number;
|
||||
workOrderId: string | null;
|
||||
errors: string[];
|
||||
};
|
||||
|
||||
function normalizeWorkOrders(raw: unknown[]) {
|
||||
@@ -78,17 +89,98 @@ function normalizeWorkOrders(raw: unknown[]) {
|
||||
const cycleTime =
|
||||
cycleTimeRaw == null ? null : Math.min(Math.max(cycleTimeRaw, 0), MAX_CYCLE_TIME);
|
||||
|
||||
const mold = cleanText(
|
||||
record.mold ?? record.moldId ?? record.mold_id ?? null,
|
||||
MAX_MOLD_LENGTH
|
||||
);
|
||||
const cavitiesTotalRaw = toIntOrNull(
|
||||
record.cavitiesTotal ??
|
||||
record.cavities_total ??
|
||||
record.totalCavities ??
|
||||
record.total_cavities
|
||||
);
|
||||
const cavitiesActiveRaw = toIntOrNull(
|
||||
record.cavitiesActive ??
|
||||
record.cavities_active ??
|
||||
record.activeCavities ??
|
||||
record.active_cavities
|
||||
);
|
||||
const cavitiesTotal =
|
||||
cavitiesTotalRaw == null
|
||||
? null
|
||||
: Math.min(Math.max(cavitiesTotalRaw, 0), MAX_CAVITIES);
|
||||
const cavitiesActive =
|
||||
cavitiesActiveRaw == null
|
||||
? null
|
||||
: Math.min(Math.max(cavitiesActiveRaw, 0), MAX_CAVITIES);
|
||||
|
||||
cleaned.push({
|
||||
workOrderId: idRaw,
|
||||
sku: sku ?? null,
|
||||
targetQty: targetQty ?? null,
|
||||
cycleTime: cycleTime ?? null,
|
||||
mold: mold ?? null,
|
||||
cavitiesTotal: cavitiesTotal ?? null,
|
||||
cavitiesActive: cavitiesActive ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
// ✨ NUEVO: validación estricta del Excel
|
||||
// Cada fila debe tener mold (no vacío), cavitiesTotal (>=1), cavitiesActive (>=1, <=cavitiesTotal)
|
||||
// Si UNA SOLA fila falla, se rechaza el archivo completo (Opción A)
|
||||
function validateRows(rows: WorkOrderInput[], rawList: unknown[]): RowIssue[] {
|
||||
const issues: RowIssue[] = [];
|
||||
|
||||
// Validar lista cruda primero (si hay duplicados o IDs inválidos no llegaron a `cleaned`)
|
||||
// Pero aquí enfocamos en la validación de mold/cavidades sobre filas ya normalizadas.
|
||||
rows.forEach((row, idx) => {
|
||||
const errors: string[] = [];
|
||||
|
||||
// Mold requerido
|
||||
if (!row.mold || row.mold.length === 0) {
|
||||
errors.push("Mold is required");
|
||||
}
|
||||
|
||||
// Cavities Total requerido y >= 1
|
||||
if (row.cavitiesTotal == null) {
|
||||
errors.push("Total Cavities is required");
|
||||
} else if (row.cavitiesTotal < 1) {
|
||||
errors.push("Total Cavities must be at least 1");
|
||||
}
|
||||
|
||||
// Cavities Active requerido y >= 1
|
||||
if (row.cavitiesActive == null) {
|
||||
errors.push("Active Cavities is required");
|
||||
} else if (row.cavitiesActive < 1) {
|
||||
errors.push("Active Cavities must be at least 1");
|
||||
}
|
||||
|
||||
// Active <= Total
|
||||
if (
|
||||
row.cavitiesActive != null &&
|
||||
row.cavitiesTotal != null &&
|
||||
row.cavitiesActive > row.cavitiesTotal
|
||||
) {
|
||||
errors.push(
|
||||
`Active Cavities (${row.cavitiesActive}) cannot exceed Total Cavities (${row.cavitiesTotal})`
|
||||
);
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
issues.push({
|
||||
row: idx + 1, // 1-indexed para el operador
|
||||
workOrderId: row.workOrderId,
|
||||
errors,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await requireSession();
|
||||
if (!session) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
|
||||
@@ -138,6 +230,21 @@ export async function POST(req: NextRequest) {
|
||||
return NextResponse.json({ ok: false, error: "No valid work orders provided" }, { status: 400 });
|
||||
}
|
||||
|
||||
// ✨ NUEVO: validación estricta de mold/cavidades
|
||||
// Si una sola fila falla, rechazamos el archivo completo
|
||||
const issues = validateRows(cleaned, listRaw);
|
||||
if (issues.length > 0) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
ok: false,
|
||||
error: "Validation failed",
|
||||
summary: `Excel rejected: ${issues.length} of ${cleaned.length} work order(s) have errors. All work orders must include mold name, total cavities, and active cavities. Fix and re-upload.`,
|
||||
issues,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const created = await prisma.machineWorkOrder.createMany({
|
||||
data: cleaned.map((row) => ({
|
||||
orgId: session.orgId,
|
||||
@@ -146,6 +253,9 @@ export async function POST(req: NextRequest) {
|
||||
sku: row.sku ?? null,
|
||||
targetQty: row.targetQty ?? null,
|
||||
cycleTime: row.cycleTime ?? null,
|
||||
mold: row.mold ?? null,
|
||||
cavitiesTotal: row.cavitiesTotal ?? null,
|
||||
cavitiesActive: row.cavitiesActive ?? null,
|
||||
status: "PENDING",
|
||||
})),
|
||||
skipDuplicates: true,
|
||||
@@ -167,4 +277,4 @@ export async function POST(req: NextRequest) {
|
||||
inserted: created.count,
|
||||
total: cleaned.length,
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user