From ea92b32618f850b890cc13bfa39a99d1ff0fcc30 Mon Sep 17 00:00:00 2001 From: mdares Date: Mon, 5 Jan 2026 16:36:00 +0000 Subject: [PATCH] Finalish MVP --- .../[machineId]/MachineDetailClient.tsx | 672 +++++++++--------- app/(app)/machines/page.tsx | 81 ++- app/(app)/overview/page.tsx | 120 ++-- app/(app)/reports/page.tsx | 233 +++--- app/(app)/settings/page.tsx | 245 ++++--- app/globals.css | 159 ++++- app/invite/[token]/InviteAcceptForm.tsx | 26 +- app/layout.tsx | 11 +- app/login/LoginForm.tsx | 20 +- app/signup/SignupForm.tsx | 38 +- components/auth/RequireAuth.tsx | 2 +- components/layout/Sidebar.tsx | 136 +++- dictionary_en_es.md | 378 ++++++++++ lib/i18n/en.json | 333 +++++++++ lib/i18n/es-MX.json | 333 +++++++++ lib/i18n/translations.ts | 30 + lib/i18n/useI18n.ts | 60 ++ package-lock.json | 112 ++- package.json | 1 + 19 files changed, 2289 insertions(+), 701 deletions(-) create mode 100644 dictionary_en_es.md create mode 100644 lib/i18n/en.json create mode 100644 lib/i18n/es-MX.json create mode 100644 lib/i18n/translations.ts create mode 100644 lib/i18n/useI18n.ts diff --git a/app/(app)/machines/[machineId]/MachineDetailClient.tsx b/app/(app)/machines/[machineId]/MachineDetailClient.tsx index 1aa4e49..d0c9bb1 100644 --- a/app/(app)/machines/[machineId]/MachineDetailClient.tsx +++ b/app/(app)/machines/[machineId]/MachineDetailClient.tsx @@ -3,25 +3,21 @@ import { useEffect, useMemo, useState } from "react"; import Link from "next/link"; import { useParams } from "next/navigation"; -import { ComposedChart } from "recharts"; -import { Cell } from "recharts"; - - import { - ResponsiveContainer, - ScatterChart, - Scatter, - LineChart, - Line, + Bar, + BarChart, CartesianGrid, + Cell, + ComposedChart, + Line, + ReferenceLine, + ResponsiveContainer, + Scatter, + Tooltip, XAxis, YAxis, - Tooltip, - ReferenceLine, - BarChart, - Bar, } from "recharts"; - +import { useI18n } from "@/lib/i18n/useI18n"; type Heartbeat = { ts: string; @@ -57,10 +53,10 @@ type EventRow = { }; type CycleRow = { - ts: string; // ISO - t: number; // epoch ms + ts: string; + t: number; cycleCount: number | null; - actual: number; // seconds + actual: number; ideal: number | null; workOrderId: string | null; sku: string | null; @@ -80,7 +76,46 @@ type MachineDetail = { latestKpi: Kpi | null; }; +type TimelineState = "normal" | "slow" | "microstop" | "macrostop"; + +type TimelineSeg = { + start: number; + end: number; + durationSec: number; + state: TimelineState; +}; + +const TOL = 0.10; + +function classifyGap(dtSec: number, idealSec: number): TimelineState { + const SLOW_X = 1.5; + const STOP_X = 3.0; + const MACRO_X = 10.0; + + if (dtSec <= idealSec * SLOW_X) return "normal"; + if (dtSec <= idealSec * STOP_X) return "slow"; + if (dtSec <= idealSec * MACRO_X) return "microstop"; + return "macrostop"; +} + +function mergeAdjacent(segs: TimelineSeg[]): TimelineSeg[] { + if (!segs.length) return []; + const out: TimelineSeg[] = [segs[0]]; + for (let i = 1; i < segs.length; i++) { + const prev = out[out.length - 1]; + const cur = segs[i]; + if (cur.state === prev.state && cur.start <= prev.end + 1) { + prev.end = Math.max(prev.end, cur.end); + prev.durationSec = (prev.end - prev.start) / 1000; + } else { + out.push(cur); + } + } + return out; +} + export default function MachineDetailClient() { + const { t, locale } = useI18n(); const params = useParams<{ machineId: string }>(); const machineId = params?.machineId; @@ -90,21 +125,42 @@ export default function MachineDetailClient() { const [error, setError] = useState(null); const [cycles, setCycles] = useState([]); const [open, setOpen] = useState(null); - const BUCKET = { - normal: { label: "Ciclo Normal", dot: "#12D18E", glow: "rgba(18,209,142,.35)", chip: "bg-emerald-500/15 text-emerald-300 border-emerald-500/20" }, - slow: { label: "Ciclo Lento", dot: "#F7B500", glow: "rgba(247,181,0,.35)", chip: "bg-yellow-500/15 text-yellow-300 border-yellow-500/20" }, - microstop:{ label: "Microparo", dot: "#FF7A00", glow: "rgba(255,122,0,.35)", chip: "bg-orange-500/15 text-orange-300 border-orange-500/20" }, - macrostop:{ label: "Macroparo", dot: "#FF3B5C", glow: "rgba(255,59,92,.35)", chip: "bg-rose-500/15 text-rose-300 border-rose-500/20" }, - unknown: { label: "Desconocido", dot: "#A1A1AA", glow: "rgba(161,161,170,.25)", chip: "bg-white/10 text-zinc-200 border-white/10" }, + normal: { + labelKey: "machine.detail.bucket.normal", + dot: "#12D18E", + glow: "rgba(18,209,142,.35)", + chip: "bg-emerald-500/15 text-emerald-300 border-emerald-500/20", + }, + slow: { + labelKey: "machine.detail.bucket.slow", + dot: "#F7B500", + glow: "rgba(247,181,0,.35)", + chip: "bg-yellow-500/15 text-yellow-300 border-yellow-500/20", + }, + microstop: { + labelKey: "machine.detail.bucket.microstop", + dot: "#FF7A00", + glow: "rgba(255,122,0,.35)", + chip: "bg-orange-500/15 text-orange-300 border-orange-500/20", + }, + macrostop: { + labelKey: "machine.detail.bucket.macrostop", + dot: "#FF3B5C", + glow: "rgba(255,59,92,.35)", + chip: "bg-rose-500/15 text-rose-300 border-rose-500/20", + }, + unknown: { + labelKey: "machine.detail.bucket.unknown", + dot: "#A1A1AA", + glow: "rgba(161,161,170,.25)", + chip: "bg-white/10 text-zinc-200 border-white/10", + }, } as const; - - - useEffect(() => { - if (!machineId) return; // <-- IMPORTANT guard + if (!machineId) return; let alive = true; @@ -114,15 +170,12 @@ export default function MachineDetailClient() { cache: "no-store", credentials: "include", }); - const json = await res.json(); - - - + const json = await res.json().catch(() => ({})); if (!alive) return; if (!res.ok || json?.ok === false) { - setError(json?.error ?? "Failed to load machine"); + setError(json?.error ?? t("machine.detail.error.failed")); setLoading(false); return; } @@ -134,38 +187,36 @@ export default function MachineDetailClient() { setLoading(false); } catch { if (!alive) return; - setError("Network error"); + setError(t("machine.detail.error.network")); setLoading(false); } - } load(); - const t = setInterval(load, 5000); + const timer = setInterval(load, 5000); return () => { alive = false; - clearInterval(t); + clearInterval(timer); }; - }, [machineId]); - - + }, [machineId, t]); function fmtPct(v?: number | null) { - if (v === null || v === undefined || Number.isNaN(v)) return "—"; + if (v === null || v === undefined || Number.isNaN(v)) return t("common.na"); return `${v.toFixed(1)}%`; } function fmtNum(v?: number | null) { - if (v === null || v === undefined || Number.isNaN(v)) return "—"; + if (v === null || v === undefined || Number.isNaN(v)) return t("common.na"); return `${v}`; } function timeAgo(ts?: string) { - if (!ts) return "never"; + if (!ts) return t("common.never"); const diff = Math.floor((Date.now() - new Date(ts).getTime()) / 1000); - if (diff < 60) return `${diff}s ago`; - if (diff < 3600) return `${Math.floor(diff / 60)}m ago`; - return `${Math.floor(diff / 3600)}h ago`; + 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) { @@ -195,29 +246,55 @@ export default function MachineDetailClient() { return "bg-white/10 text-zinc-200"; } + function formatSeverity(severity?: string) { + if (!severity) return ""; + const key = `overview.severity.${severity.toLowerCase()}`; + const label = t(key); + return label === key ? severity.toUpperCase() : label; + } + + function formatEventType(eventType?: string) { + if (!eventType) return ""; + const key = `overview.event.${eventType}`; + const label = t(key); + return label === key ? eventType : label; + } + const hb = machine?.latestHeartbeat ?? null; const kpi = machine?.latestKpi ?? null; const offline = useMemo(() => isOffline(hb?.ts), [hb?.ts]); const normalizedStatus = normalizeStatus(hb?.status); - const statusLabel = offline ? "OFFLINE" : (normalizedStatus || "UNKNOWN"); + const statusLabel = offline + ? t("machine.detail.status.offline") + : (() => { + if (!normalizedStatus) return t("machine.detail.status.unknown"); + const key = `machine.detail.status.${normalizedStatus.toLowerCase()}`; + const label = t(key); + return label === key ? normalizedStatus : label; + })(); const cycleTarget = (machine as any)?.effectiveCycleTime ?? kpi?.cycleTime ?? null; + const machineCode = machine?.code ?? t("common.na"); + const machineLocation = machine?.location ?? t("common.na"); + const lastSeenLabel = t("machine.detail.lastSeen", { + time: hb?.ts ? timeAgo(hb.ts) : t("common.never"), + }); const ActiveRing = (props: any) => { const { cx, cy, fill } = props; if (cx == null || cy == null) return null; return ( - + ); }; function MiniCard({ - title, - subtitle, - value, - onClick, + title, + subtitle, + value, + onClick, }: { title: string; subtitle: string; @@ -231,7 +308,7 @@ export default function MachineDetailClient() { - {children} @@ -370,7 +436,6 @@ export default function MachineDetailClient() { ); } - function CycleTooltip({ active, payload, label }: any) { if (!active || !payload?.length) return null; @@ -383,78 +448,82 @@ export default function MachineDetailClient() { return (
-
Ciclo: {label}
+
+ {t("machine.detail.tooltip.cycle", { label })} +
-
Duración: {actual?.toFixed(2)}s
-
Ideal: {ideal != null ? `${ideal.toFixed(2)}s` : "—"}
-
Desviación: {deltaPct != null ? `${deltaPct.toFixed(1)}%` : "—"}
+
+ {t("machine.detail.tooltip.duration")}: {actual?.toFixed(2)}s +
+
+ {t("machine.detail.tooltip.ideal")}: {ideal != null ? `${ideal.toFixed(2)}s` : t("common.na")} +
+
+ {t("machine.detail.tooltip.deviation")}: {deltaPct != null ? `${deltaPct.toFixed(1)}%` : t("common.na")} +
); } - - - - const TOL = 0.10; - function hasIdealAndActual(r: CycleDerivedRow): r is CycleDerivedRow & { ideal: number; actual: number } { - return r.ideal != null && r.actual != null && r.ideal > 0; + function hasIdealAndActual( + row: CycleDerivedRow + ): row is CycleDerivedRow & { ideal: number; actual: number } { + return row.ideal != null && row.actual != null && row.ideal > 0; } + const cycleDerived = useMemo(() => { const rows = cycles ?? []; - const mapped: CycleDerivedRow[] = rows.map((c) => { - const ideal = c.ideal ?? null; - const actual = c.actual ?? null; - const extra = ideal != null && actual != null ? actual - ideal : null; + const mapped: CycleDerivedRow[] = rows.map((cycle) => { + const ideal = cycle.ideal ?? null; + const actual = cycle.actual ?? null; + const extra = ideal != null && actual != null ? actual - ideal : null; - let bucket: CycleDerivedRow["bucket"] = "unknown"; - if (ideal != null && actual != null) { - if (actual <= ideal * (1 + TOL)) bucket = "normal"; - else if (extra != null && extra <= 1) bucket = "slow"; - else if (extra != null && extra <= 10) bucket = "microstop"; - else bucket = "macrostop"; - } + let bucket: CycleDerivedRow["bucket"] = "unknown"; + if (ideal != null && actual != null) { + if (actual <= ideal * (1 + TOL)) bucket = "normal"; + else if (extra != null && extra <= 1) bucket = "slow"; + else if (extra != null && extra <= 10) bucket = "microstop"; + else bucket = "macrostop"; + } - return { ...c, ideal, actual, extra, bucket }; - }); + return { ...cycle, ideal, actual, extra, bucket }; + }); const counts = mapped.reduce( - (acc, r) => { + (acc, row) => { acc.total += 1; - acc[r.bucket] += 1; - if (r.extra != null && r.extra > 0) acc.extraTotal += r.extra; + acc[row.bucket] += 1; + if (row.extra != null && row.extra > 0) acc.extraTotal += row.extra; return acc; }, { total: 0, normal: 0, slow: 0, microstop: 0, macrostop: 0, unknown: 0, extraTotal: 0 } ); - const deltas = mapped - .filter(hasIdealAndActual) - .map((r) => ((r.actual - r.ideal) / r.ideal) * 100); - + const deltas = mapped.filter(hasIdealAndActual).map((row) => ((row.actual - row.ideal) / row.ideal) * 100); const avgDeltaPct = deltas.length ? deltas.reduce((a, b) => a + b, 0) / deltas.length : null; return { mapped, counts, avgDeltaPct }; }, [cycles]); + const deviationSeries = useMemo(() => { - // use last N cycles to keep chart readable const last = cycleDerived.mapped.slice(-100); return last - .map((r, idx) => { - const ideal = r.ideal; - const actual = r.actual; + .map((row, idx) => { + const ideal = row.ideal; + const actual = row.actual; if (ideal == null || actual == null || ideal <= 0) return null; const deltaPct = ((actual - ideal) / ideal) * 100; return { - i: idx + 1, // x-axis index (cycle order) + i: idx + 1, actual, ideal, deltaPct, - bucket: r.bucket, + bucket: row.bucket, }; }) .filter(Boolean) as Array<{ @@ -467,111 +536,77 @@ export default function MachineDetailClient() { }, [cycleDerived.mapped]); const impactAgg = useMemo(() => { - // sum extra seconds by bucket const buckets = { slow: 0, microstop: 0, macrostop: 0 } as Record; - for (const r of cycleDerived.mapped) { - if (!r.extra || r.extra <= 0) continue; - if (r.bucket === "slow" || r.bucket === "microstop" || r.bucket === "macrostop") { - buckets[r.bucket] += r.extra; + for (const row of cycleDerived.mapped) { + if (!row.extra || row.extra <= 0) continue; + if (row.bucket === "slow" || row.bucket === "microstop" || row.bucket === "macrostop") { + buckets[row.bucket] += row.extra; } } - const rows = [ - { name: "Slow", seconds: Math.round(buckets.slow * 10) / 10 }, - { name: "Microstop", seconds: Math.round(buckets.microstop * 10) / 10 }, - { name: "Macrostop", seconds: Math.round(buckets.macrostop * 10) / 10 }, - ]; + const rows = (["slow", "microstop", "macrostop"] as const).map((bucket) => ({ + bucket, + label: t(BUCKET[bucket].labelKey), + seconds: Math.round(buckets[bucket] * 10) / 10, + })); - const total = rows.reduce((a, b) => a + b.seconds, 0); + const total = rows.reduce((sum, row) => sum + row.seconds, 0); return { rows, total }; - }, [cycleDerived.mapped]); + }, [BUCKET, cycleDerived.mapped, t]); - type TimelineState = "normal" | "slow" | "microstop" | "macrostop"; -type TimelineSeg = { - start: number; // ms - end: number; // ms - durationSec: number; - state: TimelineState; -}; - -function classifyGap(dtSec: number, idealSec: number): TimelineState { - const SLOW_X = 1.5; - const STOP_X = 3.0; - const MACRO_X = 10.0; - - if (dtSec <= idealSec * SLOW_X) return "normal"; - if (dtSec <= idealSec * STOP_X) return "slow"; - if (dtSec <= idealSec * MACRO_X) return "microstop"; - return "macrostop"; -} - -function mergeAdjacent(segs: TimelineSeg[]): TimelineSeg[] { - if (!segs.length) return []; - const out: TimelineSeg[] = [segs[0]]; - for (let i = 1; i < segs.length; i++) { - const prev = out[out.length - 1]; - const cur = segs[i]; - // merge if same state and touching - if (cur.state === prev.state && cur.start <= prev.end + 1) { - prev.end = Math.max(prev.end, cur.end); - prev.durationSec = (prev.end - prev.start) / 1000; - } else { - out.push(cur); + const timeline = useMemo(() => { + const rows = cycles ?? []; + if (rows.length < 2) { + return { + windowSec: 10800, + segments: [] as TimelineSeg[], + start: null as number | null, + end: null as number | null, + }; } - } - return out; -} -const timeline = useMemo(() => { - const rows = cycles ?? []; - if (rows.length < 2) { - return { windowSec: 10800, segments: [] as TimelineSeg[], start: null as number | null, end: null as number | null }; - } + const windowSec = 10800; + const end = rows[rows.length - 1].t; + const start = end - windowSec * 1000; - // window: last 180s (like your screenshot) - const windowSec = 10800; - const end = rows[rows.length - 1].t; - const start = end - windowSec * 1000; + const idxFirst = Math.max( + 0, + rows.findIndex((row) => row.t >= start) - 1 + ); + const sliced = rows.slice(idxFirst); - // keep cycles that overlap window (need one cycle before start to build first interval) - const idxFirst = Math.max( - 0, - rows.findIndex(r => r.t >= start) - 1 - ); - const sliced = rows.slice(idxFirst); + const segs: TimelineSeg[] = []; - const segs: TimelineSeg[] = []; + for (let i = 1; i < sliced.length; i++) { + const prev = sliced[i - 1]; + const cur = sliced[i]; - for (let i = 1; i < sliced.length; i++) { - const prev = sliced[i - 1]; - const cur = sliced[i]; + const segStart = Math.max(prev.t, start); + const segEnd = Math.min(cur.t, end); + if (segEnd <= segStart) continue; - const s = Math.max(prev.t, start); - const e = Math.min(cur.t, end); - if (e <= s) continue; + const dtSec = (cur.t - prev.t) / 1000; + const ideal = (cur.ideal ?? prev.ideal ?? cycleTarget ?? 0) as number; + if (!ideal || ideal <= 0) continue; - const dtSec = (cur.t - prev.t) / 1000; + const state = classifyGap(dtSec, ideal); - const ideal = (cur.ideal ?? prev.ideal ?? cycleTarget ?? 0) as number; - if (!ideal || ideal <= 0) continue; - - const state = classifyGap(dtSec, ideal); - - segs.push({ - start: s, - end: e, - durationSec: (e - s) / 1000, - state, - }); - } - - const segments = mergeAdjacent(segs); - - return { windowSec, segments, start, end }; -}, [cycles, cycleTarget]); + segs.push({ + start: segStart, + end: segEnd, + durationSec: (segEnd - segStart) / 1000, + state, + }); + } + const segments = mergeAdjacent(segs); + return { windowSec, segments, start, end }; + }, [cycles, cycleTarget]); + const cycleTargetLabel = cycleTarget ? `${cycleTarget}s` : t("common.na"); + const workOrderLabel = kpi?.workOrderId ?? t("common.na"); + const skuLabel = kpi?.sku ?? t("common.na"); return (
@@ -579,15 +614,14 @@ const timeline = useMemo(() => {

- {machine?.name ?? "Machine"} + {machine?.name ?? t("machine.detail.titleFallback")}

{statusLabel}
- {machine?.code ? machine.code : "—"} • {machine?.location ? machine.location : "—"} • Last seen{" "} - {hb?.ts ? timeAgo(hb.ts) : "never"} + {machineCode} - {machineLocation} - {lastSeenLabel}
@@ -596,12 +630,12 @@ const timeline = useMemo(() => { href="/machines" className="rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-white hover:bg-white/10" > - Back + {t("machine.detail.back")}
- {loading &&
Loading…
} + {loading &&
{t("machine.detail.loading")}
} {error && !loading && (
{error} @@ -610,12 +644,15 @@ const timeline = useMemo(() => { {!loading && !error && ( <> - {/* KPI cards */}
OEE
{fmtPct(kpi?.oee)}
-
Updated {kpi?.ts ? timeAgo(kpi.ts) : "never"}
+
+ {t("machine.detail.kpi.updated", { + time: kpi?.ts ? timeAgo(kpi.ts) : t("common.never"), + })} +
@@ -638,72 +675,75 @@ const timeline = useMemo(() => {
- {/* Work order + recent events */}
-
Current Work Order
-
{kpi?.workOrderId ?? "—"}
+
{t("machine.detail.currentWorkOrder")}
+
{workOrderLabel}
SKU
-
{kpi?.sku ?? "—"}
+
{skuLabel}
-
Target
+
{t("overview.target")}
{fmtNum(kpi?.target)}
-
Good
+
{t("overview.good")}
{fmtNum(kpi?.good)}
-
Scrap
+
{t("overview.scrap")}
{fmtNum(kpi?.scrap)}
- Cycle target: {cycleTarget ? `${cycleTarget}s` : "—"} + {t("machine.detail.cycleTarget")}: {cycleTargetLabel}
-
Recent Events
-
{events.length} shown
+
{t("machine.detail.recentEvents")}
+
+ {events.length} {t("overview.shown")} +
{events.length === 0 ? ( -
No events yet.
- ) : ( -
- {events.map((e) => ( -
+
{t("machine.detail.noEvents")}
+ ) : ( +
+ {events.map((event) => ( +
- - {e.severity.toUpperCase()} + + {formatSeverity(event.severity)} - {e.eventType} + {formatEventType(event.eventType)} - {e.requiresAck && ( + {event.requiresAck ? ( - ACK + {t("overview.ack")} - )} + ) : null}
-
{e.title}
- {e.description && ( -
{e.description}
- )} +
{event.title}
+ {event.description ? ( +
{event.description}
+ ) : null}
-
{timeAgo(e.ts)}
+
{timeAgo(event.ts)}
))} @@ -711,118 +751,108 @@ const timeline = useMemo(() => { )}
- {/* Mini analysis cards */} +
setOpen("events")} /> setOpen("deviation")} /> setOpen("impact")} />
- setOpen(null)} - title="Eventos Detectados" - > -
+ + setOpen(null)} title={t("machine.detail.modal.events")}> +
{cycleDerived.mapped - .filter((r) => r.bucket !== "normal" && r.bucket !== "unknown") + .filter((row) => row.bucket !== "normal" && row.bucket !== "unknown") .slice() .reverse() - .map((r, idx) => { - const meta = BUCKET[r.bucket as keyof typeof BUCKET]; + .map((row, idx) => { + const meta = BUCKET[row.bucket as keyof typeof BUCKET]; return (
-
- {/* left accent dot */} +
- {/* colored chip */} - {meta.label} + {t(meta.labelKey)} - - - {r.actual?.toFixed(2)}s - {r.ideal != null ? ` (ideal ${r.ideal.toFixed(2)}s)` : ""} + + {row.actual?.toFixed(2)}s + {row.ideal != null ? ` (${t("machine.detail.modal.standardCycle")} ${row.ideal.toFixed(2)}s)` : ""}
-
{timeAgo(r.ts)}
+
{timeAgo(row.ts)}
); })}
- setOpen(null)} - title="Ciclo Real vs Estándar" - > + + setOpen(null)} title={t("machine.detail.modal.deviation")}>
- {/* Summary cards */}
-
Ciclo estándar (ideal)
+
{t("machine.detail.modal.standardCycle")}
- {cycleTarget ? `${Number(cycleTarget).toFixed(1)}s` : "—"} + {cycleTarget ? `${Number(cycleTarget).toFixed(1)}s` : t("common.na")}
-
Desviación promedio
+
{t("machine.detail.modal.avgDeviation")}
- {cycleDerived.avgDeltaPct == null ? "—" : `${cycleDerived.avgDeltaPct.toFixed(1)}%`} + {cycleDerived.avgDeltaPct == null ? t("common.na") : `${cycleDerived.avgDeltaPct.toFixed(1)}%`}
-
Muestra
+
{t("machine.detail.modal.sample")}
- {deviationSeries.length} ciclos + {deviationSeries.length} {t("machine.detail.modal.cycles")}
- {/* Chart */} -
+
- - + - { : ["auto", "auto"] } /> + } cursor={{ stroke: "var(--app-chart-grid)" }} /> - } cursor={{ stroke: "rgba(255,255,255,0.15)" }} /> - - {/* Ideal center line */} {kpi?.cycleTime ? ( <> - - {/* ±10% tolerance band lines */} { ) : null} - {/* Optional: ideal line from series */} - - - - {/* ONE scatter so hover always matches */} + {
-
- Tip: la línea tenue es el ideal. Cada punto es un ciclo real. -
+
{t("machine.detail.modal.tip")}
- setOpen(null)} - title="Impacto en Producción" - > + + setOpen(null)} title={t("machine.detail.modal.impact")}>
-
Tiempo extra total
+
{t("machine.detail.modal.totalExtra")}
+
{Math.round(impactAgg.total)}s
+
+ +
+
{t("machine.detail.modal.microstops")}
- {Math.round(impactAgg.total)}s + {Math.round(impactAgg.rows.find((row) => row.bucket === "microstop")?.seconds ?? 0)}s
-
Microstops
+
{t("machine.detail.modal.macroStops")}
- {Math.round((impactAgg.rows.find(r => r.name === "Microstop")?.seconds ?? 0))}s -
-
- -
-
Macroparos
-
- {Math.round((impactAgg.rows.find(r => r.name === "Macrostop")?.seconds ?? 0))}s + {Math.round(impactAgg.rows.find((row) => row.bucket === "macrostop")?.seconds ?? 0)}s
-
+
- - - + + + [`${Number(val).toFixed(1)}s`, "Tiempo extra"]} + contentStyle={{ + background: "var(--app-chart-tooltip-bg)", + border: "1px solid var(--app-chart-tooltip-border)", + }} + labelStyle={{ color: "var(--app-chart-label)" }} + formatter={(val: any) => [`${Number(val).toFixed(1)}s`, t("machine.detail.modal.extraTimeLabel")]} /> {impactAgg.rows.map((row, idx) => { - const key = - row.name === "Slow" ? "slow" : - row.name === "Microstop" ? "microstop" : - "macrostop"; - + const key = row.bucket as keyof typeof BUCKET; return ; })} @@ -947,14 +959,10 @@ const timeline = useMemo(() => {
-
- Esto es “tiempo perdido” vs ideal, distribuido por tipo de evento. -
+
{t("machine.detail.modal.extraTimeNote")}
- - )}
); diff --git a/app/(app)/machines/page.tsx b/app/(app)/machines/page.tsx index 4b308ab..f09a9ba 100644 --- a/app/(app)/machines/page.tsx +++ b/app/(app)/machines/page.tsx @@ -2,6 +2,7 @@ import Link from "next/link"; import { useEffect, useState } from "react"; +import { useI18n } from "@/lib/i18n/useI18n"; type MachineRow = { id: string; @@ -17,11 +18,12 @@ type MachineRow = { }; }; -function secondsAgo(ts?: string) { - if (!ts) return "never"; +function secondsAgo(ts: string | undefined, locale: string, fallback: string) { + if (!ts) return fallback; const diff = Math.floor((Date.now() - new Date(ts).getTime()) / 1000); - if (diff < 60) return `${diff}s ago`; - return `${Math.floor(diff / 60)}m ago`; + 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) { @@ -45,6 +47,7 @@ function badgeClass(status?: string, offline?: boolean) { } export default function MachinesPage() { + const { t, locale } = useI18n(); const [machines, setMachines] = useState([]); const [loading, setLoading] = useState(true); const [showCreate, setShowCreate] = useState(false); @@ -88,7 +91,7 @@ export default function MachinesPage() { async function createMachine() { if (!createName.trim()) { - setCreateError("Machine name is required"); + setCreateError(t("machines.create.error.nameRequired")); return; } @@ -107,7 +110,7 @@ export default function MachinesPage() { }); const data = await res.json().catch(() => ({})); if (!res.ok || !data.ok) { - throw new Error(data.error || "Failed to create machine"); + throw new Error(data.error || t("machines.create.error.failed")); } const nextMachine = { @@ -126,7 +129,7 @@ export default function MachinesPage() { setCreateLocation(""); setShowCreate(false); } catch (err: any) { - setCreateError(err?.message || "Failed to create machine"); + setCreateError(err?.message || t("machines.create.error.failed")); } finally { setCreating(false); } @@ -136,12 +139,12 @@ export default function MachinesPage() { try { if (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(text); - setCopyStatus("Copied"); + setCopyStatus(t("machines.pairing.copied")); } else { - setCopyStatus("Copy not supported"); + setCopyStatus(t("machines.pairing.copyUnsupported")); } } catch { - setCopyStatus("Copy failed"); + setCopyStatus(t("machines.pairing.copyFailed")); } setTimeout(() => setCopyStatus(null), 2000); } @@ -152,8 +155,8 @@ export default function MachinesPage() {
-

Machines

-

Select a machine to view live KPIs.

+

{t("machines.title")}

+

{t("machines.subtitle")}

@@ -162,13 +165,13 @@ export default function MachinesPage() { onClick={() => setShowCreate((prev) => !prev)} 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" > - {showCreate ? "Cancel" : "Add Machine"} + {showCreate ? t("machines.cancel") : t("machines.addMachine")} - Back to Overview + {t("machines.backOverview")}
@@ -177,16 +180,14 @@ export default function MachinesPage() {
-
Add a machine
-
- Generate the machine ID and API key for your Node-RED edge. -
+
{t("machines.addCardTitle")}
+
{t("machines.addCardSubtitle")}
@@ -227,22 +228,22 @@ export default function MachinesPage() { {createdMachine && (
-
Edge pairing code
+
{t("machines.pairing.title")}
- Machine: {createdMachine.name} + {t("machines.pairing.machine")} {createdMachine.name}
-
Pairing code
+
{t("machines.pairing.codeLabel")}
{createdMachine.pairingCode}
- Expires{" "} + {t("machines.pairing.expires")}{" "} {createdMachine.pairingExpiresAt - ? new Date(createdMachine.pairingExpiresAt).toLocaleString() - : "soon"} + ? new Date(createdMachine.pairingExpiresAt).toLocaleString(locale) + : t("machines.pairing.soon")}
- Enter this code on the Node-RED Control Tower settings screen to link the edge device. + {t("machines.pairing.instructions")}
{copyStatus &&
{copyStatus}
}
)} - {loading &&
Loading machines…
} + {loading &&
{t("machines.loading")}
} {!loading && machines.length === 0 && ( -
No machines found for this org.
+
{t("machines.empty")}
)}
@@ -268,8 +269,8 @@ export default function MachinesPage() { const hb = m.latestHeartbeat; const offline = isOffline(hb?.ts); const normalizedStatus = normalizeStatus(hb?.status); - const statusLabel = offline ? "OFFLINE" : normalizedStatus || "UNKNOWN"; - const lastSeen = secondsAgo(hb?.ts); + const statusLabel = offline ? t("machines.status.offline") : (normalizedStatus || t("machines.status.unknown")); + const lastSeen = secondsAgo(hb?.ts, locale, t("common.never")); return (
{m.name}
- {m.code ? m.code : "—"} • Last seen {lastSeen} + {m.code ? m.code : t("common.na")} - {t("machines.lastSeen", { time: lastSeen })}
@@ -295,9 +296,9 @@ export default function MachinesPage() {
-
Status
+
{t("machines.status")}
- {offline ? "No heartbeat" : hb?.message ?? "OK"} + {offline ? t("machines.status.noHeartbeat") : (hb?.message ?? t("machines.status.ok"))}
); @@ -306,3 +307,7 @@ export default function MachinesPage() {
); } + + + + diff --git a/app/(app)/overview/page.tsx b/app/(app)/overview/page.tsx index 9a70c95..8a8cf74 100644 --- a/app/(app)/overview/page.tsx +++ b/app/(app)/overview/page.tsx @@ -2,6 +2,7 @@ import Link from "next/link"; import { useEffect, useMemo, useState } from "react"; +import { useI18n } from "@/lib/i18n/useI18n"; type Heartbeat = { ts: string; @@ -61,12 +62,13 @@ const EVENT_WINDOW_SEC = 1800; const MAX_EVENT_MACHINES = 6; const TOL = 0.10; -function secondsAgo(ts?: string) { - if (!ts) return "never"; +function secondsAgo(ts: string | undefined, locale: string, fallback: string) { + if (!ts) return fallback; const diff = Math.floor((Date.now() - new Date(ts).getTime()) / 1000); - if (diff < 60) return `${diff}s ago`; - if (diff < 3600) return `${Math.floor(diff / 60)}m ago`; - return `${Math.floor(diff / 3600)}h ago`; + 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) { @@ -135,6 +137,7 @@ function classifyDerivedEvent(c: CycleRow) { } export default function OverviewPage() { + const { t, locale } = useI18n(); const [machines, setMachines] = useState([]); const [events, setEvents] = useState([]); const [loading, setLoading] = useState(true); @@ -345,72 +348,93 @@ export default function OverviewPage() { return list; }, [machines]); + const formatEventType = (eventType?: string) => { + if (!eventType) return ""; + const key = `overview.event.${eventType}`; + const label = t(key); + return label === key ? eventType : label; + }; + + const formatSource = (source?: string) => { + if (!source) return ""; + const key = `overview.source.${source}`; + const label = t(key); + return label === key ? source : label; + }; + + const formatSeverity = (severity?: string) => { + if (!severity) return ""; + const key = `overview.severity.${severity}`; + const label = t(key); + return label === key ? severity.toUpperCase() : label; + }; + return (
-

Overview

-

Fleet pulse, alerts, and top attention items.

+

{t("overview.title")}

+

{t("overview.subtitle")}

- View Machines + {t("overview.viewMachines")}
- {loading &&
Loading overview...
} + {loading &&
{t("overview.loading")}
}
-
Fleet Health
+
{t("overview.fleetHealth")}
{stats.total}
-
Machines total
+
{t("overview.machinesTotal")}
- Online {stats.online} + {t("overview.online")} {stats.online} - Offline {stats.offline} + {t("overview.offline")} {stats.offline} - Run {stats.running} + {t("overview.run")} {stats.running} - Idle {stats.idle} + {t("overview.idle")} {stats.idle} - Stop {stats.stopped} + {t("overview.stop")} {stats.stopped}
-
Production Totals
+
{t("overview.productionTotals")}
-
Good
+
{t("overview.good")}
{fmtNum(stats.goodSum)}
-
Scrap
+
{t("overview.scrap")}
{fmtNum(stats.scrapSum)}
-
Target
+
{t("overview.target")}
{fmtNum(stats.targetSum)}
-
Sum of latest KPIs across machines.
+
{t("overview.kpiSumNote")}
-
Activity Feed
+
{t("overview.activityFeed")}
{events.length}
- {eventsLoading ? "Refreshing recent events..." : "Last 30 merged events"} + {eventsLoading ? t("overview.eventsRefreshing") : t("overview.eventsLast30")}
{events.slice(0, 3).map((e) => ( @@ -419,11 +443,13 @@ export default function OverviewPage() { {e.machineName ? `${e.machineName}: ` : ""} {e.title}
-
{secondsAgo(e.ts)}
+
+ {secondsAgo(e.ts, locale, t("common.never"))} +
))} {events.length === 0 && !eventsLoading ? ( -
No recent events.
+
{t("overview.eventsNone")}
) : null}
@@ -431,19 +457,19 @@ export default function OverviewPage() {
-
OEE (avg)
+
{t("overview.oeeAvg")}
{fmtPct(stats.oee)}
-
Availability (avg)
+
{t("overview.availabilityAvg")}
{fmtPct(stats.availability)}
-
Performance (avg)
+
{t("overview.performanceAvg")}
{fmtPct(stats.performance)}
-
Quality (avg)
+
{t("overview.qualityAvg")}
{fmtPct(stats.quality)}
@@ -451,11 +477,13 @@ export default function OverviewPage() {
-
Attention List
-
{attention.length} shown
+
{t("overview.attentionList")}
+
+ {attention.length} {t("overview.shown")} +
{attention.length === 0 ? ( -
No urgent issues detected.
+
{t("overview.noUrgent")}
) : (
{attention.map(({ machine, offline, oee }) => ( @@ -467,7 +495,9 @@ export default function OverviewPage() { {machine.code ?? ""} {machine.location ? `- ${machine.location}` : ""}
-
{secondsAgo(machine.latestHeartbeat?.ts)}
+
+ {secondsAgo(machine.latestHeartbeat?.ts, locale, t("common.never"))} +
- {offline ? "OFFLINE" : "ONLINE"} + {offline ? t("overview.status.offline") : t("overview.status.online")} {oee != null && ( @@ -491,12 +521,14 @@ export default function OverviewPage() {
-
Unified Timeline
-
{events.length} items
+
{t("overview.timeline")}
+
+ {events.length} {t("overview.items")} +
{events.length === 0 && !eventsLoading ? ( -
No events yet.
+
{t("overview.noEvents")}
) : (
{events.map((e) => ( @@ -505,16 +537,18 @@ export default function OverviewPage() {
- {e.severity.toUpperCase()} + {formatSeverity(e.severity)} - {e.eventType} + {formatEventType(e.eventType)} - {e.source} + {formatSource(e.source)} {e.requiresAck ? ( - ACK + + {t("overview.ack")} + ) : null}
@@ -526,7 +560,9 @@ export default function OverviewPage() {
{e.description}
) : null}
-
{secondsAgo(e.ts)}
+
+ {secondsAgo(e.ts, locale, t("common.never"))} +
))} diff --git a/app/(app)/reports/page.tsx b/app/(app)/reports/page.tsx index 53706d8..2eb12f1 100644 --- a/app/(app)/reports/page.tsx +++ b/app/(app)/reports/page.tsx @@ -1,6 +1,7 @@ "use client"; import { useEffect, useMemo, useState } from "react"; +import { useI18n } from "@/lib/i18n/useI18n"; import { Bar, BarChart, @@ -66,6 +67,7 @@ type ReportPayload = { type MachineOption = { id: string; name: string }; type FilterOptions = { workOrders: string[]; skus: string[] }; +type Translator = (key: string, vars?: Record) => string; function fmtPct(v?: number | null) { if (v === null || v === undefined || Number.isNaN(v)) return "--"; @@ -102,23 +104,23 @@ function formatTickLabel(ts: string, range: RangeKey) { return `${month}-${day}`; } -function CycleTooltip({ active, payload }: any) { +function CycleTooltip({ active, payload, t }: any) { if (!active || !payload?.length) return null; const p = payload[0]?.payload; if (!p) return null; let detail = ""; if (p.overflow === "low") { - detail = `Below ${p.rangeEnd?.toFixed(1)}s`; + detail = `${t("reports.tooltip.below")} ${p.rangeEnd?.toFixed(1)}s`; } else if (p.overflow === "high") { - detail = `Above ${p.rangeStart?.toFixed(1)}s`; + detail = `${t("reports.tooltip.above")} ${p.rangeStart?.toFixed(1)}s`; } else if (p.rangeStart != null && p.rangeEnd != null) { detail = `${p.rangeStart.toFixed(1)}s - ${p.rangeEnd.toFixed(1)}s`; } const extreme = p.overflow && (p.minValue != null || p.maxValue != null) - ? `Extremes: ${p.minValue?.toFixed(1) ?? "--"}s - ${p.maxValue?.toFixed(1) ?? "--"}s` + ? `${t("reports.tooltip.extremes")}: ${p.minValue?.toFixed(1) ?? "--"}s - ${p.maxValue?.toFixed(1) ?? "--"}s` : ""; return ( @@ -126,11 +128,11 @@ function CycleTooltip({ active, payload }: any) {
{p.label}
- Cycles: {p.count} + {t("reports.tooltip.cycles")}: {p.count}
{detail ? (
- Range: {detail} + {t("reports.tooltip.range")}: {detail}
) : null} {extreme ?
{extreme}
: null} @@ -139,7 +141,7 @@ function CycleTooltip({ active, payload }: any) { ); } -function DowntimeTooltip({ active, payload }: any) { +function DowntimeTooltip({ active, payload, t }: any) { if (!active || !payload?.length) return null; const row = payload[0]?.payload ?? {}; const label = row.name ?? payload[0]?.name ?? ""; @@ -149,13 +151,13 @@ function DowntimeTooltip({ active, payload }: any) {
{label}
- Downtime: {Number(value)} min + {t("reports.tooltip.downtime")}: {Number(value)} min
); } -function buildCsv(report: ReportPayload) { +function buildCsv(report: ReportPayload, t: Translator) { const rows = new Map>(); const addSeries = (series: ReportTrendPoint[], key: string) => { for (const p of series) { @@ -195,7 +197,9 @@ function buildCsv(report: ReportPayload) { const downtime = report.downtime; const sectionLines: string[] = []; - sectionLines.push("section,key,value"); + 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)] @@ -248,7 +252,8 @@ function downloadText(filename: string, content: string) { function buildPdfHtml( report: ReportPayload, rangeLabel: string, - filters: { machine: string; workOrder: string; sku: string } + filters: { machine: string; workOrder: string; sku: string }, + t: Translator ) { const summary = report.summary; const downtime = report.downtime; @@ -260,7 +265,7 @@ function buildPdfHtml( - Report Export + ${t("reports.pdf.title")} -

Reports

-
Range: ${rangeLabel} | Machine: ${filters.machine} | Work Order: ${filters.workOrder} | SKU: ${filters.sku}
+

${t("reports.title")}

+
${t("reports.pdf.range")}: ${rangeLabel} | ${t("reports.pdf.machine")}: ${filters.machine} | ${t("reports.pdf.workOrder")}: ${filters.workOrder} | ${t("reports.pdf.sku")}: ${filters.sku}
@@ -298,44 +303,44 @@ function buildPdfHtml(
-
Top Loss Drivers
+
${t("reports.pdf.topLoss")}
- + - - - - - - + + + + + +
MetricValue
${t("reports.pdf.metric")}${t("reports.pdf.value")}
Macrostop (sec)${downtime.macrostopSec}
Microstop (sec)${downtime.microstopSec}
Slow Cycles${downtime.slowCycleCount}
Quality Spikes${downtime.qualitySpikeCount}
Performance Degradation${downtime.performanceDegradationCount}
OEE Drops${downtime.oeeDropCount}
${t("reports.loss.macrostop")} (sec)${downtime.macrostopSec}
${t("reports.loss.microstop")} (sec)${downtime.microstopSec}
${t("reports.loss.slowCycle")}${downtime.slowCycleCount}
${t("reports.loss.qualitySpike")}${downtime.qualitySpikeCount}
${t("reports.loss.perfDegradation")}${downtime.performanceDegradationCount}
${t("reports.loss.oeeDrop")}${downtime.oeeDropCount}
-
Quality Summary
+
${t("reports.pdf.qualitySummary")}
- + - - - - - - + + + + + +
MetricValue
${t("reports.pdf.metric")}${t("reports.pdf.value")}
Scrap Rate${summary.scrapRate != null ? summary.scrapRate.toFixed(1) + "%" : "--"}
Good Total${summary.goodTotal ?? "--"}
Scrap Total${summary.scrapTotal ?? "--"}
Target Total${summary.targetTotal ?? "--"}
Top Scrap SKU${summary.topScrapSku ?? "--"}
Top Scrap Work Order${summary.topScrapWorkOrder ?? "--"}
${t("reports.scrapRate")}${summary.scrapRate != null ? summary.scrapRate.toFixed(1) + "%" : "--"}
${t("overview.good")}${summary.goodTotal ?? "--"}
${t("overview.scrap")}${summary.scrapTotal ?? "--"}
${t("overview.target")}${summary.targetTotal ?? "--"}
${t("reports.topScrapSku")}${summary.topScrapSku ?? "--"}
${t("reports.topScrapWorkOrder")}${summary.topScrapWorkOrder ?? "--"}
-
Cycle Time Distribution
+
${t("reports.pdf.cycleDistribution")}
- + ${cycleBins @@ -346,8 +351,8 @@ function buildPdfHtml(
-
Notes for Ops
- ${insights.length ? `
    ${insights.map((n) => `
  • ${n}
  • `).join("")}
` : "
None
"} +
${t("reports.pdf.notes")}
+ ${insights.length ? `
    ${insights.map((n) => `
  • ${n}
  • `).join("")}
` : `
${t("reports.pdf.none")}
`}
@@ -355,6 +360,7 @@ function buildPdfHtml( } export default function ReportsPage() { + const { t, locale } = useI18n(); const [range, setRange] = useState("24h"); const [report, setReport] = useState(null); const [loading, setLoading] = useState(true); @@ -366,11 +372,11 @@ export default function ReportsPage() { const [sku, setSku] = useState(""); const rangeLabel = useMemo(() => { - if (range === "24h") return "Last 24 hours"; - if (range === "7d") return "Last 7 days"; - if (range === "30d") return "Last 30 days"; - return "Custom range"; - }, [range]); + 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; @@ -400,14 +406,14 @@ export default function ReportsPage() { const json = await res.json(); if (!alive) return; if (!res.ok || json?.ok === false) { - setError(json?.error ?? "Failed to load reports"); + setError(json?.error ?? t("reports.error.failed")); setReport(null); } else { setReport(json); } } catch { if (!alive) return; - setError("Network error"); + setError(t("reports.error.network")); setReport(null); } finally { if (alive) setLoading(false); @@ -494,26 +500,31 @@ export default function ReportsPage() { }; const machineLabel = useMemo(() => { - if (!machineId) return "All machines"; + if (!machineId) return t("reports.filter.allMachines"); return machines.find((m) => m.id === machineId)?.name ?? machineId; - }, [machineId, machines]); + }, [machineId, machines, t]); - const workOrderLabel = workOrderId || "All work orders"; - const skuLabel = sku || "All SKUs"; + const workOrderLabel = workOrderId || t("reports.filter.allWorkOrders"); + const skuLabel = sku || t("reports.filter.allSkus"); const handleExportCsv = () => { if (!report) return; - const csv = buildCsv(report); + 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, - }); + const html = buildPdfHtml( + report, + rangeLabel, + { + machine: machineLabel, + workOrder: workOrderLabel, + sku: skuLabel, + }, + t + ); const win = window.open("", "_blank", "width=900,height=650"); if (!win) return; @@ -528,10 +539,8 @@ export default function ReportsPage() {
-

Reports

-

- Trends, downtime, and quality analytics across machines. -

+

{t("reports.title")}

+

{t("reports.subtitle")}

@@ -539,26 +548,26 @@ export default function ReportsPage() { onClick={handleExportCsv} className="rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-white hover:bg-white/10" > - Export CSV + {t("reports.exportCsv")}
-
Filters
+
{t("reports.filters")}
{rangeLabel}
-
Range
+
{t("reports.filter.range")}
{(["24h", "7d", "30d", "custom"] as RangeKey[]).map((k) => (
@@ -564,17 +581,19 @@ export default function SettingsPage() {
-
Organization
+
{t("settings.org.title")}
-
Plant Name
-
{orgInfo?.name || "Loading..."}
+
{t("settings.org.plantName")}
+
{orgInfo?.name || t("common.loading")}
{orgInfo?.slug ? ( -
Slug: {orgInfo.slug}
+
+ {t("settings.org.slug")}: {orgInfo.slug} +
) : null}
- Updated: {draft.updatedAt ? new Date(draft.updatedAt).toLocaleString() : "-"} + {t("settings.updated")}:{" "} + {draft.updatedAt ? new Date(draft.updatedAt).toLocaleString(locale) : t("common.na")}
-
Alert Thresholds
-
Applies to all machines
+
{t("settings.thresholds")}
+
{t("settings.thresholds.appliesAll")}
BinCount
${t("reports.tooltip.range")}${t("reports.tooltip.cycles")}