recent changes

This commit is contained in:
Marcelo
2026-04-29 05:05:00 +00:00
parent 7e0fe5c2e1
commit 62169b163c
25 changed files with 6698 additions and 1013 deletions

View File

@@ -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) {

View 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>
);
}

View File

@@ -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()}