pre-bemis

This commit is contained in:
Marcelo
2026-04-22 05:04:19 +00:00
parent ac1a7900c8
commit 80d27f83b6
91 changed files with 11769 additions and 820 deletions

View File

@@ -19,6 +19,7 @@ type MachineRow = {
fwVersion?: string | null;
};
};
const LIVE_REFRESH_MS = 5000;
function secondsAgo(ts: string | undefined, locale: string, fallback: string) {
if (!ts) return fallback;
@@ -52,7 +53,7 @@ export default function MachinesClient({ initialMachines = [] }: { initialMachin
const { t, locale } = useI18n();
const router = useRouter();
const [machines, setMachines] = useState<MachineRow[]>(() => initialMachines);
const [loading, setLoading] = useState(false);
const [loading, setLoading] = useState(() => initialMachines.length === 0);
const [showCreate, setShowCreate] = useState(false);
const [createName, setCreateName] = useState("");
const [createCode, setCreateCode] = useState("");
@@ -69,28 +70,36 @@ export default function MachinesClient({ initialMachines = [] }: { initialMachin
useEffect(() => {
let alive = true;
let timer: ReturnType<typeof setTimeout> | null = null;
async function load() {
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 ?? []);
setLoading(false);
if (initial) setLoading(false);
}
} catch {
if (alive) setLoading(false);
if (alive && initial) setLoading(false);
} finally {
if (!alive) return;
timer = setTimeout(() => {
void load(false);
}, LIVE_REFRESH_MS);
}
}
load();
const t = setInterval(load, 15000);
void load(initialMachines.length === 0);
return () => {
alive = false;
clearInterval(t);
if (timer) clearTimeout(timer);
};
}, []);
}, [initialMachines.length]);
async function createMachine() {
if (!createName.trim()) {

View File

@@ -128,6 +128,7 @@ const TOL = 0.10;
const DEFAULT_MICRO_MULT = 1.5;
const DEFAULT_MACRO_MULT = 5;
const NORMAL_TOL_SEC = 0.1;
const LIVE_REFRESH_MS = 5000;
const BUCKET = {
normal: {
@@ -289,6 +290,24 @@ function rowsToWorkOrders(rows: WorkOrderRow[]): WorkOrderUpload[] {
return out;
}
function toErrorMessage(value: unknown, fallback: string): string {
if (typeof value === "string" && value.trim().length > 0) return value;
if (value && typeof value === "object") {
const maybeMessage =
("message" in value && typeof value.message === "string" && value.message) ||
("error" in value && typeof value.error === "string" && value.error) ||
("text" in value && typeof value.text === "string" && value.text) ||
null;
if (maybeMessage && maybeMessage.trim().length > 0) return maybeMessage;
try {
return JSON.stringify(value);
} catch {
return fallback;
}
}
return fallback;
}
export default function MachineDetailClient() {
const { t, locale } = useI18n();
const { screenlessMode } = useScreenlessMode();
@@ -319,9 +338,14 @@ export default function MachineDetailClient() {
if (!machineId) return;
let alive = true;
let timer: ReturnType<typeof setTimeout> | null = null;
async function load() {
async function load(initial: boolean) {
try {
if (!initial && typeof document !== "undefined" && document.hidden) {
return;
}
const res = await fetch(`/api/machines/${machineId}?windowSec=3600&events=critical`, {
cache: "no-cache",
credentials: "include",
@@ -329,7 +353,7 @@ export default function MachineDetailClient() {
if (res.status === 304) {
if (!alive) return;
setLoading(false);
if (initial) setLoading(false);
return;
}
@@ -338,8 +362,8 @@ export default function MachineDetailClient() {
if (!alive) return;
if (!res.ok || json?.ok === false) {
setError(json?.error ?? t("machine.detail.error.failed"));
setLoading(false);
setError(toErrorMessage(json?.error, t("machine.detail.error.failed")));
if (initial) setLoading(false);
return;
}
@@ -350,19 +374,25 @@ export default function MachineDetailClient() {
setThresholds(json.thresholds ?? null);
setActiveStoppage(json.activeStoppage ?? null);
setError(null);
setLoading(false);
if (initial) setLoading(false);
} catch {
if (!alive) return;
setError(t("machine.detail.error.network"));
setLoading(false);
if (initial) {
setError(t("machine.detail.error.network"));
setLoading(false);
}
} finally {
if (!alive) return;
timer = setTimeout(() => {
void load(false);
}, LIVE_REFRESH_MS);
}
}
load();
const timer = setInterval(load, 15000);
void load(true);
return () => {
alive = false;
clearInterval(timer);
if (timer) clearTimeout(timer);
};
}, [machineId, t]);
@@ -479,7 +509,7 @@ export default function MachineDetailClient() {
} else {
setUploadState({
status: "error",
message: json?.error ?? t("machine.detail.workOrders.uploadError"),
message: toErrorMessage(json?.error, t("machine.detail.workOrders.uploadError")),
});
}
event.target.value = "";
@@ -508,7 +538,7 @@ export default function MachineDetailClient() {
const res = await fetch(`/api/machines/${machineId}`, { method: "DELETE" });
const data = await res.json().catch(() => ({}));
if (!res.ok || !data.ok) {
throw new Error(data.error || t("machines.delete.error.failed"));
throw new Error(toErrorMessage(data?.error, t("machines.delete.error.failed")));
}
router.push("/machines");
} catch (err: unknown) {
@@ -886,9 +916,10 @@ export default function MachineDetailClient() {
const cycleDerived = useMemo(() => {
const rows = cycles ?? [];
const fallbackIdeal = cycleTarget && cycleTarget > 0 ? cycleTarget : null;
const mapped: CycleDerivedRow[] = rows.map((cycle) => {
const ideal = cycle.ideal ?? null;
const ideal = cycle.ideal ?? fallbackIdeal;
const actual = cycle.actual ?? null;
const extra = ideal != null && actual != null ? actual - ideal : null;
@@ -914,7 +945,7 @@ export default function MachineDetailClient() {
const avgDeltaPct = deltas.length ? deltas.reduce((a, b) => a + b, 0) / deltas.length : null;
return { mapped, counts, avgDeltaPct };
}, [cycles, thresholds]);
}, [cycles, cycleTarget, thresholds]);
const deviationSeries = useMemo(() => {
const last = cycleDerived.mapped.slice(-100);
@@ -1313,7 +1344,7 @@ export default function MachineDetailClient() {
className="h-[380px] rounded-3xl border border-white/10 bg-black/30 p-4 backdrop-blur"
style={{ boxShadow: "var(--app-chart-shadow)" }}
>
<ResponsiveContainer width="100%" height="100%">
<ResponsiveContainer width="100%" height="100%" minHeight={200}>
<ComposedChart data={deviationSeries}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--app-chart-grid)" />
<XAxis
@@ -1407,7 +1438,7 @@ export default function MachineDetailClient() {
className="h-[380px] rounded-3xl border border-white/10 bg-black/30 p-4 backdrop-blur"
style={{ boxShadow: "var(--app-chart-shadow)" }}
>
<ResponsiveContainer width="100%" height="100%">
<ResponsiveContainer width="100%" height="100%" minHeight={200}>
<BarChart data={impactAgg.rows}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--app-chart-grid)" />
<XAxis dataKey="label" tick={{ fill: "var(--app-chart-tick)" }} />

View File

@@ -0,0 +1,22 @@
export default function MachinesLoading() {
return (
<div className="p-4 sm:p-6 space-y-6 animate-pulse">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-2">
<div className="h-6 w-36 rounded-lg bg-white/10" />
<div className="h-4 w-60 rounded-lg bg-white/5" />
</div>
<div className="flex w-full gap-2 sm:w-auto">
<div className="h-9 w-full rounded-xl border border-emerald-400/40 bg-emerald-500/10 sm:w-36" />
<div className="h-9 w-full rounded-xl border border-white/10 bg-white/5 sm:w-32" />
</div>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
{Array.from({ length: 6 }).map((_, idx) => (
<div key={idx} className="h-40 rounded-2xl border border-white/10 bg-white/5" />
))}
</div>
</div>
);
}

View File

@@ -1,6 +1,10 @@
import { redirect } from "next/navigation";
import { prisma } from "@/lib/prisma";
import { requireSession } from "@/lib/auth/requireSession";
import {
fetchLatestHeartbeats,
fetchMachineBase,
mergeMachineOverviewRows,
} from "@/lib/machines/withLatest";
import MachinesClient from "./MachinesClient";
function toIso(value?: Date | null) {
@@ -11,34 +15,32 @@ export default async function MachinesPage() {
const session = await requireSession();
if (!session) redirect("/login?next=/machines");
const machines = await prisma.machine.findMany({
where: { orgId: session.orgId },
orderBy: { createdAt: "desc" },
select: {
id: true,
name: true,
code: true,
location: true,
createdAt: true,
updatedAt: true,
heartbeats: {
orderBy: { tsServer: "desc" },
take: 1,
select: { ts: true, tsServer: true, status: true, message: true, ip: true, fwVersion: true },
},
},
const machines = await fetchMachineBase(session.orgId);
const heartbeats = await fetchLatestHeartbeats(
session.orgId,
machines.map((machine) => machine.id)
);
const rows = mergeMachineOverviewRows({
machines,
heartbeats,
includeKpi: false,
});
const initialMachines = machines.map((machine) => ({
...machine,
latestHeartbeat: machine.heartbeats[0]
const initialMachines = rows.map((machine) => ({
id: machine.id,
name: machine.name,
code: machine.code ?? null,
location: machine.location ?? null,
latestHeartbeat: machine.latestHeartbeat
? {
...machine.heartbeats[0],
ts: toIso(machine.heartbeats[0].ts) ?? "",
tsServer: toIso(machine.heartbeats[0].tsServer),
ts: toIso(machine.latestHeartbeat.ts) ?? "",
tsServer: toIso(machine.latestHeartbeat.tsServer),
status: machine.latestHeartbeat.status,
message: machine.latestHeartbeat.message ?? null,
ip: machine.latestHeartbeat.ip ?? null,
fwVersion: machine.latestHeartbeat.fwVersion ?? null,
}
: null,
heartbeats: undefined,
}));
return <MachinesClient initialMachines={initialMachines} />;