Finalish MVP

This commit is contained in:
mdares
2026-01-05 16:36:00 +00:00
parent 538b06bd4b
commit ea92b32618
19 changed files with 2289 additions and 701 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,7 @@
import Link from "next/link"; import Link from "next/link";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useI18n } from "@/lib/i18n/useI18n";
type MachineRow = { type MachineRow = {
id: string; id: string;
@@ -17,11 +18,12 @@ type MachineRow = {
}; };
}; };
function secondsAgo(ts?: string) { function secondsAgo(ts: string | undefined, locale: string, fallback: string) {
if (!ts) return "never"; if (!ts) return fallback;
const diff = Math.floor((Date.now() - new Date(ts).getTime()) / 1000); const diff = Math.floor((Date.now() - new Date(ts).getTime()) / 1000);
if (diff < 60) return `${diff}s ago`; const rtf = new Intl.RelativeTimeFormat(locale, { numeric: "auto" });
return `${Math.floor(diff / 60)}m ago`; if (diff < 60) return rtf.format(-diff, "second");
return rtf.format(-Math.floor(diff / 60), "minute");
} }
function isOffline(ts?: string) { function isOffline(ts?: string) {
@@ -45,6 +47,7 @@ function badgeClass(status?: string, offline?: boolean) {
} }
export default function MachinesPage() { export default function MachinesPage() {
const { t, locale } = useI18n();
const [machines, setMachines] = useState<MachineRow[]>([]); const [machines, setMachines] = useState<MachineRow[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [showCreate, setShowCreate] = useState(false); const [showCreate, setShowCreate] = useState(false);
@@ -88,7 +91,7 @@ export default function MachinesPage() {
async function createMachine() { async function createMachine() {
if (!createName.trim()) { if (!createName.trim()) {
setCreateError("Machine name is required"); setCreateError(t("machines.create.error.nameRequired"));
return; return;
} }
@@ -107,7 +110,7 @@ export default function MachinesPage() {
}); });
const data = await res.json().catch(() => ({})); const data = await res.json().catch(() => ({}));
if (!res.ok || !data.ok) { 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 = { const nextMachine = {
@@ -126,7 +129,7 @@ export default function MachinesPage() {
setCreateLocation(""); setCreateLocation("");
setShowCreate(false); setShowCreate(false);
} catch (err: any) { } catch (err: any) {
setCreateError(err?.message || "Failed to create machine"); setCreateError(err?.message || t("machines.create.error.failed"));
} finally { } finally {
setCreating(false); setCreating(false);
} }
@@ -136,12 +139,12 @@ export default function MachinesPage() {
try { try {
if (navigator.clipboard?.writeText) { if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text); await navigator.clipboard.writeText(text);
setCopyStatus("Copied"); setCopyStatus(t("machines.pairing.copied"));
} else { } else {
setCopyStatus("Copy not supported"); setCopyStatus(t("machines.pairing.copyUnsupported"));
} }
} catch { } catch {
setCopyStatus("Copy failed"); setCopyStatus(t("machines.pairing.copyFailed"));
} }
setTimeout(() => setCopyStatus(null), 2000); setTimeout(() => setCopyStatus(null), 2000);
} }
@@ -152,8 +155,8 @@ export default function MachinesPage() {
<div className="p-6"> <div className="p-6">
<div className="mb-6 flex items-center justify-between"> <div className="mb-6 flex items-center justify-between">
<div> <div>
<h1 className="text-2xl font-semibold text-white">Machines</h1> <h1 className="text-2xl font-semibold text-white">{t("machines.title")}</h1>
<p className="text-sm text-zinc-400">Select a machine to view live KPIs.</p> <p className="text-sm text-zinc-400">{t("machines.subtitle")}</p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -162,13 +165,13 @@ export default function MachinesPage() {
onClick={() => setShowCreate((prev) => !prev)} 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" 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")}
</button> </button>
<Link <Link
href="/overview" href="/overview"
className="rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-white hover:bg-white/10" className="rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-white hover:bg-white/10"
> >
Back to Overview {t("machines.backOverview")}
</Link> </Link>
</div> </div>
</div> </div>
@@ -177,16 +180,14 @@ export default function MachinesPage() {
<div className="mb-6 rounded-2xl border border-white/10 bg-white/5 p-5"> <div className="mb-6 rounded-2xl border border-white/10 bg-white/5 p-5">
<div className="flex items-center justify-between gap-4"> <div className="flex items-center justify-between gap-4">
<div> <div>
<div className="text-sm font-semibold text-white">Add a machine</div> <div className="text-sm font-semibold text-white">{t("machines.addCardTitle")}</div>
<div className="text-xs text-zinc-400"> <div className="text-xs text-zinc-400">{t("machines.addCardSubtitle")}</div>
Generate the machine ID and API key for your Node-RED edge.
</div>
</div> </div>
</div> </div>
<div className="mt-4 grid grid-cols-1 gap-3 md:grid-cols-3"> <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"> <label className="rounded-xl border border-white/10 bg-black/20 p-3 text-xs text-zinc-400">
Machine Name {t("machines.field.name")}
<input <input
value={createName} value={createName}
onChange={(event) => setCreateName(event.target.value)} onChange={(event) => setCreateName(event.target.value)}
@@ -194,7 +195,7 @@ export default function MachinesPage() {
/> />
</label> </label>
<label className="rounded-xl border border-white/10 bg-black/20 p-3 text-xs text-zinc-400"> <label className="rounded-xl border border-white/10 bg-black/20 p-3 text-xs text-zinc-400">
Code (optional) {t("machines.field.code")}
<input <input
value={createCode} value={createCode}
onChange={(event) => setCreateCode(event.target.value)} onChange={(event) => setCreateCode(event.target.value)}
@@ -202,7 +203,7 @@ export default function MachinesPage() {
/> />
</label> </label>
<label className="rounded-xl border border-white/10 bg-black/20 p-3 text-xs text-zinc-400"> <label className="rounded-xl border border-white/10 bg-black/20 p-3 text-xs text-zinc-400">
Location (optional) {t("machines.field.location")}
<input <input
value={createLocation} value={createLocation}
onChange={(event) => setCreateLocation(event.target.value)} onChange={(event) => setCreateLocation(event.target.value)}
@@ -218,7 +219,7 @@ export default function MachinesPage() {
disabled={creating} 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" 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 ? "Creating..." : "Create Machine"} {creating ? t("machines.create.loading") : t("machines.create.default")}
</button> </button>
{createError && <div className="text-xs text-red-200">{createError}</div>} {createError && <div className="text-xs text-red-200">{createError}</div>}
</div> </div>
@@ -227,22 +228,22 @@ export default function MachinesPage() {
{createdMachine && ( {createdMachine && (
<div className="mb-6 rounded-2xl border border-emerald-500/20 bg-emerald-500/10 p-5"> <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">Edge pairing code</div> <div className="text-sm font-semibold text-white">{t("machines.pairing.title")}</div>
<div className="mt-2 text-xs text-zinc-300"> <div className="mt-2 text-xs text-zinc-300">
Machine: <span className="text-white">{createdMachine.name}</span> {t("machines.pairing.machine")} <span className="text-white">{createdMachine.name}</span>
</div> </div>
<div className="mt-3 rounded-xl border border-white/10 bg-black/30 p-4"> <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">Pairing code</div> <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-3xl font-semibold text-white">{createdMachine.pairingCode}</div>
<div className="mt-2 text-xs text-zinc-400"> <div className="mt-2 text-xs text-zinc-400">
Expires{" "} {t("machines.pairing.expires")}{" "}
{createdMachine.pairingExpiresAt {createdMachine.pairingExpiresAt
? new Date(createdMachine.pairingExpiresAt).toLocaleString() ? new Date(createdMachine.pairingExpiresAt).toLocaleString(locale)
: "soon"} : t("machines.pairing.soon")}
</div> </div>
</div> </div>
<div className="mt-3 text-xs text-zinc-300"> <div className="mt-3 text-xs text-zinc-300">
Enter this code on the Node-RED Control Tower settings screen to link the edge device. {t("machines.pairing.instructions")}
</div> </div>
<div className="mt-3 flex flex-wrap items-center gap-3"> <div className="mt-3 flex flex-wrap items-center gap-3">
<button <button
@@ -250,17 +251,17 @@ export default function MachinesPage() {
onClick={() => copyText(createdMachine.pairingCode)} 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" className="rounded-xl border border-white/10 bg-white/5 px-3 py-2 text-sm text-white hover:bg-white/10"
> >
Copy Code {t("machines.pairing.copy")}
</button> </button>
{copyStatus && <div className="text-xs text-zinc-300">{copyStatus}</div>} {copyStatus && <div className="text-xs text-zinc-300">{copyStatus}</div>}
</div> </div>
</div> </div>
)} )}
{loading && <div className="mb-4 text-sm text-zinc-400">Loading machines</div>} {loading && <div className="mb-4 text-sm text-zinc-400">{t("machines.loading")}</div>}
{!loading && machines.length === 0 && ( {!loading && machines.length === 0 && (
<div className="mb-4 text-sm text-zinc-400">No machines found for this org.</div> <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"> <div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
@@ -268,8 +269,8 @@ export default function MachinesPage() {
const hb = m.latestHeartbeat; const hb = m.latestHeartbeat;
const offline = isOffline(hb?.ts); const offline = isOffline(hb?.ts);
const normalizedStatus = normalizeStatus(hb?.status); const normalizedStatus = normalizeStatus(hb?.status);
const statusLabel = offline ? "OFFLINE" : normalizedStatus || "UNKNOWN"; const statusLabel = offline ? t("machines.status.offline") : (normalizedStatus || t("machines.status.unknown"));
const lastSeen = secondsAgo(hb?.ts); const lastSeen = secondsAgo(hb?.ts, locale, t("common.never"));
return ( return (
<Link <Link
@@ -281,7 +282,7 @@ export default function MachinesPage() {
<div className="min-w-0"> <div className="min-w-0">
<div className="truncate text-lg font-semibold text-white">{m.name}</div> <div className="truncate text-lg font-semibold text-white">{m.name}</div>
<div className="mt-1 text-xs text-zinc-400"> <div className="mt-1 text-xs text-zinc-400">
{m.code ? m.code : "—"} Last seen {lastSeen} {m.code ? m.code : t("common.na")} - {t("machines.lastSeen", { time: lastSeen })}
</div> </div>
</div> </div>
@@ -295,9 +296,9 @@ export default function MachinesPage() {
</span> </span>
</div> </div>
<div className="mt-4 text-sm text-zinc-400">Status</div> <div className="mt-4 text-sm text-zinc-400">{t("machines.status")}</div>
<div className="text-xl font-semibold text-white"> <div className="text-xl font-semibold text-white">
{offline ? "No heartbeat" : hb?.message ?? "OK"} {offline ? t("machines.status.noHeartbeat") : (hb?.message ?? t("machines.status.ok"))}
</div> </div>
</Link> </Link>
); );
@@ -306,3 +307,7 @@ export default function MachinesPage() {
</div> </div>
); );
} }

View File

@@ -2,6 +2,7 @@
import Link from "next/link"; import Link from "next/link";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useI18n } from "@/lib/i18n/useI18n";
type Heartbeat = { type Heartbeat = {
ts: string; ts: string;
@@ -61,12 +62,13 @@ const EVENT_WINDOW_SEC = 1800;
const MAX_EVENT_MACHINES = 6; const MAX_EVENT_MACHINES = 6;
const TOL = 0.10; const TOL = 0.10;
function secondsAgo(ts?: string) { function secondsAgo(ts: string | undefined, locale: string, fallback: string) {
if (!ts) return "never"; if (!ts) return fallback;
const diff = Math.floor((Date.now() - new Date(ts).getTime()) / 1000); const diff = Math.floor((Date.now() - new Date(ts).getTime()) / 1000);
if (diff < 60) return `${diff}s ago`; const rtf = new Intl.RelativeTimeFormat(locale, { numeric: "auto" });
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`; if (diff < 60) return rtf.format(-diff, "second");
return `${Math.floor(diff / 3600)}h ago`; if (diff < 3600) return rtf.format(-Math.floor(diff / 60), "minute");
return rtf.format(-Math.floor(diff / 3600), "hour");
} }
function isOffline(ts?: string) { function isOffline(ts?: string) {
@@ -135,6 +137,7 @@ function classifyDerivedEvent(c: CycleRow) {
} }
export default function OverviewPage() { export default function OverviewPage() {
const { t, locale } = useI18n();
const [machines, setMachines] = useState<MachineRow[]>([]); const [machines, setMachines] = useState<MachineRow[]>([]);
const [events, setEvents] = useState<EventRow[]>([]); const [events, setEvents] = useState<EventRow[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -345,72 +348,93 @@ export default function OverviewPage() {
return list; return list;
}, [machines]); }, [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 ( return (
<div className="p-6"> <div className="p-6">
<div className="mb-6 flex items-start justify-between gap-4"> <div className="mb-6 flex items-start justify-between gap-4">
<div> <div>
<h1 className="text-2xl font-semibold text-white">Overview</h1> <h1 className="text-2xl font-semibold text-white">{t("overview.title")}</h1>
<p className="text-sm text-zinc-400">Fleet pulse, alerts, and top attention items.</p> <p className="text-sm text-zinc-400">{t("overview.subtitle")}</p>
</div> </div>
<Link <Link
href="/machines" href="/machines"
className="rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-white hover:bg-white/10" className="rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-white hover:bg-white/10"
> >
View Machines {t("overview.viewMachines")}
</Link> </Link>
</div> </div>
{loading && <div className="mb-4 text-sm text-zinc-400">Loading overview...</div>} {loading && <div className="mb-4 text-sm text-zinc-400">{t("overview.loading")}</div>}
<div className="grid grid-cols-1 gap-4 xl:grid-cols-3"> <div className="grid grid-cols-1 gap-4 xl:grid-cols-3">
<div className="rounded-2xl border border-white/10 bg-white/5 p-5"> <div className="rounded-2xl border border-white/10 bg-white/5 p-5">
<div className="text-xs text-zinc-400">Fleet Health</div> <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-3xl font-semibold text-white">{stats.total}</div>
<div className="mt-2 text-xs text-zinc-400">Machines 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"> <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"> <span className="rounded-full bg-emerald-500/15 px-2 py-0.5 text-emerald-300">
Online {stats.online} {t("overview.online")} {stats.online}
</span> </span>
<span className="rounded-full bg-white/10 px-2 py-0.5 text-zinc-300"> <span className="rounded-full bg-white/10 px-2 py-0.5 text-zinc-300">
Offline {stats.offline} {t("overview.offline")} {stats.offline}
</span> </span>
<span className="rounded-full bg-emerald-500/10 px-2 py-0.5 text-emerald-200"> <span className="rounded-full bg-emerald-500/10 px-2 py-0.5 text-emerald-200">
Run {stats.running} {t("overview.run")} {stats.running}
</span> </span>
<span className="rounded-full bg-yellow-500/15 px-2 py-0.5 text-yellow-300"> <span className="rounded-full bg-yellow-500/15 px-2 py-0.5 text-yellow-300">
Idle {stats.idle} {t("overview.idle")} {stats.idle}
</span> </span>
<span className="rounded-full bg-red-500/15 px-2 py-0.5 text-red-300"> <span className="rounded-full bg-red-500/15 px-2 py-0.5 text-red-300">
Stop {stats.stopped} {t("overview.stop")} {stats.stopped}
</span> </span>
</div> </div>
</div> </div>
<div className="rounded-2xl border border-white/10 bg-white/5 p-5"> <div className="rounded-2xl border border-white/10 bg-white/5 p-5">
<div className="text-xs text-zinc-400">Production Totals</div> <div className="text-xs text-zinc-400">{t("overview.productionTotals")}</div>
<div className="mt-2 grid grid-cols-3 gap-3"> <div className="mt-2 grid grid-cols-3 gap-3">
<div className="rounded-xl border border-white/10 bg-black/20 p-3"> <div className="rounded-xl border border-white/10 bg-black/20 p-3">
<div className="text-[11px] text-zinc-400">Good</div> <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 className="mt-1 text-sm font-semibold text-white">{fmtNum(stats.goodSum)}</div>
</div> </div>
<div className="rounded-xl border border-white/10 bg-black/20 p-3"> <div className="rounded-xl border border-white/10 bg-black/20 p-3">
<div className="text-[11px] text-zinc-400">Scrap</div> <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 className="mt-1 text-sm font-semibold text-white">{fmtNum(stats.scrapSum)}</div>
</div> </div>
<div className="rounded-xl border border-white/10 bg-black/20 p-3"> <div className="rounded-xl border border-white/10 bg-black/20 p-3">
<div className="text-[11px] text-zinc-400">Target</div> <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 className="mt-1 text-sm font-semibold text-white">{fmtNum(stats.targetSum)}</div>
</div> </div>
</div> </div>
<div className="mt-3 text-xs text-zinc-400">Sum of latest KPIs across machines.</div> <div className="mt-3 text-xs text-zinc-400">{t("overview.kpiSumNote")}</div>
</div> </div>
<div className="rounded-2xl border border-white/10 bg-white/5 p-5"> <div className="rounded-2xl border border-white/10 bg-white/5 p-5">
<div className="text-xs text-zinc-400">Activity Feed</div> <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-3xl font-semibold text-white">{events.length}</div>
<div className="mt-2 text-xs text-zinc-400"> <div className="mt-2 text-xs text-zinc-400">
{eventsLoading ? "Refreshing recent events..." : "Last 30 merged events"} {eventsLoading ? t("overview.eventsRefreshing") : t("overview.eventsLast30")}
</div> </div>
<div className="mt-4 space-y-2"> <div className="mt-4 space-y-2">
{events.slice(0, 3).map((e) => ( {events.slice(0, 3).map((e) => (
@@ -419,11 +443,13 @@ export default function OverviewPage() {
{e.machineName ? `${e.machineName}: ` : ""} {e.machineName ? `${e.machineName}: ` : ""}
{e.title} {e.title}
</div> </div>
<div className="shrink-0 text-zinc-500">{secondsAgo(e.ts)}</div> <div className="shrink-0 text-zinc-500">
{secondsAgo(e.ts, locale, t("common.never"))}
</div>
</div> </div>
))} ))}
{events.length === 0 && !eventsLoading ? ( {events.length === 0 && !eventsLoading ? (
<div className="text-xs text-zinc-500">No recent events.</div> <div className="text-xs text-zinc-500">{t("overview.eventsNone")}</div>
) : null} ) : null}
</div> </div>
</div> </div>
@@ -431,19 +457,19 @@ export default function OverviewPage() {
<div className="mt-6 grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4"> <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="rounded-2xl border border-white/10 bg-white/5 p-5">
<div className="text-xs text-zinc-400">OEE (avg)</div> <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 className="mt-2 text-3xl font-semibold text-emerald-300">{fmtPct(stats.oee)}</div>
</div> </div>
<div className="rounded-2xl border border-white/10 bg-white/5 p-5"> <div className="rounded-2xl border border-white/10 bg-white/5 p-5">
<div className="text-xs text-zinc-400">Availability (avg)</div> <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 className="mt-2 text-2xl font-semibold text-white">{fmtPct(stats.availability)}</div>
</div> </div>
<div className="rounded-2xl border border-white/10 bg-white/5 p-5"> <div className="rounded-2xl border border-white/10 bg-white/5 p-5">
<div className="text-xs text-zinc-400">Performance (avg)</div> <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 className="mt-2 text-2xl font-semibold text-white">{fmtPct(stats.performance)}</div>
</div> </div>
<div className="rounded-2xl border border-white/10 bg-white/5 p-5"> <div className="rounded-2xl border border-white/10 bg-white/5 p-5">
<div className="text-xs text-zinc-400">Quality (avg)</div> <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 className="mt-2 text-2xl font-semibold text-white">{fmtPct(stats.quality)}</div>
</div> </div>
</div> </div>
@@ -451,11 +477,13 @@ export default function OverviewPage() {
<div className="mt-6 grid grid-cols-1 gap-4 xl:grid-cols-3"> <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="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="mb-3 flex items-center justify-between">
<div className="text-sm font-semibold text-white">Attention List</div> <div className="text-sm font-semibold text-white">{t("overview.attentionList")}</div>
<div className="text-xs text-zinc-400">{attention.length} shown</div> <div className="text-xs text-zinc-400">
{attention.length} {t("overview.shown")}
</div>
</div> </div>
{attention.length === 0 ? ( {attention.length === 0 ? (
<div className="text-sm text-zinc-400">No urgent issues detected.</div> <div className="text-sm text-zinc-400">{t("overview.noUrgent")}</div>
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
{attention.map(({ machine, offline, oee }) => ( {attention.map(({ machine, offline, oee }) => (
@@ -467,7 +495,9 @@ export default function OverviewPage() {
{machine.code ?? ""} {machine.location ? `- ${machine.location}` : ""} {machine.code ?? ""} {machine.location ? `- ${machine.location}` : ""}
</div> </div>
</div> </div>
<div className="text-xs text-zinc-400">{secondsAgo(machine.latestHeartbeat?.ts)}</div> <div className="text-xs text-zinc-400">
{secondsAgo(machine.latestHeartbeat?.ts, locale, t("common.never"))}
</div>
</div> </div>
<div className="mt-2 flex items-center gap-2 text-xs"> <div className="mt-2 flex items-center gap-2 text-xs">
<span <span
@@ -475,7 +505,7 @@ export default function OverviewPage() {
offline ? "bg-white/10 text-zinc-300" : "bg-emerald-500/15 text-emerald-300" offline ? "bg-white/10 text-zinc-300" : "bg-emerald-500/15 text-emerald-300"
}`} }`}
> >
{offline ? "OFFLINE" : "ONLINE"} {offline ? t("overview.status.offline") : t("overview.status.online")}
</span> </span>
{oee != null && ( {oee != null && (
<span className="rounded-full bg-yellow-500/15 px-2 py-0.5 text-yellow-300"> <span className="rounded-full bg-yellow-500/15 px-2 py-0.5 text-yellow-300">
@@ -491,12 +521,14 @@ export default function OverviewPage() {
<div className="rounded-2xl border border-white/10 bg-white/5 p-5 xl:col-span-2"> <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="mb-3 flex items-center justify-between">
<div className="text-sm font-semibold text-white">Unified Timeline</div> <div className="text-sm font-semibold text-white">{t("overview.timeline")}</div>
<div className="text-xs text-zinc-400">{events.length} items</div> <div className="text-xs text-zinc-400">
{events.length} {t("overview.items")}
</div>
</div> </div>
{events.length === 0 && !eventsLoading ? ( {events.length === 0 && !eventsLoading ? (
<div className="text-sm text-zinc-400">No events yet.</div> <div className="text-sm text-zinc-400">{t("overview.noEvents")}</div>
) : ( ) : (
<div className="h-[360px] space-y-3 overflow-y-auto no-scrollbar"> <div className="h-[360px] space-y-3 overflow-y-auto no-scrollbar">
{events.map((e) => ( {events.map((e) => (
@@ -505,16 +537,18 @@ export default function OverviewPage() {
<div className="min-w-0"> <div className="min-w-0">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<span className={`rounded-full px-2 py-0.5 text-xs ${severityClass(e.severity)}`}> <span className={`rounded-full px-2 py-0.5 text-xs ${severityClass(e.severity)}`}>
{e.severity.toUpperCase()} {formatSeverity(e.severity)}
</span> </span>
<span className="rounded-full bg-white/10 px-2 py-0.5 text-xs text-zinc-200"> <span className="rounded-full bg-white/10 px-2 py-0.5 text-xs text-zinc-200">
{e.eventType} {formatEventType(e.eventType)}
</span> </span>
<span className={`rounded-full px-2 py-0.5 text-xs ${sourceClass(e.source)}`}> <span className={`rounded-full px-2 py-0.5 text-xs ${sourceClass(e.source)}`}>
{e.source} {formatSource(e.source)}
</span> </span>
{e.requiresAck ? ( {e.requiresAck ? (
<span className="rounded-full bg-white/10 px-2 py-0.5 text-xs text-white">ACK</span> <span className="rounded-full bg-white/10 px-2 py-0.5 text-xs text-white">
{t("overview.ack")}
</span>
) : null} ) : null}
</div> </div>
@@ -526,7 +560,9 @@ export default function OverviewPage() {
<div className="mt-1 text-sm text-zinc-300">{e.description}</div> <div className="mt-1 text-sm text-zinc-300">{e.description}</div>
) : null} ) : null}
</div> </div>
<div className="shrink-0 text-xs text-zinc-400">{secondsAgo(e.ts)}</div> <div className="shrink-0 text-xs text-zinc-400">
{secondsAgo(e.ts, locale, t("common.never"))}
</div>
</div> </div>
</div> </div>
))} ))}

View File

@@ -1,6 +1,7 @@
"use client"; "use client";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useI18n } from "@/lib/i18n/useI18n";
import { import {
Bar, Bar,
BarChart, BarChart,
@@ -66,6 +67,7 @@ type ReportPayload = {
type MachineOption = { id: string; name: string }; type MachineOption = { id: string; name: string };
type FilterOptions = { workOrders: string[]; skus: string[] }; type FilterOptions = { workOrders: string[]; skus: string[] };
type Translator = (key: string, vars?: Record<string, string | number>) => string;
function fmtPct(v?: number | null) { function fmtPct(v?: number | null) {
if (v === null || v === undefined || Number.isNaN(v)) return "--"; if (v === null || v === undefined || Number.isNaN(v)) return "--";
@@ -102,23 +104,23 @@ function formatTickLabel(ts: string, range: RangeKey) {
return `${month}-${day}`; return `${month}-${day}`;
} }
function CycleTooltip({ active, payload }: any) { function CycleTooltip({ active, payload, t }: any) {
if (!active || !payload?.length) return null; if (!active || !payload?.length) return null;
const p = payload[0]?.payload; const p = payload[0]?.payload;
if (!p) return null; if (!p) return null;
let detail = ""; let detail = "";
if (p.overflow === "low") { 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") { } 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) { } else if (p.rangeStart != null && p.rangeEnd != null) {
detail = `${p.rangeStart.toFixed(1)}s - ${p.rangeEnd.toFixed(1)}s`; detail = `${p.rangeStart.toFixed(1)}s - ${p.rangeEnd.toFixed(1)}s`;
} }
const extreme = const extreme =
p.overflow && (p.minValue != null || p.maxValue != null) 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 ( return (
@@ -126,11 +128,11 @@ function CycleTooltip({ active, payload }: any) {
<div className="text-sm font-semibold text-white">{p.label}</div> <div className="text-sm font-semibold text-white">{p.label}</div>
<div className="mt-2 space-y-1 text-xs text-zinc-300"> <div className="mt-2 space-y-1 text-xs text-zinc-300">
<div> <div>
Cycles: <span className="text-white">{p.count}</span> {t("reports.tooltip.cycles")}: <span className="text-white">{p.count}</span>
</div> </div>
{detail ? ( {detail ? (
<div> <div>
Range: <span className="text-white">{detail}</span> {t("reports.tooltip.range")}: <span className="text-white">{detail}</span>
</div> </div>
) : null} ) : null}
{extreme ? <div className="text-zinc-400">{extreme}</div> : null} {extreme ? <div className="text-zinc-400">{extreme}</div> : 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; if (!active || !payload?.length) return null;
const row = payload[0]?.payload ?? {}; const row = payload[0]?.payload ?? {};
const label = row.name ?? payload[0]?.name ?? ""; const label = row.name ?? payload[0]?.name ?? "";
@@ -149,13 +151,13 @@ function DowntimeTooltip({ active, payload }: any) {
<div className="rounded-xl border border-white/10 bg-zinc-950/95 px-4 py-3 shadow-lg"> <div className="rounded-xl border border-white/10 bg-zinc-950/95 px-4 py-3 shadow-lg">
<div className="text-sm font-semibold text-white">{label}</div> <div className="text-sm font-semibold text-white">{label}</div>
<div className="mt-2 text-xs text-zinc-300"> <div className="mt-2 text-xs text-zinc-300">
Downtime: <span className="text-white">{Number(value)} min</span> {t("reports.tooltip.downtime")}: <span className="text-white">{Number(value)} min</span>
</div> </div>
</div> </div>
); );
} }
function buildCsv(report: ReportPayload) { function buildCsv(report: ReportPayload, t: Translator) {
const rows = new Map<string, Record<string, string | number>>(); const rows = new Map<string, Record<string, string | number>>();
const addSeries = (series: ReportTrendPoint[], key: string) => { const addSeries = (series: ReportTrendPoint[], key: string) => {
for (const p of series) { for (const p of series) {
@@ -195,7 +197,9 @@ function buildCsv(report: ReportPayload) {
const downtime = report.downtime; const downtime = report.downtime;
const sectionLines: string[] = []; 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) => { const addRow = (section: string, key: string, value: string | number | null | undefined) => {
sectionLines.push( sectionLines.push(
[section, key, value == null ? "" : String(value)] [section, key, value == null ? "" : String(value)]
@@ -248,7 +252,8 @@ function downloadText(filename: string, content: string) {
function buildPdfHtml( function buildPdfHtml(
report: ReportPayload, report: ReportPayload,
rangeLabel: string, rangeLabel: string,
filters: { machine: string; workOrder: string; sku: string } filters: { machine: string; workOrder: string; sku: string },
t: Translator
) { ) {
const summary = report.summary; const summary = report.summary;
const downtime = report.downtime; const downtime = report.downtime;
@@ -260,7 +265,7 @@ function buildPdfHtml(
<html> <html>
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<title>Report Export</title> <title>${t("reports.pdf.title")}</title>
<style> <style>
body { font-family: Arial, sans-serif; color: #111; margin: 24px; } body { font-family: Arial, sans-serif; color: #111; margin: 24px; }
h1 { margin: 0 0 6px; } h1 { margin: 0 0 6px; }
@@ -275,8 +280,8 @@ function buildPdfHtml(
</style> </style>
</head> </head>
<body> <body>
<h1>Reports</h1> <h1>${t("reports.title")}</h1>
<div class="meta">Range: ${rangeLabel} | Machine: ${filters.machine} | Work Order: ${filters.workOrder} | SKU: ${filters.sku}</div> <div class="meta">${t("reports.pdf.range")}: ${rangeLabel} | ${t("reports.pdf.machine")}: ${filters.machine} | ${t("reports.pdf.workOrder")}: ${filters.workOrder} | ${t("reports.pdf.sku")}: ${filters.sku}</div>
<div class="grid"> <div class="grid">
<div class="card"> <div class="card">
@@ -298,44 +303,44 @@ function buildPdfHtml(
</div> </div>
<div class="card" style="margin-top: 16px;"> <div class="card" style="margin-top: 16px;">
<div class="label">Top Loss Drivers</div> <div class="label">${t("reports.pdf.topLoss")}</div>
<table> <table>
<thead> <thead>
<tr><th>Metric</th><th>Value</th></tr> <tr><th>${t("reports.pdf.metric")}</th><th>${t("reports.pdf.value")}</th></tr>
</thead> </thead>
<tbody> <tbody>
<tr><td>Macrostop (sec)</td><td>${downtime.macrostopSec}</td></tr> <tr><td>${t("reports.loss.macrostop")} (sec)</td><td>${downtime.macrostopSec}</td></tr>
<tr><td>Microstop (sec)</td><td>${downtime.microstopSec}</td></tr> <tr><td>${t("reports.loss.microstop")} (sec)</td><td>${downtime.microstopSec}</td></tr>
<tr><td>Slow Cycles</td><td>${downtime.slowCycleCount}</td></tr> <tr><td>${t("reports.loss.slowCycle")}</td><td>${downtime.slowCycleCount}</td></tr>
<tr><td>Quality Spikes</td><td>${downtime.qualitySpikeCount}</td></tr> <tr><td>${t("reports.loss.qualitySpike")}</td><td>${downtime.qualitySpikeCount}</td></tr>
<tr><td>Performance Degradation</td><td>${downtime.performanceDegradationCount}</td></tr> <tr><td>${t("reports.loss.perfDegradation")}</td><td>${downtime.performanceDegradationCount}</td></tr>
<tr><td>OEE Drops</td><td>${downtime.oeeDropCount}</td></tr> <tr><td>${t("reports.loss.oeeDrop")}</td><td>${downtime.oeeDropCount}</td></tr>
</tbody> </tbody>
</table> </table>
</div> </div>
<div class="card" style="margin-top: 16px;"> <div class="card" style="margin-top: 16px;">
<div class="label">Quality Summary</div> <div class="label">${t("reports.pdf.qualitySummary")}</div>
<table> <table>
<thead> <thead>
<tr><th>Metric</th><th>Value</th></tr> <tr><th>${t("reports.pdf.metric")}</th><th>${t("reports.pdf.value")}</th></tr>
</thead> </thead>
<tbody> <tbody>
<tr><td>Scrap Rate</td><td>${summary.scrapRate != null ? summary.scrapRate.toFixed(1) + "%" : "--"}</td></tr> <tr><td>${t("reports.scrapRate")}</td><td>${summary.scrapRate != null ? summary.scrapRate.toFixed(1) + "%" : "--"}</td></tr>
<tr><td>Good Total</td><td>${summary.goodTotal ?? "--"}</td></tr> <tr><td>${t("overview.good")}</td><td>${summary.goodTotal ?? "--"}</td></tr>
<tr><td>Scrap Total</td><td>${summary.scrapTotal ?? "--"}</td></tr> <tr><td>${t("overview.scrap")}</td><td>${summary.scrapTotal ?? "--"}</td></tr>
<tr><td>Target Total</td><td>${summary.targetTotal ?? "--"}</td></tr> <tr><td>${t("overview.target")}</td><td>${summary.targetTotal ?? "--"}</td></tr>
<tr><td>Top Scrap SKU</td><td>${summary.topScrapSku ?? "--"}</td></tr> <tr><td>${t("reports.topScrapSku")}</td><td>${summary.topScrapSku ?? "--"}</td></tr>
<tr><td>Top Scrap Work Order</td><td>${summary.topScrapWorkOrder ?? "--"}</td></tr> <tr><td>${t("reports.topScrapWorkOrder")}</td><td>${summary.topScrapWorkOrder ?? "--"}</td></tr>
</tbody> </tbody>
</table> </table>
</div> </div>
<div class="card" style="margin-top: 16px;"> <div class="card" style="margin-top: 16px;">
<div class="label">Cycle Time Distribution</div> <div class="label">${t("reports.pdf.cycleDistribution")}</div>
<table> <table>
<thead> <thead>
<tr><th>Bin</th><th>Count</th></tr> <tr><th>${t("reports.tooltip.range")}</th><th>${t("reports.tooltip.cycles")}</th></tr>
</thead> </thead>
<tbody> <tbody>
${cycleBins ${cycleBins
@@ -346,8 +351,8 @@ function buildPdfHtml(
</div> </div>
<div class="card" style="margin-top: 16px;"> <div class="card" style="margin-top: 16px;">
<div class="label">Notes for Ops</div> <div class="label">${t("reports.pdf.notes")}</div>
${insights.length ? `<ul>${insights.map((n) => `<li>${n}</li>`).join("")}</ul>` : "<div>None</div>"} ${insights.length ? `<ul>${insights.map((n) => `<li>${n}</li>`).join("")}</ul>` : `<div>${t("reports.pdf.none")}</div>`}
</div> </div>
</body> </body>
</html> </html>
@@ -355,6 +360,7 @@ function buildPdfHtml(
} }
export default function ReportsPage() { export default function ReportsPage() {
const { t, locale } = useI18n();
const [range, setRange] = useState<RangeKey>("24h"); const [range, setRange] = useState<RangeKey>("24h");
const [report, setReport] = useState<ReportPayload | null>(null); const [report, setReport] = useState<ReportPayload | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -366,11 +372,11 @@ export default function ReportsPage() {
const [sku, setSku] = useState(""); const [sku, setSku] = useState("");
const rangeLabel = useMemo(() => { const rangeLabel = useMemo(() => {
if (range === "24h") return "Last 24 hours"; if (range === "24h") return t("reports.rangeLabel.last24");
if (range === "7d") return "Last 7 days"; if (range === "7d") return t("reports.rangeLabel.last7");
if (range === "30d") return "Last 30 days"; if (range === "30d") return t("reports.rangeLabel.last30");
return "Custom range"; return t("reports.rangeLabel.custom");
}, [range]); }, [range, t]);
useEffect(() => { useEffect(() => {
let alive = true; let alive = true;
@@ -400,14 +406,14 @@ export default function ReportsPage() {
const json = await res.json(); const json = await res.json();
if (!alive) return; if (!alive) return;
if (!res.ok || json?.ok === false) { if (!res.ok || json?.ok === false) {
setError(json?.error ?? "Failed to load reports"); setError(json?.error ?? t("reports.error.failed"));
setReport(null); setReport(null);
} else { } else {
setReport(json); setReport(json);
} }
} catch { } catch {
if (!alive) return; if (!alive) return;
setError("Network error"); setError(t("reports.error.network"));
setReport(null); setReport(null);
} finally { } finally {
if (alive) setLoading(false); if (alive) setLoading(false);
@@ -494,26 +500,31 @@ export default function ReportsPage() {
}; };
const machineLabel = useMemo(() => { const machineLabel = useMemo(() => {
if (!machineId) return "All machines"; if (!machineId) return t("reports.filter.allMachines");
return machines.find((m) => m.id === machineId)?.name ?? machineId; return machines.find((m) => m.id === machineId)?.name ?? machineId;
}, [machineId, machines]); }, [machineId, machines, t]);
const workOrderLabel = workOrderId || "All work orders"; const workOrderLabel = workOrderId || t("reports.filter.allWorkOrders");
const skuLabel = sku || "All SKUs"; const skuLabel = sku || t("reports.filter.allSkus");
const handleExportCsv = () => { const handleExportCsv = () => {
if (!report) return; if (!report) return;
const csv = buildCsv(report); const csv = buildCsv(report, t);
downloadText("reports.csv", csv); downloadText("reports.csv", csv);
}; };
const handleExportPdf = () => { const handleExportPdf = () => {
if (!report) return; if (!report) return;
const html = buildPdfHtml(report, rangeLabel, { const html = buildPdfHtml(
report,
rangeLabel,
{
machine: machineLabel, machine: machineLabel,
workOrder: workOrderLabel, workOrder: workOrderLabel,
sku: skuLabel, sku: skuLabel,
}); },
t
);
const win = window.open("", "_blank", "width=900,height=650"); const win = window.open("", "_blank", "width=900,height=650");
if (!win) return; if (!win) return;
@@ -528,10 +539,8 @@ export default function ReportsPage() {
<div className="p-6"> <div className="p-6">
<div className="mb-6 flex flex-wrap items-start justify-between gap-4"> <div className="mb-6 flex flex-wrap items-start justify-between gap-4">
<div> <div>
<h1 className="text-2xl font-semibold text-white">Reports</h1> <h1 className="text-2xl font-semibold text-white">{t("reports.title")}</h1>
<p className="text-sm text-zinc-400"> <p className="text-sm text-zinc-400">{t("reports.subtitle")}</p>
Trends, downtime, and quality analytics across machines.
</p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -539,26 +548,26 @@ export default function ReportsPage() {
onClick={handleExportCsv} onClick={handleExportCsv}
className="rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-white hover:bg-white/10" 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")}
</button> </button>
<button <button
onClick={handleExportPdf} onClick={handleExportPdf}
className="rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-white hover:bg-white/10" className="rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-white hover:bg-white/10"
> >
Export PDF {t("reports.exportPdf")}
</button> </button>
</div> </div>
</div> </div>
<div className="rounded-2xl border border-white/10 bg-white/5 p-5"> <div className="rounded-2xl border border-white/10 bg-white/5 p-5">
<div className="flex flex-wrap items-center justify-between gap-4"> <div className="flex flex-wrap items-center justify-between gap-4">
<div className="text-sm font-semibold text-white">Filters</div> <div className="text-sm font-semibold text-white">{t("reports.filters")}</div>
<div className="text-xs text-zinc-400">{rangeLabel}</div> <div className="text-xs text-zinc-400">{rangeLabel}</div>
</div> </div>
<div className="mt-4 grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-4"> <div className="mt-4 grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-4">
<div className="rounded-xl border border-white/10 bg-black/20 p-3"> <div className="rounded-xl border border-white/10 bg-black/20 p-3">
<div className="text-[11px] text-zinc-400">Range</div> <div className="text-[11px] text-zinc-400">{t("reports.filter.range")}</div>
<div className="mt-2 flex flex-wrap gap-2"> <div className="mt-2 flex flex-wrap gap-2">
{(["24h", "7d", "30d", "custom"] as RangeKey[]).map((k) => ( {(["24h", "7d", "30d", "custom"] as RangeKey[]).map((k) => (
<button <button
@@ -577,13 +586,13 @@ export default function ReportsPage() {
</div> </div>
<div className="rounded-xl border border-white/10 bg-black/20 p-3"> <div className="rounded-xl border border-white/10 bg-black/20 p-3">
<div className="text-[11px] text-zinc-400">Machine</div> <div className="text-[11px] text-zinc-400">{t("reports.filter.machine")}</div>
<select <select
value={machineId} value={machineId}
onChange={(e) => setMachineId(e.target.value)} onChange={(e) => setMachineId(e.target.value)}
className="mt-2 w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm text-zinc-300" className="mt-2 w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm text-zinc-300"
> >
<option value="">All machines</option> <option value="">{t("reports.filter.allMachines")}</option>
{machines.map((m) => ( {machines.map((m) => (
<option key={m.id} value={m.id}> <option key={m.id} value={m.id}>
{m.name} {m.name}
@@ -593,12 +602,12 @@ export default function ReportsPage() {
</div> </div>
<div className="rounded-xl border border-white/10 bg-black/20 p-3"> <div className="rounded-xl border border-white/10 bg-black/20 p-3">
<div className="text-[11px] text-zinc-400">Work Order</div> <div className="text-[11px] text-zinc-400">{t("reports.filter.workOrder")}</div>
<input <input
list="work-order-list" list="work-order-list"
value={workOrderId} value={workOrderId}
onChange={(e) => setWorkOrderId(e.target.value)} onChange={(e) => setWorkOrderId(e.target.value)}
placeholder="All work orders" placeholder={t("reports.filter.allWorkOrders")}
className="mt-2 w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm text-zinc-300 placeholder:text-zinc-500" className="mt-2 w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm text-zinc-300 placeholder:text-zinc-500"
/> />
<datalist id="work-order-list"> <datalist id="work-order-list">
@@ -609,12 +618,12 @@ export default function ReportsPage() {
</div> </div>
<div className="rounded-xl border border-white/10 bg-black/20 p-3"> <div className="rounded-xl border border-white/10 bg-black/20 p-3">
<div className="text-[11px] text-zinc-400">SKU</div> <div className="text-[11px] text-zinc-400">{t("reports.filter.sku")}</div>
<input <input
list="sku-list" list="sku-list"
value={sku} value={sku}
onChange={(e) => setSku(e.target.value)} onChange={(e) => setSku(e.target.value)}
placeholder="All SKUs" placeholder={t("reports.filter.allSkus")}
className="mt-2 w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm text-zinc-300 placeholder:text-zinc-500" className="mt-2 w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm text-zinc-300 placeholder:text-zinc-500"
/> />
<datalist id="sku-list"> <datalist id="sku-list">
@@ -627,7 +636,7 @@ export default function ReportsPage() {
</div> </div>
<div className="mt-4"> <div className="mt-4">
{loading && <div className="text-sm text-zinc-400">Loading reports...</div>} {loading && <div className="text-sm text-zinc-400">{t("reports.loading")}</div>}
{error && !loading && ( {error && !loading && (
<div className="rounded-2xl border border-red-500/20 bg-red-500/10 p-4 text-sm text-red-200"> <div className="rounded-2xl border border-red-500/20 bg-red-500/10 p-4 text-sm text-red-200">
{error} {error}
@@ -646,7 +655,7 @@ export default function ReportsPage() {
<div className="text-xs text-zinc-400">{kpi.label} (avg)</div> <div className="text-xs text-zinc-400">{kpi.label} (avg)</div>
<div className={`mt-2 text-3xl font-semibold ${kpi.tone}`}>{kpi.value}</div> <div className={`mt-2 text-3xl font-semibold ${kpi.tone}`}>{kpi.value}</div>
<div className="mt-2 text-xs text-zinc-500"> <div className="mt-2 text-xs text-zinc-500">
{summary ? "Computed from KPI snapshots." : "No data in selected range."} {summary ? t("reports.kpi.note.withData") : t("reports.kpi.note.noData")}
</div> </div>
</div> </div>
))} ))}
@@ -654,19 +663,23 @@ export default function ReportsPage() {
<div className="mt-6 grid grid-cols-1 gap-4 xl:grid-cols-2"> <div className="mt-6 grid grid-cols-1 gap-4 xl:grid-cols-2">
<div className="rounded-2xl border border-white/10 bg-white/5 p-5"> <div className="rounded-2xl border border-white/10 bg-white/5 p-5">
<div className="mb-2 text-sm font-semibold text-white">OEE Trend</div> <div className="mb-2 text-sm font-semibold text-white">{t("reports.oeeTrend")}</div>
<div className="h-[260px] rounded-2xl border border-white/10 bg-black/25 p-4"> <div className="h-[260px] rounded-2xl border border-white/10 bg-black/25 p-4">
{oeeSeries.length ? ( {oeeSeries.length ? (
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<LineChart data={oeeSeries}> <LineChart data={oeeSeries}>
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.08)" /> <CartesianGrid strokeDasharray="3 3" stroke="var(--app-chart-grid)" />
<XAxis dataKey="label" tick={{ fill: "#a1a1aa" }} /> <XAxis dataKey="label" tick={{ fill: "var(--app-chart-tick)" }} />
<YAxis domain={[0, 100]} tick={{ fill: "#a1a1aa" }} /> <YAxis domain={[0, 100]} tick={{ fill: "var(--app-chart-tick)" }} />
<Tooltip <Tooltip
contentStyle={{ background: "rgba(0,0,0,0.85)", border: "1px solid rgba(255,255,255,0.1)" }} contentStyle={{
background: "var(--app-chart-tooltip-bg)",
border: "1px solid var(--app-chart-tooltip-border)",
}}
labelStyle={{ color: "var(--app-chart-label)" }}
labelFormatter={(_, payload) => { labelFormatter={(_, payload) => {
const row = payload?.[0]?.payload; const row = payload?.[0]?.payload;
return row?.ts ? new Date(row.ts).toLocaleString() : ""; return row?.ts ? new Date(row.ts).toLocaleString(locale) : "";
}} }}
formatter={(val: any) => [`${Number(val).toFixed(1)}%`, "OEE"]} formatter={(val: any) => [`${Number(val).toFixed(1)}%`, "OEE"]}
/> />
@@ -675,22 +688,22 @@ export default function ReportsPage() {
</ResponsiveContainer> </ResponsiveContainer>
) : ( ) : (
<div className="flex h-full items-center justify-center text-sm text-zinc-500"> <div className="flex h-full items-center justify-center text-sm text-zinc-500">
No trend data yet. {t("reports.noTrend")}
</div> </div>
)} )}
</div> </div>
</div> </div>
<div className="rounded-2xl border border-white/10 bg-white/5 p-5"> <div className="rounded-2xl border border-white/10 bg-white/5 p-5">
<div className="mb-2 text-sm font-semibold text-white">Downtime Pareto</div> <div className="mb-2 text-sm font-semibold text-white">{t("reports.downtimePareto")}</div>
<div className="h-[260px] rounded-2xl border border-white/10 bg-black/25 p-4"> <div className="h-[260px] rounded-2xl border border-white/10 bg-black/25 p-4">
{downtimeSeries.length ? ( {downtimeSeries.length ? (
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<BarChart data={downtimeSeries}> <BarChart data={downtimeSeries}>
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.08)" /> <CartesianGrid strokeDasharray="3 3" stroke="var(--app-chart-grid)" />
<XAxis dataKey="name" tick={{ fill: "#a1a1aa" }} /> <XAxis dataKey="name" tick={{ fill: "var(--app-chart-tick)" }} />
<YAxis tick={{ fill: "#a1a1aa" }} /> <YAxis tick={{ fill: "var(--app-chart-tick)" }} />
<Tooltip content={<DowntimeTooltip />} /> <Tooltip content={<DowntimeTooltip t={t} />} />
<Bar dataKey="value" radius={[10, 10, 0, 0]} isAnimationActive={false}> <Bar dataKey="value" radius={[10, 10, 0, 0]} isAnimationActive={false}>
{downtimeSeries.map((row, idx) => ( {downtimeSeries.map((row, idx) => (
<Cell key={`${row.name}-${idx}`} fill={downtimeColors[row.name] ?? "#94a3b8"} /> <Cell key={`${row.name}-${idx}`} fill={downtimeColors[row.name] ?? "#94a3b8"} />
@@ -700,7 +713,7 @@ export default function ReportsPage() {
</ResponsiveContainer> </ResponsiveContainer>
) : ( ) : (
<div className="flex h-full items-center justify-center text-sm text-zinc-500"> <div className="flex h-full items-center justify-center text-sm text-zinc-500">
No downtime data yet. {t("reports.noTrend")}
</div> </div>
)} )}
</div> </div>
@@ -709,65 +722,69 @@ export default function ReportsPage() {
<div className="mt-6 grid grid-cols-1 gap-4 xl:grid-cols-3"> <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"> <div className="rounded-2xl border border-white/10 bg-white/5 p-5">
<div className="mb-2 text-sm font-semibold text-white">Cycle Time Distribution</div> <div className="mb-2 text-sm font-semibold text-white">{t("reports.cycleDistribution")}</div>
<div className="h-[220px] rounded-2xl border border-white/10 bg-black/25 p-4"> <div className="h-[220px] rounded-2xl border border-white/10 bg-black/25 p-4">
{cycleHistogram.length ? ( {cycleHistogram.length ? (
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<BarChart data={cycleHistogram}> <BarChart data={cycleHistogram}>
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.08)" /> <CartesianGrid strokeDasharray="3 3" stroke="var(--app-chart-grid)" />
<XAxis dataKey="label" tick={{ fill: "#a1a1aa", fontSize: 10 }} /> <XAxis dataKey="label" tick={{ fill: "var(--app-chart-tick)", fontSize: 10 }} />
<YAxis tick={{ fill: "#a1a1aa" }} /> <YAxis tick={{ fill: "var(--app-chart-tick)" }} />
<Tooltip content={<CycleTooltip />} /> <Tooltip content={<CycleTooltip t={t} />} />
<Bar dataKey="count" radius={[8, 8, 0, 0]} fill="#60a5fa" isAnimationActive={false} /> <Bar dataKey="count" radius={[8, 8, 0, 0]} fill="#60a5fa" isAnimationActive={false} />
</BarChart> </BarChart>
</ResponsiveContainer> </ResponsiveContainer>
) : ( ) : (
<div className="flex h-full items-center justify-center text-sm text-zinc-500"> <div className="flex h-full items-center justify-center text-sm text-zinc-500">
No cycle data yet. {t("reports.noCycle")}
</div> </div>
)} )}
</div> </div>
</div> </div>
<div className="rounded-2xl border border-white/10 bg-white/5 p-5"> <div className="rounded-2xl border border-white/10 bg-white/5 p-5">
<div className="mb-2 text-sm font-semibold text-white">Scrap Trend</div> <div className="mb-2 text-sm font-semibold text-white">{t("reports.scrapTrend")}</div>
<div className="h-[220px] rounded-2xl border border-white/10 bg-black/25 p-4"> <div className="h-[220px] rounded-2xl border border-white/10 bg-black/25 p-4">
{scrapSeries.length ? ( {scrapSeries.length ? (
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<LineChart data={scrapSeries}> <LineChart data={scrapSeries}>
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.08)" /> <CartesianGrid strokeDasharray="3 3" stroke="var(--app-chart-grid)" />
<XAxis dataKey="label" tick={{ fill: "#a1a1aa" }} /> <XAxis dataKey="label" tick={{ fill: "var(--app-chart-tick)" }} />
<YAxis domain={[0, 100]} tick={{ fill: "#a1a1aa" }} /> <YAxis domain={[0, 100]} tick={{ fill: "var(--app-chart-tick)" }} />
<Tooltip <Tooltip
contentStyle={{ background: "rgba(0,0,0,0.85)", border: "1px solid rgba(255,255,255,0.1)" }} contentStyle={{
background: "var(--app-chart-tooltip-bg)",
border: "1px solid var(--app-chart-tooltip-border)",
}}
labelStyle={{ color: "var(--app-chart-label)" }}
labelFormatter={(_, payload) => { labelFormatter={(_, payload) => {
const row = payload?.[0]?.payload; const row = payload?.[0]?.payload;
return row?.ts ? new Date(row.ts).toLocaleString() : ""; return row?.ts ? new Date(row.ts).toLocaleString(locale) : "";
}} }}
formatter={(val: any) => [`${Number(val).toFixed(1)}%`, "Scrap Rate"]} formatter={(val: any) => [`${Number(val).toFixed(1)}%`, t("reports.scrapRate")]}
/> />
<Line type="monotone" dataKey="value" stroke="#f97316" dot={false} strokeWidth={2} /> <Line type="monotone" dataKey="value" stroke="#f97316" dot={false} strokeWidth={2} />
</LineChart> </LineChart>
</ResponsiveContainer> </ResponsiveContainer>
) : ( ) : (
<div className="flex h-full items-center justify-center text-sm text-zinc-500"> <div className="flex h-full items-center justify-center text-sm text-zinc-500">
No scrap data yet. {t("reports.noDowntime")}
</div> </div>
)} )}
</div> </div>
</div> </div>
<div className="rounded-2xl border border-white/10 bg-white/5 p-5"> <div className="rounded-2xl border border-white/10 bg-white/5 p-5">
<div className="mb-2 text-sm font-semibold text-white">Top Loss Drivers</div> <div className="mb-2 text-sm font-semibold text-white">{t("reports.topLossDrivers")}</div>
<div className="space-y-3 text-sm text-zinc-300"> <div className="space-y-3 text-sm text-zinc-300">
{[ {[
{ label: "Macrostop", value: fmtDuration(downtime?.macrostopSec) }, { label: t("reports.loss.macrostop"), value: fmtDuration(downtime?.macrostopSec) },
{ label: "Microstop", value: fmtDuration(downtime?.microstopSec) }, { label: t("reports.loss.microstop"), value: fmtDuration(downtime?.microstopSec) },
{ label: "Slow Cycle", value: downtime ? `${downtime.slowCycleCount}` : "--" }, { label: t("reports.loss.slowCycle"), value: downtime ? `${downtime.slowCycleCount}` : "--" },
{ label: "Quality Spike", value: downtime ? `${downtime.qualitySpikeCount}` : "--" }, { label: t("reports.loss.qualitySpike"), value: downtime ? `${downtime.qualitySpikeCount}` : "--" },
{ label: "OEE Drop", value: downtime ? `${downtime.oeeDropCount}` : "--" }, { label: t("reports.loss.oeeDrop"), value: downtime ? `${downtime.oeeDropCount}` : "--" },
{ {
label: "Perf Degradation", label: t("reports.loss.perfDegradation"),
value: downtime ? `${downtime.performanceDegradationCount}` : "--", value: downtime ? `${downtime.performanceDegradationCount}` : "--",
}, },
].map((row) => ( ].map((row) => (
@@ -782,29 +799,29 @@ export default function ReportsPage() {
<div className="mt-6 grid grid-cols-1 gap-4 xl:grid-cols-2"> <div className="mt-6 grid grid-cols-1 gap-4 xl:grid-cols-2">
<div className="rounded-2xl border border-white/10 bg-white/5 p-5"> <div className="rounded-2xl border border-white/10 bg-white/5 p-5">
<div className="mb-3 text-sm font-semibold text-white">Quality Summary</div> <div className="mb-3 text-sm font-semibold text-white">{t("reports.qualitySummary")}</div>
<div className="space-y-3 text-sm text-zinc-300"> <div className="space-y-3 text-sm text-zinc-300">
<div className="rounded-xl border border-white/10 bg-black/20 p-3"> <div className="rounded-xl border border-white/10 bg-black/20 p-3">
<div className="text-xs text-zinc-400">Scrap Rate</div> <div className="text-xs text-zinc-400">{t("reports.scrapRate")}</div>
<div className="mt-1 text-lg font-semibold text-white"> <div className="mt-1 text-lg font-semibold text-white">
{summary?.scrapRate != null ? fmtPct(summary.scrapRate) : "--"} {summary?.scrapRate != null ? fmtPct(summary.scrapRate) : "--"}
</div> </div>
</div> </div>
<div className="rounded-xl border border-white/10 bg-black/20 p-3"> <div className="rounded-xl border border-white/10 bg-black/20 p-3">
<div className="text-xs text-zinc-400">Top Scrap SKU</div> <div className="text-xs text-zinc-400">{t("reports.topScrapSku")}</div>
<div className="mt-1 text-sm text-zinc-300">{summary?.topScrapSku ?? "--"}</div> <div className="mt-1 text-sm text-zinc-300">{summary?.topScrapSku ?? "--"}</div>
</div> </div>
<div className="rounded-xl border border-white/10 bg-black/20 p-3"> <div className="rounded-xl border border-white/10 bg-black/20 p-3">
<div className="text-xs text-zinc-400">Top Scrap Work Order</div> <div className="text-xs text-zinc-400">{t("reports.topScrapWorkOrder")}</div>
<div className="mt-1 text-sm text-zinc-300">{summary?.topScrapWorkOrder ?? "--"}</div> <div className="mt-1 text-sm text-zinc-300">{summary?.topScrapWorkOrder ?? "--"}</div>
</div> </div>
</div> </div>
</div> </div>
<div className="rounded-2xl border border-white/10 bg-white/5 p-5"> <div className="rounded-2xl border border-white/10 bg-white/5 p-5">
<div className="mb-3 text-sm font-semibold text-white">Notes for Ops</div> <div className="mb-3 text-sm font-semibold text-white">{t("reports.notes")}</div>
<div className="rounded-xl border border-white/10 bg-black/20 p-4 text-sm text-zinc-300"> <div className="rounded-xl border border-white/10 bg-black/20 p-4 text-sm text-zinc-300">
<div className="mb-2 text-xs text-zinc-400">Suggested actions</div> <div className="mb-2 text-xs text-zinc-400">{t("reports.notes.suggested")}</div>
{report?.insights && report.insights.length > 0 ? ( {report?.insights && report.insights.length > 0 ? (
<div className="space-y-2"> <div className="space-y-2">
{report.insights.map((note, idx) => ( {report.insights.map((note, idx) => (
@@ -812,7 +829,7 @@ export default function ReportsPage() {
))} ))}
</div> </div>
) : ( ) : (
<div>No insights yet. Generate reports after data collection.</div> <div>{t("reports.notes.none")}</div>
)} )}
</div> </div>
</div> </div>

View File

@@ -1,6 +1,7 @@
"use client"; "use client";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { useI18n } from "@/lib/i18n/useI18n";
type Shift = { type Shift = {
name: string; name: string;
@@ -63,8 +64,7 @@ type InviteRow = {
expiresAt: string; expiresAt: string;
}; };
const DEFAULT_SHIFT: Shift = { const DEFAULT_SHIFT: Omit<Shift, "name"> = {
name: "Shift 1",
start: "06:00", start: "06:00",
end: "15:00", end: "15:00",
enabled: true, enabled: true,
@@ -75,7 +75,7 @@ const DEFAULT_SETTINGS: SettingsPayload = {
version: 0, version: 0,
timezone: "UTC", timezone: "UTC",
shiftSchedule: { shiftSchedule: {
shifts: [DEFAULT_SHIFT], shifts: [],
shiftChangeCompensationMin: 10, shiftChangeCompensationMin: 10,
lunchBreakMin: 30, lunchBreakMin: 30,
}, },
@@ -111,22 +111,30 @@ async function readResponse(response: Response) {
} }
} }
function normalizeShift(raw: any, index: number): Shift { function normalizeShift(raw: any, index: number, fallbackName: string): Shift {
const name = String(raw?.name || `Shift ${index + 1}`); const name = String(raw?.name || fallbackName);
const start = String(raw?.start || raw?.startTime || DEFAULT_SHIFT.start); const start = String(raw?.start || raw?.startTime || DEFAULT_SHIFT.start);
const end = String(raw?.end || raw?.endTime || DEFAULT_SHIFT.end); const end = String(raw?.end || raw?.endTime || DEFAULT_SHIFT.end);
const enabled = raw?.enabled !== false; const enabled = raw?.enabled !== false;
return { name, start, end, enabled }; return { name, start, end, enabled };
} }
function normalizeSettings(raw: any): SettingsPayload { function normalizeSettings(raw: any, fallbackName: (index: number) => string): SettingsPayload {
if (!raw || typeof raw !== "object") return { ...DEFAULT_SETTINGS }; if (!raw || typeof raw !== "object") {
return {
...DEFAULT_SETTINGS,
shiftSchedule: {
...DEFAULT_SETTINGS.shiftSchedule,
shifts: [{ name: fallbackName(1), ...DEFAULT_SHIFT }],
},
};
}
const shiftSchedule = raw.shiftSchedule || {}; const shiftSchedule = raw.shiftSchedule || {};
const shiftsRaw = Array.isArray(shiftSchedule.shifts) ? shiftSchedule.shifts : []; const shiftsRaw = Array.isArray(shiftSchedule.shifts) ? shiftSchedule.shifts : [];
const shifts = shiftsRaw.length const shifts = shiftsRaw.length
? shiftsRaw.map((s: any, idx: number) => normalizeShift(s, idx)) ? shiftsRaw.map((s: any, idx: number) => normalizeShift(s, idx, fallbackName(idx + 1)))
: [DEFAULT_SHIFT]; : [{ name: fallbackName(1), ...DEFAULT_SHIFT }];
return { return {
orgId: String(raw.orgId || ""), orgId: String(raw.orgId || ""),
@@ -207,11 +215,12 @@ function Toggle({
} }
export default function SettingsPage() { export default function SettingsPage() {
const { t, locale } = useI18n();
const [draft, setDraft] = useState<SettingsPayload | null>(null); const [draft, setDraft] = useState<SettingsPayload | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [saveStatus, setSaveStatus] = useState<string | null>(null); const [saveStatus, setSaveStatus] = useState<"saved" | null>(null);
const [orgInfo, setOrgInfo] = useState<OrgInfo | null>(null); const [orgInfo, setOrgInfo] = useState<OrgInfo | null>(null);
const [members, setMembers] = useState<MemberRow[]>([]); const [members, setMembers] = useState<MemberRow[]>([]);
const [invites, setInvites] = useState<InviteRow[]>([]); const [invites, setInvites] = useState<InviteRow[]>([]);
@@ -221,6 +230,10 @@ export default function SettingsPage() {
const [inviteRole, setInviteRole] = useState("MEMBER"); const [inviteRole, setInviteRole] = useState("MEMBER");
const [inviteStatus, setInviteStatus] = useState<string | null>(null); const [inviteStatus, setInviteStatus] = useState<string | null>(null);
const [inviteSubmitting, setInviteSubmitting] = useState(false); const [inviteSubmitting, setInviteSubmitting] = useState(false);
const defaultShiftName = useCallback(
(index: number) => t("settings.shift.defaultName", { index }),
[t]
);
const loadSettings = useCallback(async () => { const loadSettings = useCallback(async () => {
setLoading(true); setLoading(true);
@@ -229,18 +242,17 @@ export default function SettingsPage() {
const response = await fetch("/api/settings", { cache: "no-store" }); const response = await fetch("/api/settings", { cache: "no-store" });
const { data, text } = await readResponse(response); const { data, text } = await readResponse(response);
if (!response.ok || !data?.ok) { if (!response.ok || !data?.ok) {
const message = const message = data?.error || data?.message || text || t("settings.failedLoad");
data?.error || data?.message || text || `Failed to load settings (${response.status})`;
throw new Error(message); throw new Error(message);
} }
const next = normalizeSettings(data.settings); const next = normalizeSettings(data.settings, defaultShiftName);
setDraft(next); setDraft(next);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Failed to load settings"); setError(err instanceof Error ? err.message : t("settings.failedLoad"));
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, []); }, [defaultShiftName, t]);
const buildInviteUrl = useCallback((token: string) => { const buildInviteUrl = useCallback((token: string) => {
if (typeof window === "undefined") return `/invite/${token}`; if (typeof window === "undefined") return `/invite/${token}`;
@@ -254,19 +266,18 @@ export default function SettingsPage() {
const response = await fetch("/api/org/members", { cache: "no-store" }); const response = await fetch("/api/org/members", { cache: "no-store" });
const { data, text } = await readResponse(response); const { data, text } = await readResponse(response);
if (!response.ok || !data?.ok) { if (!response.ok || !data?.ok) {
const message = const message = data?.error || data?.message || text || t("settings.failedTeam");
data?.error || data?.message || text || `Failed to load team (${response.status})`;
throw new Error(message); throw new Error(message);
} }
setOrgInfo(data.org ?? null); setOrgInfo(data.org ?? null);
setMembers(Array.isArray(data.members) ? data.members : []); setMembers(Array.isArray(data.members) ? data.members : []);
setInvites(Array.isArray(data.invites) ? data.invites : []); setInvites(Array.isArray(data.invites) ? data.invites : []);
} catch (err) { } catch (err) {
setTeamError(err instanceof Error ? err.message : "Failed to load team"); setTeamError(err instanceof Error ? err.message : t("settings.failedTeam"));
} finally { } finally {
setTeamLoading(false); setTeamLoading(false);
} }
}, []); }, [t]);
useEffect(() => { useEffect(() => {
loadSettings(); loadSettings();
@@ -295,10 +306,8 @@ export default function SettingsPage() {
if (prev.shiftSchedule.shifts.length >= 3) return prev; if (prev.shiftSchedule.shifts.length >= 3) return prev;
const nextIndex = prev.shiftSchedule.shifts.length + 1; const nextIndex = prev.shiftSchedule.shifts.length + 1;
const newShift: Shift = { const newShift: Shift = {
name: `Shift ${nextIndex}`, name: defaultShiftName(nextIndex),
start: DEFAULT_SHIFT.start, ...DEFAULT_SHIFT,
end: DEFAULT_SHIFT.end,
enabled: true,
}; };
return { return {
...prev, ...prev,
@@ -308,7 +317,7 @@ export default function SettingsPage() {
}, },
}; };
}); });
}, []); }, [defaultShiftName]);
const removeShift = useCallback((index: number) => { const removeShift = useCallback((index: number) => {
setDraft((prev) => { setDraft((prev) => {
@@ -403,7 +412,7 @@ export default function SettingsPage() {
try { try {
if (navigator.clipboard?.writeText) { if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(url); await navigator.clipboard.writeText(url);
setInviteStatus("Invite link copied"); setInviteStatus(t("settings.inviteStatus.copied"));
} else { } else {
setInviteStatus(url); setInviteStatus(url);
} }
@@ -411,7 +420,7 @@ export default function SettingsPage() {
setInviteStatus(url); setInviteStatus(url);
} }
}, },
[buildInviteUrl] [buildInviteUrl, t]
); );
const revokeInvite = useCallback(async (inviteId: string) => { const revokeInvite = useCallback(async (inviteId: string) => {
@@ -420,19 +429,18 @@ export default function SettingsPage() {
const response = await fetch(`/api/org/invites/${inviteId}`, { method: "DELETE" }); const response = await fetch(`/api/org/invites/${inviteId}`, { method: "DELETE" });
const { data, text } = await readResponse(response); const { data, text } = await readResponse(response);
if (!response.ok || !data?.ok) { if (!response.ok || !data?.ok) {
const message = const message = data?.error || data?.message || text || t("settings.inviteStatus.failed");
data?.error || data?.message || text || `Failed to revoke invite (${response.status})`;
throw new Error(message); throw new Error(message);
} }
setInvites((prev) => prev.filter((invite) => invite.id !== inviteId)); setInvites((prev) => prev.filter((invite) => invite.id !== inviteId));
} catch (err) { } catch (err) {
setInviteStatus(err instanceof Error ? err.message : "Failed to revoke invite"); setInviteStatus(err instanceof Error ? err.message : t("settings.inviteStatus.failed"));
} }
}, []); }, [t]);
const createInvite = useCallback(async () => { const createInvite = useCallback(async () => {
if (!inviteEmail.trim()) { if (!inviteEmail.trim()) {
setInviteStatus("Email is required"); setInviteStatus(t("settings.inviteStatus.emailRequired"));
return; return;
} }
setInviteSubmitting(true); setInviteSubmitting(true);
@@ -445,8 +453,7 @@ export default function SettingsPage() {
}); });
const { data, text } = await readResponse(response); const { data, text } = await readResponse(response);
if (!response.ok || !data?.ok) { if (!response.ok || !data?.ok) {
const message = const message = data?.error || data?.message || text || t("settings.inviteStatus.createFailed");
data?.error || data?.message || text || `Failed to create invite (${response.status})`;
throw new Error(message); throw new Error(message);
} }
const nextInvite = data.invite; const nextInvite = data.invite;
@@ -454,19 +461,19 @@ export default function SettingsPage() {
setInvites((prev) => [nextInvite, ...prev.filter((invite) => invite.id !== nextInvite.id)]); setInvites((prev) => [nextInvite, ...prev.filter((invite) => invite.id !== nextInvite.id)]);
const inviteUrl = buildInviteUrl(nextInvite.token); const inviteUrl = buildInviteUrl(nextInvite.token);
if (data.emailSent === false) { if (data.emailSent === false) {
setInviteStatus(`Invite created, email failed: ${inviteUrl}`); setInviteStatus(t("settings.inviteStatus.emailFailed", { url: inviteUrl }));
} else { } else {
setInviteStatus("Invite email sent"); setInviteStatus(t("settings.inviteStatus.sent"));
} }
} }
setInviteEmail(""); setInviteEmail("");
await loadTeam(); await loadTeam();
} catch (err) { } catch (err) {
setInviteStatus(err instanceof Error ? err.message : "Failed to create invite"); setInviteStatus(err instanceof Error ? err.message : t("settings.inviteStatus.createFailed"));
} finally { } finally {
setInviteSubmitting(false); setInviteSubmitting(false);
} }
}, [buildInviteUrl, inviteEmail, inviteRole, loadTeam]); }, [buildInviteUrl, inviteEmail, inviteRole, loadTeam, t]);
const saveSettings = useCallback(async () => { const saveSettings = useCallback(async () => {
if (!draft) return; if (!draft) return;
@@ -490,33 +497,43 @@ export default function SettingsPage() {
const { data, text } = await readResponse(response); const { data, text } = await readResponse(response);
if (!response.ok || !data?.ok) { if (!response.ok || !data?.ok) {
if (response.status === 409) { if (response.status === 409) {
throw new Error("Settings changed elsewhere. Refresh and try again."); throw new Error(t("settings.conflict"));
} }
const message = const message = data?.error || data?.message || text || t("settings.failedSave");
data?.error || data?.message || text || `Failed to save settings (${response.status})`;
throw new Error(message); throw new Error(message);
} }
const next = normalizeSettings(data.settings); const next = normalizeSettings(data.settings, defaultShiftName);
setDraft(next); setDraft(next);
setSaveStatus("Saved"); setSaveStatus("saved");
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Failed to save settings"); setError(err instanceof Error ? err.message : t("settings.failedSave"));
} finally { } finally {
setSaving(false); setSaving(false);
} }
}, [draft]); }, [defaultShiftName, draft, t]);
const statusLabel = useMemo(() => { const statusLabel = useMemo(() => {
if (loading) return "Loading settings..."; if (loading) return t("settings.loading");
if (saving) return "Saving..."; if (saving) return t("settings.saving");
return saveStatus; if (saveStatus === "saved") return t("settings.saved");
}, [loading, saving, saveStatus]); return null;
}, [loading, saving, saveStatus, t]);
const formatRole = useCallback(
(role?: string | null) => {
if (!role) return "";
const key = `settings.role.${role.toLowerCase()}`;
const label = t(key);
return label === key ? role : label;
},
[t]
);
if (loading && !draft) { if (loading && !draft) {
return ( return (
<div className="p-6"> <div className="p-6">
<div className="rounded-2xl border border-white/10 bg-white/5 p-6 text-sm text-zinc-300"> <div className="rounded-2xl border border-white/10 bg-white/5 p-6 text-sm text-zinc-300">
Loading settings... {t("settings.loading")}
</div> </div>
</div> </div>
); );
@@ -526,7 +543,7 @@ export default function SettingsPage() {
return ( return (
<div className="p-6"> <div className="p-6">
<div className="rounded-2xl border border-red-500/30 bg-red-500/10 p-6 text-sm text-red-200"> <div className="rounded-2xl border border-red-500/30 bg-red-500/10 p-6 text-sm text-red-200">
{error || "Settings are unavailable."} {error || t("settings.unavailable")}
</div> </div>
</div> </div>
); );
@@ -536,22 +553,22 @@ export default function SettingsPage() {
<div className="p-6"> <div className="p-6">
<div className="mb-6 flex flex-wrap items-center justify-between gap-4"> <div className="mb-6 flex flex-wrap items-center justify-between gap-4">
<div> <div>
<h1 className="text-2xl font-semibold text-white">Settings</h1> <h1 className="text-2xl font-semibold text-white">{t("settings.title")}</h1>
<p className="text-sm text-zinc-400">Live configuration for shifts, alerts, and defaults.</p> <p className="text-sm text-zinc-400">{t("settings.subtitle")}</p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <button
onClick={loadSettings} onClick={loadSettings}
className="rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-white hover:bg-white/10" className="rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-white hover:bg-white/10"
> >
Refresh {t("settings.refresh")}
</button> </button>
<button <button
onClick={saveSettings} onClick={saveSettings}
disabled={saving} disabled={saving}
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:cursor-not-allowed disabled:opacity-60" 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:cursor-not-allowed disabled:opacity-60"
> >
Save Changes {t("settings.save")}
</button> </button>
</div> </div>
</div> </div>
@@ -564,17 +581,19 @@ export default function SettingsPage() {
<div className="grid grid-cols-1 gap-4 xl:grid-cols-3"> <div className="grid grid-cols-1 gap-4 xl:grid-cols-3">
<div className="rounded-2xl border border-white/10 bg-white/5 p-5 xl:col-span-1"> <div className="rounded-2xl border border-white/10 bg-white/5 p-5 xl:col-span-1">
<div className="text-sm font-semibold text-white">Organization</div> <div className="text-sm font-semibold text-white">{t("settings.org.title")}</div>
<div className="mt-4 space-y-3"> <div className="mt-4 space-y-3">
<div className="rounded-xl border border-white/10 bg-black/20 p-3"> <div className="rounded-xl border border-white/10 bg-black/20 p-3">
<div className="text-xs text-zinc-400">Plant Name</div> <div className="text-xs text-zinc-400">{t("settings.org.plantName")}</div>
<div className="mt-1 text-sm text-zinc-300">{orgInfo?.name || "Loading..."}</div> <div className="mt-1 text-sm text-zinc-300">{orgInfo?.name || t("common.loading")}</div>
{orgInfo?.slug ? ( {orgInfo?.slug ? (
<div className="mt-1 text-[11px] text-zinc-500">Slug: {orgInfo.slug}</div> <div className="mt-1 text-[11px] text-zinc-500">
{t("settings.org.slug")}: {orgInfo.slug}
</div>
) : null} ) : null}
</div> </div>
<label className="block rounded-xl border border-white/10 bg-black/20 p-3 text-xs text-zinc-400"> <label className="block rounded-xl border border-white/10 bg-black/20 p-3 text-xs text-zinc-400">
Time Zone {t("settings.org.timeZone")}
<input <input
value={draft.timezone || ""} value={draft.timezone || ""}
onChange={(event) => onChange={(event) =>
@@ -591,20 +610,21 @@ export default function SettingsPage() {
/> />
</label> </label>
<div className="text-xs text-zinc-500"> <div className="text-xs text-zinc-500">
Updated: {draft.updatedAt ? new Date(draft.updatedAt).toLocaleString() : "-"} {t("settings.updated")}:{" "}
{draft.updatedAt ? new Date(draft.updatedAt).toLocaleString(locale) : t("common.na")}
</div> </div>
</div> </div>
</div> </div>
<div className="rounded-2xl border border-white/10 bg-white/5 p-5 xl:col-span-2"> <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 gap-4"> <div className="mb-3 flex items-center justify-between gap-4">
<div className="text-sm font-semibold text-white">Alert Thresholds</div> <div className="text-sm font-semibold text-white">{t("settings.thresholds")}</div>
<div className="text-xs text-zinc-400">Applies to all machines</div> <div className="text-xs text-zinc-400">{t("settings.thresholds.appliesAll")}</div>
</div> </div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2"> <div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<label className="rounded-xl border border-white/10 bg-black/20 p-3 text-xs text-zinc-400"> <label className="rounded-xl border border-white/10 bg-black/20 p-3 text-xs text-zinc-400">
OEE Alert (%) {t("settings.thresholds.oee")} (%)
<input <input
type="number" type="number"
min={50} min={50}
@@ -617,7 +637,7 @@ export default function SettingsPage() {
/> />
</label> </label>
<label className="rounded-xl border border-white/10 bg-black/20 p-3 text-xs text-zinc-400"> <label className="rounded-xl border border-white/10 bg-black/20 p-3 text-xs text-zinc-400">
Stoppage Multiplier {t("settings.thresholds.stoppage")}
<input <input
type="number" type="number"
min={1.1} min={1.1}
@@ -631,7 +651,7 @@ export default function SettingsPage() {
/> />
</label> </label>
<label className="rounded-xl border border-white/10 bg-black/20 p-3 text-xs text-zinc-400"> <label className="rounded-xl border border-white/10 bg-black/20 p-3 text-xs text-zinc-400">
Performance Alert (%) {t("settings.thresholds.performance")} (%)
<input <input
type="number" type="number"
min={50} min={50}
@@ -644,7 +664,7 @@ export default function SettingsPage() {
/> />
</label> </label>
<label className="rounded-xl border border-white/10 bg-black/20 p-3 text-xs text-zinc-400"> <label className="rounded-xl border border-white/10 bg-black/20 p-3 text-xs text-zinc-400">
Quality Spike Delta (%) {t("settings.thresholds.qualitySpike")} (%)
<input <input
type="number" type="number"
min={0} min={0}
@@ -663,8 +683,8 @@ export default function SettingsPage() {
<div className="mt-6 grid grid-cols-1 gap-4 xl:grid-cols-3"> <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-2"> <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 gap-4"> <div className="mb-3 flex items-center justify-between gap-4">
<div className="text-sm font-semibold text-white">Shift Schedule</div> <div className="text-sm font-semibold text-white">{t("settings.shiftSchedule")}</div>
<div className="text-xs text-zinc-400">Max 3 shifts, HH:mm</div> <div className="text-xs text-zinc-400">{t("settings.shiftHint")}</div>
</div> </div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2"> <div className="grid grid-cols-1 gap-3 md:grid-cols-2">
@@ -682,7 +702,7 @@ export default function SettingsPage() {
disabled={draft.shiftSchedule.shifts.length <= 1} disabled={draft.shiftSchedule.shifts.length <= 1}
className="ml-3 rounded-lg border border-white/10 bg-white/5 px-2 py-1 text-xs text-white disabled:opacity-40" className="ml-3 rounded-lg border border-white/10 bg-white/5 px-2 py-1 text-xs text-white disabled:opacity-40"
> >
Remove {t("settings.shiftRemove")}
</button> </button>
</div> </div>
<div className="mt-3 flex items-center gap-2"> <div className="mt-3 flex items-center gap-2">
@@ -692,7 +712,7 @@ export default function SettingsPage() {
onChange={(event) => updateShift(index, { start: event.target.value })} onChange={(event) => updateShift(index, { start: event.target.value })}
className="w-full rounded-md border border-white/10 bg-black/30 px-2 py-1 text-sm text-white" className="w-full rounded-md border border-white/10 bg-black/30 px-2 py-1 text-sm text-white"
/> />
<span className="text-xs text-zinc-400">to</span> <span className="text-xs text-zinc-400">{t("settings.shiftTo")}</span>
<input <input
type="time" type="time"
value={shift.end} value={shift.end}
@@ -707,7 +727,7 @@ export default function SettingsPage() {
onChange={(event) => updateShift(index, { enabled: event.target.checked })} onChange={(event) => updateShift(index, { enabled: event.target.checked })}
className="h-4 w-4 rounded border border-white/20 bg-black/20" className="h-4 w-4 rounded border border-white/20 bg-black/20"
/> />
Enabled {t("settings.shiftEnabled")}
</div> </div>
</div> </div>
))} ))}
@@ -720,11 +740,11 @@ export default function SettingsPage() {
disabled={draft.shiftSchedule.shifts.length >= 3} disabled={draft.shiftSchedule.shifts.length >= 3}
className="rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-white disabled:opacity-40" className="rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-white disabled:opacity-40"
> >
Add Shift {t("settings.shiftAdd")}
</button> </button>
<div className="flex flex-1 flex-wrap gap-3"> <div className="flex flex-1 flex-wrap gap-3">
<label className="flex-1 rounded-xl border border-white/10 bg-black/20 p-3 text-xs text-zinc-400"> <label className="flex-1 rounded-xl border border-white/10 bg-black/20 p-3 text-xs text-zinc-400">
Shift Change Compensation (min) {t("settings.shiftCompLabel")}
<input <input
type="number" type="number"
min={0} min={0}
@@ -737,7 +757,7 @@ export default function SettingsPage() {
/> />
</label> </label>
<label className="flex-1 rounded-xl border border-white/10 bg-black/20 p-3 text-xs text-zinc-400"> <label className="flex-1 rounded-xl border border-white/10 bg-black/20 p-3 text-xs text-zinc-400">
Lunch Break (min) {t("settings.lunchBreakLabel")}
<input <input
type="number" type="number"
min={0} min={0}
@@ -752,29 +772,29 @@ export default function SettingsPage() {
</div> </div>
<div className="rounded-2xl border border-white/10 bg-white/5 p-5"> <div className="rounded-2xl border border-white/10 bg-white/5 p-5">
<div className="text-sm font-semibold text-white">Alerts</div> <div className="text-sm font-semibold text-white">{t("settings.alerts")}</div>
<div className="mt-4 space-y-3"> <div className="mt-4 space-y-3">
<Toggle <Toggle
label="OEE Drop" label={t("settings.alerts.oeeDrop")}
helper="Notify when OEE falls below threshold" helper={t("settings.alerts.oeeDropHelper")}
enabled={draft.alerts.oeeDropEnabled} enabled={draft.alerts.oeeDropEnabled}
onChange={(next) => updateAlerts("oeeDropEnabled", next)} onChange={(next) => updateAlerts("oeeDropEnabled", next)}
/> />
<Toggle <Toggle
label="Performance Degradation" label={t("settings.alerts.performanceDegradation")}
helper="Flag prolonged slow cycles" helper={t("settings.alerts.performanceDegradationHelper")}
enabled={draft.alerts.performanceDegradationEnabled} enabled={draft.alerts.performanceDegradationEnabled}
onChange={(next) => updateAlerts("performanceDegradationEnabled", next)} onChange={(next) => updateAlerts("performanceDegradationEnabled", next)}
/> />
<Toggle <Toggle
label="Quality Spike" label={t("settings.alerts.qualitySpike")}
helper="Alert on scrap spikes" helper={t("settings.alerts.qualitySpikeHelper")}
enabled={draft.alerts.qualitySpikeEnabled} enabled={draft.alerts.qualitySpikeEnabled}
onChange={(next) => updateAlerts("qualitySpikeEnabled", next)} onChange={(next) => updateAlerts("qualitySpikeEnabled", next)}
/> />
<Toggle <Toggle
label="Predictive OEE Decline" label={t("settings.alerts.predictive")}
helper="Warn before OEE drops" helper={t("settings.alerts.predictiveHelper")}
enabled={draft.alerts.predictiveOeeDeclineEnabled} enabled={draft.alerts.predictiveOeeDeclineEnabled}
onChange={(next) => updateAlerts("predictiveOeeDeclineEnabled", next)} onChange={(next) => updateAlerts("predictiveOeeDeclineEnabled", next)}
/> />
@@ -784,10 +804,10 @@ export default function SettingsPage() {
<div className="mt-6 grid grid-cols-1 gap-4 xl:grid-cols-2"> <div className="mt-6 grid grid-cols-1 gap-4 xl:grid-cols-2">
<div className="rounded-2xl border border-white/10 bg-white/5 p-5"> <div className="rounded-2xl border border-white/10 bg-white/5 p-5">
<div className="mb-3 text-sm font-semibold text-white">Mold Defaults</div> <div className="mb-3 text-sm font-semibold text-white">{t("settings.defaults")}</div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2"> <div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<label className="rounded-xl border border-white/10 bg-black/20 p-3 text-xs text-zinc-400"> <label className="rounded-xl border border-white/10 bg-black/20 p-3 text-xs text-zinc-400">
Mold Total {t("settings.defaults.moldTotal")}
<input <input
type="number" type="number"
min={0} min={0}
@@ -797,7 +817,7 @@ export default function SettingsPage() {
/> />
</label> </label>
<label className="rounded-xl border border-white/10 bg-black/20 p-3 text-xs text-zinc-400"> <label className="rounded-xl border border-white/10 bg-black/20 p-3 text-xs text-zinc-400">
Mold Active {t("settings.defaults.moldActive")}
<input <input
type="number" type="number"
min={0} min={0}
@@ -810,15 +830,15 @@ export default function SettingsPage() {
</div> </div>
<div className="rounded-2xl border border-white/10 bg-white/5 p-5"> <div className="rounded-2xl border border-white/10 bg-white/5 p-5">
<div className="mb-3 text-sm font-semibold text-white">Integrations</div> <div className="mb-3 text-sm font-semibold text-white">{t("settings.integrations")}</div>
<div className="space-y-3 text-sm text-zinc-300"> <div className="space-y-3 text-sm text-zinc-300">
<div className="rounded-xl border border-white/10 bg-black/20 p-3"> <div className="rounded-xl border border-white/10 bg-black/20 p-3">
<div className="text-xs text-zinc-400">Webhook URL</div> <div className="text-xs text-zinc-400">{t("settings.integrations.webhook")}</div>
<div className="mt-1 text-sm text-white">https://hooks.example.com/iiot</div> <div className="mt-1 text-sm text-white">https://hooks.example.com/iiot</div>
</div> </div>
<div className="rounded-xl border border-white/10 bg-black/20 p-3"> <div className="rounded-xl border border-white/10 bg-black/20 p-3">
<div className="text-xs text-zinc-400">ERP Sync</div> <div className="text-xs text-zinc-400">{t("settings.integrations.erp")}</div>
<div className="mt-1 text-sm text-zinc-300">Not configured</div> <div className="mt-1 text-sm text-zinc-300">{t("settings.integrations.erpNotConfigured")}</div>
</div> </div>
</div> </div>
</div> </div>
@@ -827,11 +847,11 @@ export default function SettingsPage() {
<div className="mt-6 grid grid-cols-1 gap-4 xl:grid-cols-2"> <div className="mt-6 grid grid-cols-1 gap-4 xl:grid-cols-2">
<div className="rounded-2xl border border-white/10 bg-white/5 p-5"> <div className="rounded-2xl border border-white/10 bg-white/5 p-5">
<div className="mb-3 flex items-center justify-between"> <div className="mb-3 flex items-center justify-between">
<div className="text-sm font-semibold text-white">Team Members</div> <div className="text-sm font-semibold text-white">{t("settings.team")}</div>
<div className="text-xs text-zinc-400">{members.length} total</div> <div className="text-xs text-zinc-400">{t("settings.teamTotal", { count: members.length })}</div>
</div> </div>
{teamLoading && <div className="text-sm text-zinc-400">Loading team...</div>} {teamLoading && <div className="text-sm text-zinc-400">{t("settings.loadingTeam")}</div>}
{teamError && ( {teamError && (
<div className="rounded-xl border border-red-500/20 bg-red-500/10 p-3 text-sm text-red-200"> <div className="rounded-xl border border-red-500/20 bg-red-500/10 p-3 text-sm text-red-200">
{teamError} {teamError}
@@ -839,7 +859,7 @@ export default function SettingsPage() {
)} )}
{!teamLoading && !teamError && members.length === 0 && ( {!teamLoading && !teamError && members.length === 0 && (
<div className="text-sm text-zinc-400">No team members yet.</div> <div className="text-sm text-zinc-400">{t("settings.teamNone")}</div>
)} )}
{!teamLoading && !teamError && members.length > 0 && ( {!teamLoading && !teamError && members.length > 0 && (
@@ -857,11 +877,11 @@ export default function SettingsPage() {
</div> </div>
<div className="flex flex-col items-end gap-1 text-xs text-zinc-400"> <div className="flex flex-col items-end gap-1 text-xs text-zinc-400">
<span className="rounded-full border border-white/10 bg-white/5 px-2 py-0.5 text-white"> <span className="rounded-full border border-white/10 bg-white/5 px-2 py-0.5 text-white">
{member.role} {formatRole(member.role)}
</span> </span>
{!member.isActive ? ( {!member.isActive ? (
<span className="rounded-full border border-red-500/30 bg-red-500/10 px-2 py-0.5 text-red-200"> <span className="rounded-full border border-red-500/30 bg-red-500/10 px-2 py-0.5 text-red-200">
Inactive {t("settings.role.inactive")}
</span> </span>
) : null} ) : null}
</div> </div>
@@ -872,10 +892,10 @@ export default function SettingsPage() {
</div> </div>
<div className="rounded-2xl border border-white/10 bg-white/5 p-5"> <div className="rounded-2xl border border-white/10 bg-white/5 p-5">
<div className="mb-3 text-sm font-semibold text-white">Invitations</div> <div className="mb-3 text-sm font-semibold text-white">{t("settings.invites")}</div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2"> <div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<label className="rounded-xl border border-white/10 bg-black/20 p-3 text-xs text-zinc-400"> <label className="rounded-xl border border-white/10 bg-black/20 p-3 text-xs text-zinc-400">
Invite Email {t("settings.inviteEmail")}
<input <input
value={inviteEmail} value={inviteEmail}
onChange={(event) => setInviteEmail(event.target.value)} onChange={(event) => setInviteEmail(event.target.value)}
@@ -883,15 +903,15 @@ export default function SettingsPage() {
/> />
</label> </label>
<label className="rounded-xl border border-white/10 bg-black/20 p-3 text-xs text-zinc-400"> <label className="rounded-xl border border-white/10 bg-black/20 p-3 text-xs text-zinc-400">
Role {t("settings.inviteRole")}
<select <select
value={inviteRole} value={inviteRole}
onChange={(event) => setInviteRole(event.target.value)} onChange={(event) => setInviteRole(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" className="mt-2 w-full rounded-lg border border-white/10 bg-black/30 px-3 py-2 text-sm text-white"
> >
<option value="MEMBER">Member</option> <option value="MEMBER">{t("settings.inviteRole.member")}</option>
<option value="ADMIN">Admin</option> <option value="ADMIN">{t("settings.inviteRole.admin")}</option>
<option value="OWNER">Owner</option> <option value="OWNER">{t("settings.inviteRole.owner")}</option>
</select> </select>
</label> </label>
</div> </div>
@@ -903,21 +923,21 @@ export default function SettingsPage() {
disabled={inviteSubmitting} disabled={inviteSubmitting}
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" 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"
> >
{inviteSubmitting ? "Creating..." : "Create Invite"} {inviteSubmitting ? t("settings.inviteSending") : t("settings.inviteSend")}
</button> </button>
<button <button
type="button" type="button"
onClick={loadTeam} onClick={loadTeam}
className="rounded-xl border border-white/10 bg-white/5 px-3 py-2 text-sm text-white hover:bg-white/10" className="rounded-xl border border-white/10 bg-white/5 px-3 py-2 text-sm text-white hover:bg-white/10"
> >
Refresh {t("settings.refresh")}
</button> </button>
{inviteStatus && <div className="text-xs text-zinc-400">{inviteStatus}</div>} {inviteStatus && <div className="text-xs text-zinc-400">{inviteStatus}</div>}
</div> </div>
<div className="mt-4 space-y-3"> <div className="mt-4 space-y-3">
{invites.length === 0 && ( {invites.length === 0 && (
<div className="text-sm text-zinc-400">No pending invites.</div> <div className="text-sm text-zinc-400">{t("settings.inviteNone")}</div>
)} )}
{invites.map((invite) => ( {invites.map((invite) => (
<div key={invite.id} className="rounded-xl border border-white/10 bg-black/20 p-3"> <div key={invite.id} className="rounded-xl border border-white/10 bg-black/20 p-3">
@@ -925,7 +945,10 @@ export default function SettingsPage() {
<div className="min-w-0"> <div className="min-w-0">
<div className="truncate text-sm font-semibold text-white">{invite.email}</div> <div className="truncate text-sm font-semibold text-white">{invite.email}</div>
<div className="text-xs text-zinc-400"> <div className="text-xs text-zinc-400">
{invite.role} - Expires {new Date(invite.expiresAt).toLocaleDateString()} {formatRole(invite.role)} -{" "}
{t("settings.inviteExpires", {
date: new Date(invite.expiresAt).toLocaleDateString(locale),
})}
</div> </div>
</div> </div>
<div className="flex shrink-0 items-center gap-2"> <div className="flex shrink-0 items-center gap-2">
@@ -934,14 +957,14 @@ export default function SettingsPage() {
onClick={() => copyInviteLink(invite.token)} onClick={() => copyInviteLink(invite.token)}
className="rounded-lg border border-white/10 bg-white/5 px-2 py-1 text-xs text-white hover:bg-white/10" className="rounded-lg border border-white/10 bg-white/5 px-2 py-1 text-xs text-white hover:bg-white/10"
> >
Copy Link {t("settings.inviteCopy")}
</button> </button>
<button <button
type="button" type="button"
onClick={() => revokeInvite(invite.id)} onClick={() => revokeInvite(invite.id)}
className="rounded-lg border border-red-500/30 bg-red-500/10 px-2 py-1 text-xs text-red-200 hover:bg-red-500/20" className="rounded-lg border border-red-500/30 bg-red-500/10 px-2 py-1 text-xs text-red-200 hover:bg-red-500/20"
> >
Revoke {t("settings.inviteRevoke")}
</button> </button>
</div> </div>
</div> </div>

View File

@@ -1,31 +1,166 @@
@import "tailwindcss"; @import "tailwindcss";
:root { :root {
--background: #ffffff; color-scheme: dark;
--foreground: #171717; --app-bg: #0b0f14;
--app-surface: rgba(255, 255, 255, 0.05);
--app-surface-2: rgba(255, 255, 255, 0.08);
--app-surface-3: rgba(0, 0, 0, 0.28);
--app-surface-4: rgba(0, 0, 0, 0.42);
--app-border: rgba(148, 163, 184, 0.18);
--app-text: #e5e7eb;
--app-text-strong: #f8fafc;
--app-text-muted: #94a3b8;
--app-text-subtle: #6b7280;
--app-text-faint: #475569;
--app-text-on-accent: #0b0f14;
--app-good-text: #6ee7b7;
--app-good-bg: rgba(34, 197, 94, 0.18);
--app-good-border: rgba(34, 197, 94, 0.28);
--app-good-solid: #34d399;
--app-warn-text: #facc15;
--app-warn-bg: rgba(250, 204, 21, 0.18);
--app-warn-border: rgba(250, 204, 21, 0.32);
--app-bad-text: #f87171;
--app-bad-bg: rgba(248, 113, 113, 0.18);
--app-bad-border: rgba(248, 113, 113, 0.32);
--app-info-text: #7ab8ff;
--app-info-bg: rgba(59, 130, 246, 0.18);
--app-info-border: rgba(59, 130, 246, 0.3);
--app-overlay: rgba(3, 6, 12, 0.65);
--app-modal-bg: rgba(9, 13, 19, 0.92);
--app-chart-grid: rgba(148, 163, 184, 0.2);
--app-chart-tick: #9ca3af;
--app-chart-tooltip-bg: rgba(2, 6, 23, 0.88);
--app-chart-tooltip-border: rgba(148, 163, 184, 0.25);
--app-chart-label: #f8fafc;
--app-chart-shadow: 0 0 30px rgba(2, 6, 23, 0.6);
} }
@theme inline { @theme inline {
--color-background: var(--background); --color-background: var(--app-bg);
--color-foreground: var(--foreground); --color-foreground: var(--app-text);
--font-sans: var(--font-geist-sans); --font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono); --font-mono: var(--font-geist-mono);
} }
@media (prefers-color-scheme: dark) { :root[data-theme="light"] {
:root { color-scheme: light;
--background: #0a0a0a; --app-bg: #f4f6f9;
--foreground: #ededed; --app-surface: #ffffff;
} --app-surface-2: #eef2f6;
--app-surface-3: #e7ecf2;
--app-surface-4: #dde3ea;
--app-border: rgba(15, 23, 42, 0.12);
--app-text: #1f2937;
--app-text-strong: #0f172a;
--app-text-muted: #4b5563;
--app-text-subtle: #6b7280;
--app-text-faint: #8b95a3;
--app-text-on-accent: #0f172a;
--app-good-text: #0f7a3e;
--app-good-bg: rgba(34, 197, 94, 0.16);
--app-good-border: rgba(34, 197, 94, 0.3);
--app-good-solid: #22c55e;
--app-warn-text: #a16207;
--app-warn-bg: rgba(234, 179, 8, 0.18);
--app-warn-border: rgba(234, 179, 8, 0.36);
--app-bad-text: #b91c1c;
--app-bad-bg: rgba(239, 68, 68, 0.16);
--app-bad-border: rgba(239, 68, 68, 0.34);
--app-info-text: #1d4ed8;
--app-info-bg: rgba(59, 130, 246, 0.16);
--app-info-border: rgba(59, 130, 246, 0.3);
--app-overlay: rgba(15, 23, 42, 0.45);
--app-modal-bg: rgba(255, 255, 255, 0.92);
--app-chart-grid: rgba(15, 23, 42, 0.12);
--app-chart-tick: #6b7280;
--app-chart-tooltip-bg: #ffffff;
--app-chart-tooltip-border: rgba(15, 23, 42, 0.16);
--app-chart-label: #0f172a;
--app-chart-shadow: 0 0 24px rgba(15, 23, 42, 0.12);
} }
body { body {
background: var(--background); background: var(--app-bg);
color: var(--foreground); color: var(--app-text);
font-family: Arial, Helvetica, sans-serif; font-family: var(--font-geist-sans), "Segoe UI", system-ui, sans-serif;
} }
/* Hide scrollbar but keep scrolling */ /* Hide scrollbar but keep scrolling */
.no-scrollbar::-webkit-scrollbar { display: none; } .no-scrollbar::-webkit-scrollbar { display: none; }
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; } .no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
/* Theme-friendly overrides for common utility classes */
.text-white { color: var(--app-text-strong) !important; }
.text-black { color: var(--app-text-on-accent) !important; }
.text-zinc-200 { color: var(--app-text) !important; }
.text-zinc-300 { color: var(--app-text-muted) !important; }
.text-zinc-400 { color: var(--app-text-subtle) !important; }
.text-zinc-500 { color: var(--app-text-faint) !important; }
.text-emerald-100,
.text-emerald-200,
.text-emerald-300 { color: var(--app-good-text) !important; }
.text-yellow-300 { color: var(--app-warn-text) !important; }
.text-red-200,
.text-red-300,
.text-red-400 { color: var(--app-bad-text) !important; }
.text-blue-300 { color: var(--app-info-text) !important; }
.text-orange-300 { color: var(--app-warn-text) !important; }
.text-rose-300 { color: var(--app-bad-text) !important; }
.bg-black { background-color: var(--app-bg) !important; }
.bg-black\/20 { background-color: var(--app-surface-2) !important; }
.bg-black\/25 { background-color: var(--app-surface-3) !important; }
.bg-black\/30 { background-color: var(--app-surface-3) !important; }
.bg-black\/40 { background-color: var(--app-surface-4) !important; }
.bg-black\/70 { background-color: var(--app-overlay) !important; }
.bg-zinc-950\/80,
.bg-zinc-950\/95 { background-color: var(--app-modal-bg) !important; }
.bg-white\/5 { background-color: var(--app-surface) !important; }
.bg-white\/10 { background-color: var(--app-surface-2) !important; }
.border-white\/10,
.border-white\/5 { border-color: var(--app-border) !important; }
.border-emerald-500\/20,
.border-emerald-400\/40,
.border-emerald-500\/30 { border-color: var(--app-good-border) !important; }
.border-red-500\/20,
.border-red-500\/30 { border-color: var(--app-bad-border) !important; }
.border-yellow-500\/20,
.border-orange-500\/20 { border-color: var(--app-warn-border) !important; }
.border-rose-500\/20 { border-color: var(--app-bad-border) !important; }
.border-blue-500\/20 { border-color: var(--app-info-border) !important; }
.bg-emerald-500\/10,
.bg-emerald-500\/15,
.bg-emerald-500\/20,
.bg-emerald-500\/30 { background-color: var(--app-good-bg) !important; }
.bg-emerald-400 { background-color: var(--app-good-solid) !important; }
.bg-yellow-500\/15 { background-color: var(--app-warn-bg) !important; }
.bg-red-500\/10,
.bg-red-500\/15,
.bg-red-500\/20 { background-color: var(--app-bad-bg) !important; }
.bg-blue-500\/15 { background-color: var(--app-info-bg) !important; }
.bg-orange-500\/15 { background-color: var(--app-warn-bg) !important; }
.bg-rose-500\/15 { background-color: var(--app-bad-bg) !important; }
.placeholder\:text-zinc-500::placeholder { color: var(--app-text-faint) !important; }
.hover\:bg-white\/5:hover { background-color: var(--app-surface) !important; }
.hover\:bg-white\/10:hover { background-color: var(--app-surface-2) !important; }
.hover\:bg-emerald-500\/30:hover { background-color: var(--app-good-bg) !important; }
.hover\:bg-red-500\/20:hover { background-color: var(--app-bad-bg) !important; }
.hover\:text-white:hover { color: var(--app-text-strong) !important; }

View File

@@ -2,6 +2,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useI18n } from "@/lib/i18n/useI18n";
type InviteInfo = { type InviteInfo = {
email: string; email: string;
@@ -22,6 +23,7 @@ export default function InviteAcceptForm({
initialError = null, initialError = null,
}: InviteAcceptFormProps) { }: InviteAcceptFormProps) {
const router = useRouter(); const router = useRouter();
const { t } = useI18n();
const cleanedToken = token.trim(); const cleanedToken = token.trim();
const [invite, setInvite] = useState<InviteInfo | null>(initialInvite); const [invite, setInvite] = useState<InviteInfo | null>(initialInvite);
const [loading, setLoading] = useState(!initialInvite && !initialError); const [loading, setLoading] = useState(!initialInvite && !initialError);
@@ -46,11 +48,11 @@ export default function InviteAcceptForm({
}); });
const data = await res.json().catch(() => ({})); const data = await res.json().catch(() => ({}));
if (!res.ok || !data.ok) { if (!res.ok || !data.ok) {
throw new Error(data.error || "Invite not found"); throw new Error(data.error || t("invite.error.notFound"));
} }
if (alive) setInvite(data.invite); if (alive) setInvite(data.invite);
} catch (err: any) { } catch (err: any) {
if (alive) setError(err?.message || "Invite not found"); if (alive) setError(err?.message || t("invite.error.notFound"));
} finally { } finally {
if (alive) setLoading(false); if (alive) setLoading(false);
} }
@@ -74,12 +76,12 @@ export default function InviteAcceptForm({
}); });
const data = await res.json().catch(() => ({})); const data = await res.json().catch(() => ({}));
if (!res.ok || !data.ok) { if (!res.ok || !data.ok) {
throw new Error(data.error || "Invite acceptance failed"); throw new Error(data.error || t("invite.error.acceptFailed"));
} }
router.push("/machines"); router.push("/machines");
router.refresh(); router.refresh();
} catch (err: any) { } catch (err: any) {
setError(err?.message || "Invite acceptance failed"); setError(err?.message || t("invite.error.acceptFailed"));
} finally { } finally {
setSubmitting(false); setSubmitting(false);
} }
@@ -88,7 +90,7 @@ export default function InviteAcceptForm({
if (loading) { if (loading) {
return ( return (
<div className="min-h-screen bg-black flex items-center justify-center p-6 text-zinc-300"> <div className="min-h-screen bg-black flex items-center justify-center p-6 text-zinc-300">
Loading invite... {t("invite.loading")}
</div> </div>
); );
} }
@@ -97,7 +99,7 @@ export default function InviteAcceptForm({
return ( return (
<div className="min-h-screen bg-black flex items-center justify-center p-6"> <div className="min-h-screen bg-black flex items-center justify-center p-6">
<div className="max-w-md rounded-2xl border border-red-500/30 bg-red-500/10 p-6 text-sm text-red-200"> <div className="max-w-md rounded-2xl border border-red-500/30 bg-red-500/10 p-6 text-sm text-red-200">
{error || "Invite not found."} {error || t("invite.notFound")}
</div> </div>
</div> </div>
); );
@@ -106,14 +108,16 @@ export default function InviteAcceptForm({
return ( return (
<div className="min-h-screen bg-black flex items-center justify-center p-6"> <div className="min-h-screen bg-black flex items-center justify-center p-6">
<form onSubmit={onSubmit} className="w-full max-w-lg rounded-2xl border border-white/10 bg-white/5 p-8"> <form onSubmit={onSubmit} className="w-full max-w-lg rounded-2xl border border-white/10 bg-white/5 p-8">
<h1 className="text-2xl font-semibold text-white">Join {invite.org.name}</h1> <h1 className="text-2xl font-semibold text-white">
{t("invite.joinTitle", { org: invite.org.name })}
</h1>
<p className="mt-1 text-sm text-zinc-400"> <p className="mt-1 text-sm text-zinc-400">
Accept the invite for {invite.email} as {invite.role}. {t("invite.acceptCopy", { email: invite.email, role: invite.role })}
</p> </p>
<div className="mt-6 space-y-4"> <div className="mt-6 space-y-4">
<div> <div>
<label className="text-sm text-zinc-300">Your name</label> <label className="text-sm text-zinc-300">{t("invite.yourName")}</label>
<input <input
className="mt-1 w-full rounded-xl border border-white/10 bg-black/40 px-4 py-3 text-white outline-none" className="mt-1 w-full rounded-xl border border-white/10 bg-black/40 px-4 py-3 text-white outline-none"
value={name} value={name}
@@ -123,7 +127,7 @@ export default function InviteAcceptForm({
</div> </div>
<div> <div>
<label className="text-sm text-zinc-300">Password</label> <label className="text-sm text-zinc-300">{t("invite.password")}</label>
<input <input
type="password" type="password"
className="mt-1 w-full rounded-xl border border-white/10 bg-black/40 px-4 py-3 text-white outline-none" className="mt-1 w-full rounded-xl border border-white/10 bg-black/40 px-4 py-3 text-white outline-none"
@@ -140,7 +144,7 @@ export default function InviteAcceptForm({
disabled={submitting} disabled={submitting}
className="mt-2 w-full rounded-xl bg-emerald-400 py-3 font-semibold text-black disabled:opacity-70" className="mt-2 w-full rounded-xl bg-emerald-400 py-3 font-semibold text-black disabled:opacity-70"
> >
{submitting ? "Joining..." : "Join organization"} {submitting ? t("invite.submit.loading") : t("invite.submit.default")}
</button> </button>
</div> </div>
</form> </form>

View File

@@ -1,5 +1,6 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google"; import { Geist, Geist_Mono } from "next/font/google";
import { cookies } from "next/headers";
import "./globals.css"; import "./globals.css";
const geistSans = Geist({ variable: "--font-geist-sans", subsets: ["latin"] }); const geistSans = Geist({ variable: "--font-geist-sans", subsets: ["latin"] });
@@ -10,9 +11,15 @@ export const metadata: Metadata = {
description: "MaliounTech Industrial Suite", description: "MaliounTech Industrial Suite",
}; };
export default function RootLayout({ children }: { children: React.ReactNode }) { export default async function RootLayout({ children }: { children: React.ReactNode }) {
const cookieJar = await cookies();
const themeCookie = cookieJar.get("mis_theme")?.value;
const localeCookie = cookieJar.get("mis_locale")?.value;
const theme = themeCookie === "light" ? "light" : "dark";
const locale = localeCookie === "es-MX" ? "es-MX" : "en";
return ( return (
<html lang="en"> <html lang={locale} data-theme={theme}>
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}> <body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
{children} {children}
</body> </body>

View File

@@ -2,11 +2,13 @@
import { useSearchParams, useRouter } from "next/navigation"; import { useSearchParams, useRouter } from "next/navigation";
import { useState } from "react"; import { useState } from "react";
import { useI18n } from "@/lib/i18n/useI18n";
export default function LoginForm() { export default function LoginForm() {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const next = searchParams.get("next") || "/machines"; const next = searchParams.get("next") || "/machines";
const { t } = useI18n();
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
@@ -27,14 +29,14 @@ export default function LoginForm() {
const data = await res.json().catch(() => ({})); const data = await res.json().catch(() => ({}));
if (!res.ok || !data.ok) { if (!res.ok || !data.ok) {
setErr(data.error || "Login failed"); setErr(data.error || t("login.error.default"));
return; return;
} }
router.push(next); router.push(next);
router.refresh(); router.refresh();
} catch (e: any) { } catch (e: any) {
setErr(e?.message || "Network error"); setErr(e?.message || t("login.error.network"));
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -43,12 +45,12 @@ export default function LoginForm() {
return ( return (
<div className="min-h-screen bg-black flex items-center justify-center p-6"> <div className="min-h-screen bg-black flex items-center justify-center p-6">
<form onSubmit={onSubmit} className="w-full max-w-md rounded-2xl border border-white/10 bg-white/5 p-8"> <form onSubmit={onSubmit} className="w-full max-w-md rounded-2xl border border-white/10 bg-white/5 p-8">
<h1 className="text-2xl font-semibold text-white">Control Tower</h1> <h1 className="text-2xl font-semibold text-white">{t("login.title")}</h1>
<p className="mt-1 text-sm text-zinc-400">Sign in to your organization</p> <p className="mt-1 text-sm text-zinc-400">{t("login.subtitle")}</p>
<div className="mt-6 space-y-4"> <div className="mt-6 space-y-4">
<div> <div>
<label className="text-sm text-zinc-300">Email</label> <label className="text-sm text-zinc-300">{t("login.email")}</label>
<input <input
className="mt-1 w-full rounded-xl border border-white/10 bg-black/40 px-4 py-3 text-white outline-none" className="mt-1 w-full rounded-xl border border-white/10 bg-black/40 px-4 py-3 text-white outline-none"
value={email} value={email}
@@ -58,7 +60,7 @@ export default function LoginForm() {
</div> </div>
<div> <div>
<label className="text-sm text-zinc-300">Password</label> <label className="text-sm text-zinc-300">{t("login.password")}</label>
<input <input
type="password" type="password"
className="mt-1 w-full rounded-xl border border-white/10 bg-black/40 px-4 py-3 text-white outline-none" className="mt-1 w-full rounded-xl border border-white/10 bg-black/40 px-4 py-3 text-white outline-none"
@@ -75,13 +77,13 @@ export default function LoginForm() {
disabled={loading} disabled={loading}
className="mt-2 w-full rounded-xl bg-emerald-400 py-3 font-semibold text-black disabled:opacity-70" className="mt-2 w-full rounded-xl bg-emerald-400 py-3 font-semibold text-black disabled:opacity-70"
> >
{loading ? "Signing in..." : "Login"} {loading ? t("login.submit.loading") : t("login.submit.default")}
</button> </button>
<div className="text-xs text-zinc-500"> <div className="text-xs text-zinc-500">
New here?{" "} {t("login.newHere")}{" "}
<a href="/signup" className="text-emerald-300 hover:text-emerald-200"> <a href="/signup" className="text-emerald-300 hover:text-emerald-200">
Create an account {t("login.createAccount")}
</a> </a>
</div> </div>
</div> </div>

View File

@@ -1,8 +1,10 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { useI18n } from "@/lib/i18n/useI18n";
export default function SignupForm() { export default function SignupForm() {
const { t } = useI18n();
const [orgName, setOrgName] = useState(""); const [orgName, setOrgName] = useState("");
const [name, setName] = useState(""); const [name, setName] = useState("");
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
@@ -26,14 +28,14 @@ export default function SignupForm() {
const data = await res.json().catch(() => ({})); const data = await res.json().catch(() => ({}));
if (!res.ok || !data.ok) { if (!res.ok || !data.ok) {
setErr(data.error || "Signup failed"); setErr(data.error || t("signup.error.default"));
return; return;
} }
setVerificationSent(true); setVerificationSent(true);
setEmailSent(data.emailSent !== false); setEmailSent(data.emailSent !== false);
} catch (e: any) { } catch (e: any) {
setErr(e?.message || "Network error"); setErr(e?.message || t("signup.error.network"));
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -43,24 +45,22 @@ export default function SignupForm() {
return ( return (
<div className="min-h-screen bg-black flex items-center justify-center p-6"> <div className="min-h-screen bg-black flex items-center justify-center p-6">
<div className="w-full max-w-lg rounded-2xl border border-white/10 bg-white/5 p-8"> <div className="w-full max-w-lg rounded-2xl border border-white/10 bg-white/5 p-8">
<h1 className="text-2xl font-semibold text-white">Verify your email</h1> <h1 className="text-2xl font-semibold text-white">{t("signup.verify.title")}</h1>
<p className="mt-2 text-sm text-zinc-300"> <p className="mt-2 text-sm text-zinc-300">
We sent a verification link to <span className="text-white">{email}</span>. {t("signup.verify.sent", { email: email || t("common.na") })}
</p> </p>
{!emailSent && ( {!emailSent && (
<div className="mt-3 rounded-xl border border-red-500/30 bg-red-500/10 p-3 text-xs text-red-200"> <div className="mt-3 rounded-xl border border-red-500/30 bg-red-500/10 p-3 text-xs text-red-200">
Verification email failed to send. Please contact support. {t("signup.verify.failed")}
</div> </div>
)} )}
<div className="mt-4 text-xs text-zinc-500"> <div className="mt-4 text-xs text-zinc-500">{t("signup.verify.notice")}</div>
Once verified, you can sign in and invite your team.
</div>
<div className="mt-6"> <div className="mt-6">
<a <a
href="/login" href="/login"
className="inline-flex rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-white hover:bg-white/10" className="inline-flex rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-white hover:bg-white/10"
> >
Back to login {t("signup.verify.back")}
</a> </a>
</div> </div>
</div> </div>
@@ -71,14 +71,12 @@ export default function SignupForm() {
return ( return (
<div className="min-h-screen bg-black flex items-center justify-center p-6"> <div className="min-h-screen bg-black flex items-center justify-center p-6">
<form onSubmit={onSubmit} className="w-full max-w-lg rounded-2xl border border-white/10 bg-white/5 p-8"> <form onSubmit={onSubmit} className="w-full max-w-lg rounded-2xl border border-white/10 bg-white/5 p-8">
<h1 className="text-2xl font-semibold text-white">Create your Control Tower</h1> <h1 className="text-2xl font-semibold text-white">{t("signup.title")}</h1>
<p className="mt-1 text-sm text-zinc-400"> <p className="mt-1 text-sm text-zinc-400">{t("signup.subtitle")}</p>
Set up your organization and invite the team.
</p>
<div className="mt-6 space-y-4"> <div className="mt-6 space-y-4">
<div> <div>
<label className="text-sm text-zinc-300">Organization name</label> <label className="text-sm text-zinc-300">{t("signup.orgName")}</label>
<input <input
className="mt-1 w-full rounded-xl border border-white/10 bg-black/40 px-4 py-3 text-white outline-none" className="mt-1 w-full rounded-xl border border-white/10 bg-black/40 px-4 py-3 text-white outline-none"
value={orgName} value={orgName}
@@ -89,7 +87,7 @@ export default function SignupForm() {
<div className="grid grid-cols-1 gap-4 md:grid-cols-2"> <div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div> <div>
<label className="text-sm text-zinc-300">Your name</label> <label className="text-sm text-zinc-300">{t("signup.yourName")}</label>
<input <input
className="mt-1 w-full rounded-xl border border-white/10 bg-black/40 px-4 py-3 text-white outline-none" className="mt-1 w-full rounded-xl border border-white/10 bg-black/40 px-4 py-3 text-white outline-none"
value={name} value={name}
@@ -98,7 +96,7 @@ export default function SignupForm() {
/> />
</div> </div>
<div> <div>
<label className="text-sm text-zinc-300">Email</label> <label className="text-sm text-zinc-300">{t("signup.email")}</label>
<input <input
className="mt-1 w-full rounded-xl border border-white/10 bg-black/40 px-4 py-3 text-white outline-none" className="mt-1 w-full rounded-xl border border-white/10 bg-black/40 px-4 py-3 text-white outline-none"
value={email} value={email}
@@ -109,7 +107,7 @@ export default function SignupForm() {
</div> </div>
<div> <div>
<label className="text-sm text-zinc-300">Password</label> <label className="text-sm text-zinc-300">{t("signup.password")}</label>
<input <input
type="password" type="password"
className="mt-1 w-full rounded-xl border border-white/10 bg-black/40 px-4 py-3 text-white outline-none" className="mt-1 w-full rounded-xl border border-white/10 bg-black/40 px-4 py-3 text-white outline-none"
@@ -126,13 +124,13 @@ export default function SignupForm() {
disabled={loading} disabled={loading}
className="mt-2 w-full rounded-xl bg-emerald-400 py-3 font-semibold text-black disabled:opacity-70" className="mt-2 w-full rounded-xl bg-emerald-400 py-3 font-semibold text-black disabled:opacity-70"
> >
{loading ? "Creating account..." : "Create account"} {loading ? t("signup.submit.loading") : t("signup.submit.default")}
</button> </button>
<div className="text-xs text-zinc-500"> <div className="text-xs text-zinc-500">
Already have access?{" "} {t("signup.alreadyHave")}{" "}
<a href="/login" className="text-emerald-300 hover:text-emerald-200"> <a href="/login" className="text-emerald-300 hover:text-emerald-200">
Sign in {t("signup.signIn")}
</a> </a>
</div> </div>
</div> </div>

View File

@@ -19,7 +19,7 @@ export function RequireAuth({ children }: { children: React.ReactNode }) {
if (!ready) { if (!ready) {
return ( return (
<div className="min-h-screen bg-[#070A0C] text-zinc-200 flex items-center justify-center"> <div className="min-h-screen bg-black text-zinc-200 flex items-center justify-center">
Loading Loading
</div> </div>
); );

View File

@@ -3,17 +3,61 @@
import Link from "next/link"; import Link from "next/link";
import { usePathname, useRouter } from "next/navigation"; import { usePathname, useRouter } from "next/navigation";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { BarChart3, LayoutGrid, LogOut, Settings, Wrench } from "lucide-react";
import { useI18n } from "@/lib/i18n/useI18n";
const THEME_COOKIE = "mis_theme";
const SunIcon = ({ className }: { className?: string }) => (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2" />
<path d="M12 20v2" />
<path d="M4.93 4.93l1.41 1.41" />
<path d="M17.66 17.66l1.41 1.41" />
<path d="M2 12h2" />
<path d="M20 12h2" />
<path d="M4.93 19.07l1.41-1.41" />
<path d="M17.66 6.34l1.41-1.41" />
</svg>
);
const MoonIcon = ({ className }: { className?: string }) => (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M21 12.5A8.5 8.5 0 0 1 11.5 3a8.5 8.5 0 1 0 9.5 9.5z" />
</svg>
);
const items = [ const items = [
{ href: "/overview", label: "Overview", icon: "🏠" }, { href: "/overview", labelKey: "nav.overview", icon: LayoutGrid },
{ href: "/machines", label: "Machines", icon: "🏭" }, { href: "/machines", labelKey: "nav.machines", icon: Wrench },
{ href: "/reports", label: "Reports", icon: "📊" }, { href: "/reports", labelKey: "nav.reports", icon: BarChart3 },
{ href: "/settings", label: "Settings", icon: "⚙️" }, { href: "/settings", labelKey: "nav.settings", icon: Settings },
]; ] as const;
export function Sidebar() { export function Sidebar() {
const pathname = usePathname(); const pathname = usePathname();
const router = useRouter(); const router = useRouter();
const { locale, setLocale, t } = useI18n();
const [theme, setTheme] = useState<"dark" | "light">("dark");
const [me, setMe] = useState<{ const [me, setMe] = useState<{
user?: { name?: string | null; email?: string | null }; user?: { name?: string | null; email?: string | null };
org?: { name?: string | null }; org?: { name?: string | null };
@@ -39,22 +83,42 @@ export function Sidebar() {
}; };
}, []); }, []);
useEffect(() => {
const current = document.documentElement.getAttribute("data-theme");
if (current === "light" || current === "dark") {
setTheme(current);
}
}, []);
function applyTheme(next: "light" | "dark") {
document.documentElement.setAttribute("data-theme", next);
document.cookie = `${THEME_COOKIE}=${next}; Path=/; Max-Age=31536000; SameSite=Lax`;
setTheme(next);
}
function toggleTheme() {
applyTheme(theme === "light" ? "dark" : "light");
}
async function onLogout() { async function onLogout() {
await fetch("/api/logout", { method: "POST" }); await fetch("/api/logout", { method: "POST" });
router.push("/login"); router.push("/login");
router.refresh(); router.refresh();
} }
const roleKey = (me?.membership?.role || "MEMBER").toLowerCase();
return ( return (
<aside className="hidden md:flex h-screen w-64 flex-col border-r border-white/10 bg-black/40"> <aside className="relative z-20 hidden md:flex h-screen w-64 flex-col border-r border-white/10 bg-black/40">
<div className="px-5 py-4"> <div className="px-5 py-4">
<div className="text-white font-semibold tracking-wide">MIS</div> <div className="text-white font-semibold tracking-wide">{t("sidebar.productTitle")}</div>
<div className="text-xs text-zinc-500">Control Tower</div> <div className="text-xs text-zinc-500">{t("sidebar.productSubtitle")}</div>
</div> </div>
<nav className="px-3 py-2 flex-1 space-y-1"> <nav className="px-3 py-2 flex-1 space-y-1">
{items.map((it) => { {items.map((it) => {
const active = pathname === it.href || pathname.startsWith(it.href + "/"); const active = pathname === it.href || pathname.startsWith(it.href + "/");
const Icon = it.icon;
return ( return (
<Link <Link
key={it.href} key={it.href}
@@ -66,8 +130,8 @@ export function Sidebar() {
: "text-zinc-300 hover:bg-white/5 hover:text-white", : "text-zinc-300 hover:bg-white/5 hover:text-white",
].join(" ")} ].join(" ")}
> >
<span className="text-lg">{it.icon}</span> <Icon className="h-4 w-4" />
<span>{it.label}</span> <span>{t(it.labelKey)}</span>
</Link> </Link>
); );
})} })}
@@ -75,9 +139,52 @@ export function Sidebar() {
<div className="px-5 py-4 border-t border-white/10 space-y-3"> <div className="px-5 py-4 border-t border-white/10 space-y-3">
<div> <div>
<div className="text-sm text-white">{me?.user?.name || me?.user?.email || "User"}</div> <div className="text-sm text-white">
{me?.user?.name || me?.user?.email || t("sidebar.userFallback")}
</div>
<div className="text-xs text-zinc-500"> <div className="text-xs text-zinc-500">
{me?.org?.name ? `${me.org.name} - ${me?.membership?.role || "MEMBER"}` : "Loading..."} {me?.org?.name
? `${me.org.name} - ${t(`sidebar.role.${roleKey}`)}`
: t("sidebar.loadingOrg")}
</div>
</div>
<div
className="pointer-events-auto flex items-center justify-between gap-3 rounded-xl border border-white/10 bg-white/5 px-3 py-2"
title={t("sidebar.themeTooltip")}
>
<button
type="button"
onClick={toggleTheme}
aria-label={theme === "light" ? t("sidebar.switchToDark") : t("sidebar.switchToLight")}
className="flex h-8 w-8 items-center justify-center rounded-full border border-white/10 bg-black/30 text-white hover:bg-white/10 transition"
>
{theme === "light" ? <SunIcon className="h-4 w-4" /> : <MoonIcon className="h-4 w-4" />}
</button>
<div className="flex items-center gap-2 text-[11px] font-semibold tracking-[0.2em]">
<button
type="button"
onClick={() => {
setLocale("en");
router.refresh();
}}
aria-pressed={locale === "en"}
className={locale === "en" ? "text-white" : "text-zinc-400 hover:text-white"}
>
EN
</button>
<span className="text-zinc-500">|</span>
<button
type="button"
onClick={() => {
setLocale("es-MX");
router.refresh();
}}
aria-pressed={locale === "es-MX"}
className={locale === "es-MX" ? "text-white" : "text-zinc-400 hover:text-white"}
>
ES
</button>
</div> </div>
</div> </div>
@@ -85,7 +192,10 @@ export function Sidebar() {
onClick={onLogout} onClick={onLogout}
className="w-full rounded-xl border border-white/10 bg-white/5 px-3 py-2 text-sm text-zinc-200 hover:bg-white/10" className="w-full rounded-xl border border-white/10 bg-white/5 px-3 py-2 text-sm text-zinc-200 hover:bg-white/10"
> >
🚪 Logout <span className="flex items-center justify-center gap-2">
<LogOut className="h-4 w-4" />
{t("sidebar.logout")}
</span>
</button> </button>
</div> </div>
</aside> </aside>

378
dictionary_en_es.md Normal file
View File

@@ -0,0 +1,378 @@
# EN/ES Dictionary
This dictionary captures UI copy for Control Tower. EN is the source; ES-MX is the translation.
Main KPIs remain English in ES-MX (OEE, KPI, SKU, AVAILABILITY, PERFORMANCE, QUALITY).
## Common
| Key | EN | ES-MX |
| --- | --- | --- |
| common.loading | Loading... | Cargando... |
| common.loadingShort | Loading | Cargando |
| common.never | never | nunca |
| common.na | -- | -- |
| common.back | Back | Volver |
| common.cancel | Cancel | Cancelar |
| common.close | Close | Cerrar |
| common.save | Save | Guardar |
| common.copy | Copy | Copiar |
## Navigation
| Key | EN | ES-MX |
| --- | --- | --- |
| nav.overview | Overview | Resumen |
| nav.machines | Machines | Máquinas |
| nav.reports | Reports | Reportes |
| nav.settings | Settings | Configuración |
## Sidebar
| Key | EN | ES-MX |
| --- | --- | --- |
| sidebar.productTitle | MIS | MIS |
| sidebar.productSubtitle | Control Tower | Control Tower |
| sidebar.userFallback | User | Usuario |
| sidebar.loadingOrg | Loading... | Cargando... |
| sidebar.themeTooltip | Theme and language settings | Tema e idioma |
| sidebar.switchToDark | Switch to dark mode | Cambiar a modo oscuro |
| sidebar.switchToLight | Switch to light mode | Cambiar a modo claro |
| sidebar.logout | Logout | Cerrar sesión |
| sidebar.role.member | MEMBER | MIEMBRO |
| sidebar.role.admin | ADMIN | ADMIN |
| sidebar.role.owner | OWNER | PROPIETARIO |
## Login
| Key | EN | ES-MX |
| --- | --- | --- |
| login.title | Control Tower | Control Tower |
| login.subtitle | Sign in to your organization | Inicia sesión en tu organización |
| login.email | Email | Correo electrónico |
| login.password | Password | Contraseña |
| login.error.default | Login failed | Inicio de sesión fallido |
| login.error.network | Network error | Error de red |
| login.submit.loading | Signing in... | Iniciando sesión... |
| login.submit.default | Login | Iniciar sesión |
| login.newHere | New here? | ¿Nuevo aquí? |
| login.createAccount | Create an account | Crear cuenta |
## Signup
| Key | EN | ES-MX |
| --- | --- | --- |
| signup.verify.title | Verify your email | Verifica tu correo |
| signup.verify.sent | We sent a verification link to {email}. | Enviamos un enlace de verificación a {email}. |
| signup.verify.failed | Verification email failed to send. Please contact support. | No se pudo enviar el correo de verificación. Contacta a soporte. |
| signup.verify.notice | Once verified, you can sign in and invite your team. | Después de verificar, puedes iniciar sesión e invitar a tu equipo. |
| signup.verify.back | Back to login | Volver al inicio de sesión |
| signup.title | Create your Control Tower | Crea tu Control Tower |
| signup.subtitle | Set up your organization and invite the team. | Configura tu organización e invita al equipo. |
| signup.orgName | Organization name | Nombre de la organización |
| signup.yourName | Your name | Tu nombre |
| signup.email | Email | Correo electrónico |
| signup.password | Password | Contraseña |
| signup.error.default | Signup failed | Registro fallido |
| signup.error.network | Network error | Error de red |
| signup.submit.loading | Creating account... | Creando cuenta... |
| signup.submit.default | Create account | Crear cuenta |
| signup.alreadyHave | Already have access? | ¿Ya tienes acceso? |
| signup.signIn | Sign in | Iniciar sesión |
## Invite
| Key | EN | ES-MX |
| --- | --- | --- |
| invite.loading | Loading invite... | Cargando invitación... |
| invite.notFound | Invite not found. | Invitación no encontrada. |
| invite.joinTitle | Join {org} | Únete a {org} |
| invite.acceptCopy | Accept the invite for {email} as {role}. | Acepta la invitación para {email} como {role}. |
| invite.yourName | Your name | Tu nombre |
| invite.password | Password | Contraseña |
| invite.error.notFound | Invite not found | Invitación no encontrada |
| invite.error.acceptFailed | Invite acceptance failed | No se pudo aceptar la invitación |
| invite.submit.loading | Joining... | Uniéndote... |
| invite.submit.default | Join organization | Unirse a la organización |
## Overview
| Key | EN | ES-MX |
| --- | --- | --- |
| overview.title | Overview | Resumen |
| overview.subtitle | Fleet pulse, alerts, and top attention items. | Pulso de flota, alertas y elementos prioritarios. |
| overview.viewMachines | View Machines | Ver Máquinas |
| overview.loading | Loading overview... | Cargando resumen... |
| overview.fleetHealth | Fleet Health | Salud de flota |
| overview.machinesTotal | Machines total | Máquinas totales |
| overview.online | Online | En línea |
| overview.offline | Offline | Fuera de línea |
| overview.run | Run | En marcha |
| overview.idle | Idle | En espera |
| overview.stop | Stop | Paro |
| overview.productionTotals | Production Totals | Totales de producción |
| overview.good | Good | Buenas |
| overview.scrap | Scrap | Scrap |
| overview.target | Target | Meta |
| overview.kpiSumNote | Sum of latest KPIs across machines. | Suma de los últimos KPIs por máquina. |
| overview.activityFeed | Activity Feed | Actividad |
| overview.eventsRefreshing | Refreshing recent events... | Actualizando eventos recientes... |
| overview.eventsLast30 | Last 30 merged events | Últimos 30 eventos combinados |
| overview.eventsNone | No recent events. | Sin eventos recientes. |
| overview.oeeAvg | OEE (avg) | OEE (avg) |
| overview.availabilityAvg | Availability (avg) | Availability (avg) |
| overview.performanceAvg | Performance (avg) | Performance (avg) |
| overview.qualityAvg | Quality (avg) | Quality (avg) |
| overview.attentionList | Attention List | Lista de atención |
| overview.shown | shown | mostrados |
| overview.noUrgent | No urgent issues detected. | No se detectaron problemas urgentes. |
| overview.timeline | Unified Timeline | Línea de tiempo unificada |
| overview.items | items | elementos |
| overview.noEvents | No events yet. | Sin eventos aún. |
| overview.ack | ACK | ACK |
| overview.severity.critical | CRITICAL | CRÍTICO |
| overview.severity.warning | WARNING | ADVERTENCIA |
| overview.severity.info | INFO | INFO |
| overview.source.ingested | ingested | ingestado |
| overview.source.derived | derived | derivado |
| overview.event.macrostop | macrostop | macroparo |
| overview.event.microstop | microstop | microparo |
| overview.event.slow-cycle | slow-cycle | ciclo lento |
| overview.status.offline | OFFLINE | FUERA DE LÍNEA |
| overview.status.online | ONLINE | EN LÍNEA |
## Machines
| Key | EN | ES-MX |
| --- | --- | --- |
| machines.title | Machines | Máquinas |
| machines.subtitle | Select a machine to view live KPIs. | Selecciona una máquina para ver KPIs en vivo. |
| machines.cancel | Cancel | Cancelar |
| machines.addMachine | Add Machine | Agregar máquina |
| machines.backOverview | Back to Overview | Volver al Resumen |
| machines.addCardTitle | Add a machine | Agregar máquina |
| machines.addCardSubtitle | Generate the machine ID and API key for your Node-RED edge. | Genera el ID de máquina y la API key para tu edge Node-RED. |
| machines.field.name | Machine Name | Nombre de la máquina |
| machines.field.code | Code (optional) | Código (opcional) |
| machines.field.location | Location (optional) | Ubicación (opcional) |
| machines.create.loading | Creating... | Creando... |
| machines.create.default | Create Machine | Crear máquina |
| machines.create.error.nameRequired | Machine name is required | El nombre de la máquina es obligatorio |
| machines.create.error.failed | Failed to create machine | No se pudo crear la máquina |
| machines.pairing.title | Edge pairing code | Código de emparejamiento |
| machines.pairing.machine | Machine: | Máquina: |
| machines.pairing.codeLabel | Pairing code | Código de emparejamiento |
| machines.pairing.expires | Expires | Expira |
| machines.pairing.soon | soon | pronto |
| machines.pairing.instructions | Enter this code on the Node-RED Control Tower settings screen to link the edge device. | Ingresa este código en la pantalla de configuración de Node-RED Control Tower para vincular el dispositivo. |
| machines.pairing.copy | Copy Code | Copiar código |
| machines.pairing.copied | Copied | Copiado |
| machines.pairing.copyUnsupported | Copy not supported | Copiar no disponible |
| machines.pairing.copyFailed | Copy failed | Falló la copia |
| machines.loading | Loading machines... | Cargando máquinas... |
| machines.empty | No machines found for this org. | No se encontraron máquinas para esta organización. |
| machines.status | Status | Estado |
| machines.status.noHeartbeat | No heartbeat | Sin heartbeat |
| machines.status.ok | OK | OK |
| machines.status.offline | OFFLINE | FUERA DE LÍNEA |
| machines.status.unknown | UNKNOWN | DESCONOCIDO |
| machines.lastSeen | Last seen {time} | Visto hace {time} |
## Machine Detail
| Key | EN | ES-MX |
| --- | --- | --- |
| machine.detail.titleFallback | Machine | Máquina |
| machine.detail.lastSeen | Last seen {time} | Visto hace {time} |
| machine.detail.loading | Loading... | Cargando... |
| machine.detail.error.failed | Failed to load machine | No se pudo cargar la máquina |
| machine.detail.error.network | Network error | Error de red |
| machine.detail.back | Back | Volver |
| machine.detail.status.offline | OFFLINE | FUERA DE LÍNEA |
| machine.detail.status.unknown | UNKNOWN | DESCONOCIDO |
| machine.detail.status.run | RUN | EN MARCHA |
| machine.detail.status.idle | IDLE | EN ESPERA |
| machine.detail.status.stop | STOP | PARO |
| machine.detail.status.down | DOWN | CAÍDA |
| machine.detail.bucket.normal | Normal Cycle | Ciclo normal |
| machine.detail.bucket.slow | Slow Cycle | Ciclo lento |
| machine.detail.bucket.microstop | Microstop | Microparo |
| machine.detail.bucket.macrostop | Macrostop | Macroparo |
| machine.detail.bucket.unknown | Unknown | Desconocido |
| machine.detail.activity.title | Machine Activity Timeline | Línea de tiempo de actividad |
| machine.detail.activity.subtitle | Real-time analysis of production cycles | Análisis en tiempo real de ciclos de producción |
| machine.detail.activity.noData | No timeline data yet. | Sin datos de línea de tiempo. |
| machine.detail.tooltip.cycle | Cycle: {label} | Ciclo: {label} |
| machine.detail.tooltip.duration | Duration | Duración |
| machine.detail.tooltip.ideal | Ideal | Ideal |
| machine.detail.tooltip.deviation | Deviation | Desviación |
| machine.detail.kpi.updated | Updated {time} | Actualizado {time} |
| machine.detail.currentWorkOrder | Current Work Order | Orden de trabajo actual |
| machine.detail.recentEvents | Recent Events | Eventos recientes |
| machine.detail.noEvents | No events yet. | Sin eventos aún. |
| machine.detail.cycleTarget | Cycle target | Ciclo objetivo |
| machine.detail.mini.events | Detected Events | Eventos detectados |
| machine.detail.mini.events.subtitle | Count by type (cycles) | Conteo por tipo (ciclos) |
| machine.detail.mini.deviation | Actual vs Standard Cycle | Ciclo real vs estándar |
| machine.detail.mini.deviation.subtitle | Average deviation | Desviación promedio |
| machine.detail.mini.impact | Production Impact | Impacto en producción |
| machine.detail.mini.impact.subtitle | Extra time vs ideal | Tiempo extra vs ideal |
| machine.detail.modal.events | Detected Events | Eventos detectados |
| machine.detail.modal.deviation | Actual vs Standard Cycle | Ciclo real vs estándar |
| machine.detail.modal.impact | Production Impact | Impacto en producción |
| machine.detail.modal.standardCycle | Standard cycle (ideal) | Ciclo estándar (ideal) |
| machine.detail.modal.avgDeviation | Average deviation | Desviación promedio |
| machine.detail.modal.sample | Sample | Muestra |
| machine.detail.modal.cycles | cycles | ciclos |
| machine.detail.modal.tip | Tip: the faint line is the ideal. Each point is a real cycle. | Tip: la línea tenue es el ideal. Cada punto es un ciclo real. |
| machine.detail.modal.totalExtra | Total extra time | Tiempo extra total |
| machine.detail.modal.microstops | Microstops | Microparos |
| machine.detail.modal.macroStops | Macro stops | Macroparos |
| machine.detail.modal.extraTimeLabel | Extra time | Tiempo extra |
| machine.detail.modal.extraTimeNote | This is "lost time" vs ideal, distributed by event type. | Esto es "tiempo perdido" vs ideal, distribuido por tipo de evento. |
## Reports
| Key | EN | ES-MX |
| --- | --- | --- |
| reports.title | Reports | Reportes |
| reports.subtitle | Trends, downtime, and quality analytics across machines. | Tendencias, paros y analítica de calidad por máquina. |
| reports.exportCsv | Export CSV | Exportar CSV |
| reports.exportPdf | Export PDF | Exportar PDF |
| reports.filters | Filters | Filtros |
| reports.rangeLabel.last24 | Last 24 hours | Últimas 24 horas |
| reports.rangeLabel.last7 | Last 7 days | Últimos 7 días |
| reports.rangeLabel.last30 | Last 30 days | Últimos 30 días |
| reports.rangeLabel.custom | Custom range | Rango personalizado |
| reports.filter.range | Range | Rango |
| reports.filter.machine | Machine | Máquina |
| reports.filter.workOrder | Work Order | Orden de trabajo |
| reports.filter.sku | SKU | SKU |
| reports.filter.allMachines | All machines | Todas las máquinas |
| reports.filter.allWorkOrders | All work orders | Todas las órdenes |
| reports.filter.allSkus | All SKUs | Todos los SKUs |
| reports.loading | Loading reports... | Cargando reportes... |
| reports.error.failed | Failed to load reports | No se pudieron cargar los reportes |
| reports.error.network | Network error | Error de red |
| reports.kpi.note.withData | Computed from KPI snapshots. | Calculado a partir de KPIs. |
| reports.kpi.note.noData | No data in selected range. | Sin datos en el rango seleccionado. |
| reports.oeeTrend | OEE Trend | Tendencia de OEE |
| reports.downtimePareto | Downtime Pareto | Pareto de paros |
| reports.cycleDistribution | Cycle Time Distribution | Distribución de tiempos de ciclo |
| reports.scrapTrend | Scrap Trend | Tendencia de scrap |
| reports.topLossDrivers | Top Loss Drivers | Principales causas de pérdida |
| reports.qualitySummary | Quality Summary | Resumen de calidad |
| reports.notes | Notes for Ops | Notas para operaciones |
| reports.notes.suggested | Suggested actions | Acciones sugeridas |
| reports.notes.none | No insights yet. Generate reports after data collection. | Sin insights todavía. Genera reportes tras recolectar datos. |
| reports.noTrend | No trend data yet. | Sin datos de tendencia. |
| reports.noDowntime | No downtime data yet. | Sin datos de paros. |
| reports.noCycle | No cycle data yet. | Sin datos de ciclo. |
| reports.scrapRate | Scrap Rate | Scrap Rate |
| reports.topScrapSku | Top Scrap SKU | SKU con más scrap |
| reports.topScrapWorkOrder | Top Scrap Work Order | Orden con más scrap |
| reports.loss.macrostop | Macrostop | Macroparo |
| reports.loss.microstop | Microstop | Microparo |
| reports.loss.slowCycle | Slow Cycle | Ciclo lento |
| reports.loss.qualitySpike | Quality Spike | Pico de calidad |
| reports.loss.oeeDrop | OEE Drop | Caída de OEE |
| reports.loss.perfDegradation | Perf Degradation | Baja de desempeño |
| reports.tooltip.cycles | Cycles | Ciclos |
| reports.tooltip.range | Range | Rango |
| reports.tooltip.below | Below | Debajo de |
| reports.tooltip.above | Above | Encima de |
| reports.tooltip.extremes | Extremes | Extremos |
| reports.tooltip.downtime | Downtime | Tiempo de paro |
| reports.tooltip.extraTime | Extra time | Tiempo extra |
| reports.csv.section | section | sección |
| reports.csv.key | key | clave |
| reports.csv.value | value | valor |
| reports.pdf.title | Report Export | Exportación de reporte |
| reports.pdf.range | Range | Rango |
| reports.pdf.machine | Machine | Máquina |
| reports.pdf.workOrder | Work Order | Orden de trabajo |
| reports.pdf.sku | SKU | SKU |
| reports.pdf.metric | Metric | Métrica |
| reports.pdf.value | Value | Valor |
| reports.pdf.topLoss | Top Loss Drivers | Principales causas de pérdida |
| reports.pdf.qualitySummary | Quality Summary | Resumen de calidad |
| reports.pdf.cycleDistribution | Cycle Time Distribution | Distribución de tiempos de ciclo |
| reports.pdf.notes | Notes for Ops | Notas para operaciones |
| reports.pdf.none | None | Ninguna |
## Settings
| Key | EN | ES-MX |
| --- | --- | --- |
| settings.title | Settings | Configuración |
| settings.subtitle | Live configuration for shifts, alerts, and defaults. | Configuración en vivo para turnos, alertas y valores predeterminados. |
| settings.loading | Loading settings... | Cargando configuración... |
| settings.loadingTeam | Loading team... | Cargando equipo... |
| settings.refresh | Refresh | Actualizar |
| settings.save | Save changes | Guardar cambios |
| settings.saving | Saving... | Guardando... |
| settings.saved | Settings saved | Configuración guardada |
| settings.failedLoad | Failed to load settings | No se pudo cargar la configuración |
| settings.failedTeam | Failed to load team | No se pudo cargar el equipo |
| settings.failedSave | Failed to save settings | No se pudo guardar la configuración |
| settings.unavailable | Settings are unavailable. | La configuración no está disponible. |
| settings.conflict | Settings changed elsewhere. Refresh and try again. | La configuración cambió en otro lugar. Actualiza e intenta de nuevo. |
| settings.org.title | Organization | Organización |
| settings.org.plantName | Plant Name | Nombre de planta |
| settings.org.slug | Slug | Slug |
| settings.org.timeZone | Time Zone | Zona horaria |
| settings.shiftSchedule | Shift Schedule | Turnos |
| settings.shiftSubtitle | Define active shifts and downtime compensation. | Define turnos activos y compensación de paros. |
| settings.shiftName | Shift name | Nombre del turno |
| settings.shiftStart | Start | Inicio |
| settings.shiftEnd | End | Fin |
| settings.shiftEnabled | Enabled | Activo |
| settings.shiftAdd | Add shift | Agregar turno |
| settings.shiftRemove | Remove | Eliminar |
| settings.shiftComp | Shift change compensation | Compensación por cambio de turno |
| settings.lunchBreak | Lunch break | Comida |
| settings.minutes | minutes | minutos |
| settings.shiftHint | Max 3 shifts, HH:mm | Máx 3 turnos, HH:mm |
| settings.shiftTo | to | a |
| settings.shiftCompLabel | Shift change compensation (min) | Compensación por cambio de turno (min) |
| settings.lunchBreakLabel | Lunch break (min) | Comida (min) |
| settings.shift.defaultName | Shift {index} | Turno {index} |
| settings.thresholds | Alert thresholds | Umbrales de alertas |
| settings.thresholdsSubtitle | Tune production health alerts. | Ajusta alertas de salud de producción. |
| settings.thresholds.appliesAll | Applies to all machines | Aplica a todas las máquinas |
| settings.thresholds.oee | OEE alert threshold | Umbral de alerta OEE |
| settings.thresholds.performance | Performance threshold | Umbral de Performance |
| settings.thresholds.qualitySpike | Quality spike delta | Delta de pico de calidad |
| settings.thresholds.stoppage | Stoppage multiplier | Multiplicador de paro |
| settings.alerts | Alerts | Alertas |
| settings.alertsSubtitle | Choose which alerts to notify. | Elige qué alertas notificar. |
| settings.alerts.oeeDrop | OEE drop alerts | Alertas por caída de OEE |
| settings.alerts.oeeDropHelper | Notify when OEE falls below threshold | Notificar cuando OEE esté por debajo del umbral |
| settings.alerts.performanceDegradation | Performance degradation alerts | Alertas por baja de Performance |
| settings.alerts.performanceDegradationHelper | Flag prolonged slow cycles | Marcar ciclos lentos prolongados |
| settings.alerts.qualitySpike | Quality spike alerts | Alertas por picos de calidad |
| settings.alerts.qualitySpikeHelper | Alert on scrap spikes | Alertar por picos de scrap |
| settings.alerts.predictive | Predictive OEE decline alerts | Alertas predictivas de caída OEE |
| settings.alerts.predictiveHelper | Warn before OEE drops | Avisar antes de que OEE caiga |
| settings.defaults | Mold Defaults | Valores predeterminados de moldes |
| settings.defaults.moldTotal | Total molds | Moldes totales |
| settings.defaults.moldActive | Active molds | Moldes activos |
| settings.updated | Updated | Actualizado |
| settings.updatedBy | Updated by | Actualizado por |
| settings.team | Team Members | Miembros del equipo |
| settings.teamTotal | {count} total | {count} total |
| settings.teamNone | No team members yet. | Sin miembros del equipo. |
| settings.invites | Invitations | Invitaciones |
| settings.inviteEmail | Invite email | Correo de invitación |
| settings.inviteRole | Role | Rol |
| settings.inviteSend | Create invite | Crear invitación |
| settings.inviteSending | Creating... | Creando... |
| settings.inviteStatus.copied | Invite link copied | Enlace de invitación copiado |
| settings.inviteStatus.emailRequired | Email is required | El correo es obligatorio |
| settings.inviteStatus.failed | Failed to revoke invite | No se pudo revocar la invitación |
| settings.inviteStatus.sent | Invite email sent | Correo de invitación enviado |
| settings.inviteStatus.createFailed | Failed to create invite | No se pudo crear la invitación |
| settings.inviteStatus.emailFailed | Invite created, email failed: {url} | Invitación creada, falló el correo: {url} |
| settings.inviteNone | No pending invites. | Sin invitaciones pendientes. |
| settings.inviteExpires | Expires {date} | Expira {date} |
| settings.inviteRole.member | Member | Miembro |
| settings.inviteRole.admin | Admin | Admin |
| settings.inviteRole.owner | Owner | Propietario |
| settings.inviteCopy | Copy link | Copiar enlace |
| settings.inviteRevoke | Revoke | Revocar |
| settings.role.owner | Owner | Propietario |
| settings.role.admin | Admin | Admin |
| settings.role.member | Member | Miembro |
| settings.role.inactive | Inactive | Inactivo |
| settings.integrations | Integrations | Integraciones |
| settings.integrations.webhook | Webhook URL | Webhook URL |
| settings.integrations.erp | ERP Sync | ERP Sync |
| settings.integrations.erpNotConfigured | Not configured | No configurado |

333
lib/i18n/en.json Normal file
View File

@@ -0,0 +1,333 @@
{
"---": "---",
"common.loading": "Loading...",
"common.loadingShort": "Loading",
"common.never": "never",
"common.na": "--",
"common.back": "Back",
"common.cancel": "Cancel",
"common.close": "Close",
"common.save": "Save",
"common.copy": "Copy",
"nav.overview": "Overview",
"nav.machines": "Machines",
"nav.reports": "Reports",
"nav.settings": "Settings",
"sidebar.productTitle": "MIS",
"sidebar.productSubtitle": "Control Tower",
"sidebar.userFallback": "User",
"sidebar.loadingOrg": "Loading...",
"sidebar.themeTooltip": "Theme and language settings",
"sidebar.switchToDark": "Switch to dark mode",
"sidebar.switchToLight": "Switch to light mode",
"sidebar.logout": "Logout",
"sidebar.role.member": "MEMBER",
"sidebar.role.admin": "ADMIN",
"sidebar.role.owner": "OWNER",
"login.title": "Control Tower",
"login.subtitle": "Sign in to your organization",
"login.email": "Email",
"login.password": "Password",
"login.error.default": "Login failed",
"login.error.network": "Network error",
"login.submit.loading": "Signing in...",
"login.submit.default": "Login",
"login.newHere": "New here?",
"login.createAccount": "Create an account",
"signup.verify.title": "Verify your email",
"signup.verify.sent": "We sent a verification link to {email}.",
"signup.verify.failed": "Verification email failed to send. Please contact support.",
"signup.verify.notice": "Once verified, you can sign in and invite your team.",
"signup.verify.back": "Back to login",
"signup.title": "Create your Control Tower",
"signup.subtitle": "Set up your organization and invite the team.",
"signup.orgName": "Organization name",
"signup.yourName": "Your name",
"signup.email": "Email",
"signup.password": "Password",
"signup.error.default": "Signup failed",
"signup.error.network": "Network error",
"signup.submit.loading": "Creating account...",
"signup.submit.default": "Create account",
"signup.alreadyHave": "Already have access?",
"signup.signIn": "Sign in",
"invite.loading": "Loading invite...",
"invite.notFound": "Invite not found.",
"invite.joinTitle": "Join {org}",
"invite.acceptCopy": "Accept the invite for {email} as {role}.",
"invite.yourName": "Your name",
"invite.password": "Password",
"invite.error.notFound": "Invite not found",
"invite.error.acceptFailed": "Invite acceptance failed",
"invite.submit.loading": "Joining...",
"invite.submit.default": "Join organization",
"overview.title": "Overview",
"overview.subtitle": "Fleet pulse, alerts, and top attention items.",
"overview.viewMachines": "View Machines",
"overview.loading": "Loading overview...",
"overview.fleetHealth": "Fleet Health",
"overview.machinesTotal": "Machines total",
"overview.online": "Online",
"overview.offline": "Offline",
"overview.run": "Run",
"overview.idle": "Idle",
"overview.stop": "Stop",
"overview.productionTotals": "Production Totals",
"overview.good": "Good",
"overview.scrap": "Scrap",
"overview.target": "Target",
"overview.kpiSumNote": "Sum of latest KPIs across machines.",
"overview.activityFeed": "Activity Feed",
"overview.eventsRefreshing": "Refreshing recent events...",
"overview.eventsLast30": "Last 30 merged events",
"overview.eventsNone": "No recent events.",
"overview.oeeAvg": "OEE (avg)",
"overview.availabilityAvg": "Availability (avg)",
"overview.performanceAvg": "Performance (avg)",
"overview.qualityAvg": "Quality (avg)",
"overview.attentionList": "Attention List",
"overview.shown": "shown",
"overview.noUrgent": "No urgent issues detected.",
"overview.timeline": "Unified Timeline",
"overview.items": "items",
"overview.noEvents": "No events yet.",
"overview.ack": "ACK",
"overview.severity.critical": "CRITICAL",
"overview.severity.warning": "WARNING",
"overview.severity.info": "INFO",
"overview.source.ingested": "ingested",
"overview.source.derived": "derived",
"overview.event.macrostop": "macrostop",
"overview.event.microstop": "microstop",
"overview.event.slow-cycle": "slow-cycle",
"overview.status.offline": "OFFLINE",
"overview.status.online": "ONLINE",
"machines.title": "Machines",
"machines.subtitle": "Select a machine to view live KPIs.",
"machines.cancel": "Cancel",
"machines.addMachine": "Add Machine",
"machines.backOverview": "Back to Overview",
"machines.addCardTitle": "Add a machine",
"machines.addCardSubtitle": "Generate the machine ID and API key for your Node-RED edge.",
"machines.field.name": "Machine Name",
"machines.field.code": "Code (optional)",
"machines.field.location": "Location (optional)",
"machines.create.loading": "Creating...",
"machines.create.default": "Create Machine",
"machines.create.error.nameRequired": "Machine name is required",
"machines.create.error.failed": "Failed to create machine",
"machines.pairing.title": "Edge pairing code",
"machines.pairing.machine": "Machine:",
"machines.pairing.codeLabel": "Pairing code",
"machines.pairing.expires": "Expires",
"machines.pairing.soon": "soon",
"machines.pairing.instructions": "Enter this code on the Node-RED Control Tower settings screen to link the edge device.",
"machines.pairing.copy": "Copy Code",
"machines.pairing.copied": "Copied",
"machines.pairing.copyUnsupported": "Copy not supported",
"machines.pairing.copyFailed": "Copy failed",
"machines.loading": "Loading machines...",
"machines.empty": "No machines found for this org.",
"machines.status": "Status",
"machines.status.noHeartbeat": "No heartbeat",
"machines.status.ok": "OK",
"machines.status.offline": "OFFLINE",
"machines.status.unknown": "UNKNOWN",
"machines.lastSeen": "Last seen {time}",
"machine.detail.titleFallback": "Machine",
"machine.detail.lastSeen": "Last seen {time}",
"machine.detail.loading": "Loading...",
"machine.detail.error.failed": "Failed to load machine",
"machine.detail.error.network": "Network error",
"machine.detail.back": "Back",
"machine.detail.status.offline": "OFFLINE",
"machine.detail.status.unknown": "UNKNOWN",
"machine.detail.status.run": "RUN",
"machine.detail.status.idle": "IDLE",
"machine.detail.status.stop": "STOP",
"machine.detail.status.down": "DOWN",
"machine.detail.bucket.normal": "Normal Cycle",
"machine.detail.bucket.slow": "Slow Cycle",
"machine.detail.bucket.microstop": "Microstop",
"machine.detail.bucket.macrostop": "Macrostop",
"machine.detail.bucket.unknown": "Unknown",
"machine.detail.activity.title": "Machine Activity Timeline",
"machine.detail.activity.subtitle": "Real-time analysis of production cycles",
"machine.detail.activity.noData": "No timeline data yet.",
"machine.detail.tooltip.cycle": "Cycle: {label}",
"machine.detail.tooltip.duration": "Duration",
"machine.detail.tooltip.ideal": "Ideal",
"machine.detail.tooltip.deviation": "Deviation",
"machine.detail.kpi.updated": "Updated {time}",
"machine.detail.currentWorkOrder": "Current Work Order",
"machine.detail.recentEvents": "Recent Events",
"machine.detail.noEvents": "No events yet.",
"machine.detail.cycleTarget": "Cycle target",
"machine.detail.mini.events": "Detected Events",
"machine.detail.mini.events.subtitle": "Count by type (cycles)",
"machine.detail.mini.deviation": "Actual vs Standard Cycle",
"machine.detail.mini.deviation.subtitle": "Average deviation",
"machine.detail.mini.impact": "Production Impact",
"machine.detail.mini.impact.subtitle": "Extra time vs ideal",
"machine.detail.modal.events": "Detected Events",
"machine.detail.modal.deviation": "Actual vs Standard Cycle",
"machine.detail.modal.impact": "Production Impact",
"machine.detail.modal.standardCycle": "Standard cycle (ideal)",
"machine.detail.modal.avgDeviation": "Average deviation",
"machine.detail.modal.sample": "Sample",
"machine.detail.modal.cycles": "cycles",
"machine.detail.modal.tip": "Tip: the faint line is the ideal. Each point is a real cycle.",
"machine.detail.modal.totalExtra": "Total extra time",
"machine.detail.modal.microstops": "Microstops",
"machine.detail.modal.macroStops": "Macro stops",
"machine.detail.modal.extraTimeLabel": "Extra time",
"machine.detail.modal.extraTimeNote": "This is \"lost time\" vs ideal, distributed by event type.",
"reports.title": "Reports",
"reports.subtitle": "Trends, downtime, and quality analytics across machines.",
"reports.exportCsv": "Export CSV",
"reports.exportPdf": "Export PDF",
"reports.filters": "Filters",
"reports.rangeLabel.last24": "Last 24 hours",
"reports.rangeLabel.last7": "Last 7 days",
"reports.rangeLabel.last30": "Last 30 days",
"reports.rangeLabel.custom": "Custom range",
"reports.filter.range": "Range",
"reports.filter.machine": "Machine",
"reports.filter.workOrder": "Work Order",
"reports.filter.sku": "SKU",
"reports.filter.allMachines": "All machines",
"reports.filter.allWorkOrders": "All work orders",
"reports.filter.allSkus": "All SKUs",
"reports.loading": "Loading reports...",
"reports.error.failed": "Failed to load reports",
"reports.error.network": "Network error",
"reports.kpi.note.withData": "Computed from KPI snapshots.",
"reports.kpi.note.noData": "No data in selected range.",
"reports.oeeTrend": "OEE Trend",
"reports.downtimePareto": "Downtime Pareto",
"reports.cycleDistribution": "Cycle Time Distribution",
"reports.scrapTrend": "Scrap Trend",
"reports.topLossDrivers": "Top Loss Drivers",
"reports.qualitySummary": "Quality Summary",
"reports.notes": "Notes for Ops",
"reports.notes.suggested": "Suggested actions",
"reports.notes.none": "No insights yet. Generate reports after data collection.",
"reports.noTrend": "No trend data yet.",
"reports.noDowntime": "No downtime data yet.",
"reports.noCycle": "No cycle data yet.",
"reports.scrapRate": "Scrap Rate",
"reports.topScrapSku": "Top Scrap SKU",
"reports.topScrapWorkOrder": "Top Scrap Work Order",
"reports.loss.macrostop": "Macrostop",
"reports.loss.microstop": "Microstop",
"reports.loss.slowCycle": "Slow Cycle",
"reports.loss.qualitySpike": "Quality Spike",
"reports.loss.oeeDrop": "OEE Drop",
"reports.loss.perfDegradation": "Perf Degradation",
"reports.tooltip.cycles": "Cycles",
"reports.tooltip.range": "Range",
"reports.tooltip.below": "Below",
"reports.tooltip.above": "Above",
"reports.tooltip.extremes": "Extremes",
"reports.tooltip.downtime": "Downtime",
"reports.tooltip.extraTime": "Extra time",
"reports.csv.section": "section",
"reports.csv.key": "key",
"reports.csv.value": "value",
"reports.pdf.title": "Report Export",
"reports.pdf.range": "Range",
"reports.pdf.machine": "Machine",
"reports.pdf.workOrder": "Work Order",
"reports.pdf.sku": "SKU",
"reports.pdf.metric": "Metric",
"reports.pdf.value": "Value",
"reports.pdf.topLoss": "Top Loss Drivers",
"reports.pdf.qualitySummary": "Quality Summary",
"reports.pdf.cycleDistribution": "Cycle Time Distribution",
"reports.pdf.notes": "Notes for Ops",
"reports.pdf.none": "None",
"settings.title": "Settings",
"settings.subtitle": "Live configuration for shifts, alerts, and defaults.",
"settings.loading": "Loading settings...",
"settings.loadingTeam": "Loading team...",
"settings.refresh": "Refresh",
"settings.save": "Save changes",
"settings.saving": "Saving...",
"settings.saved": "Settings saved",
"settings.failedLoad": "Failed to load settings",
"settings.failedTeam": "Failed to load team",
"settings.failedSave": "Failed to save settings",
"settings.unavailable": "Settings are unavailable.",
"settings.conflict": "Settings changed elsewhere. Refresh and try again.",
"settings.org.title": "Organization",
"settings.org.plantName": "Plant Name",
"settings.org.slug": "Slug",
"settings.org.timeZone": "Time Zone",
"settings.shiftSchedule": "Shift Schedule",
"settings.shiftSubtitle": "Define active shifts and downtime compensation.",
"settings.shiftName": "Shift name",
"settings.shiftStart": "Start",
"settings.shiftEnd": "End",
"settings.shiftEnabled": "Enabled",
"settings.shiftAdd": "Add shift",
"settings.shiftRemove": "Remove",
"settings.shiftComp": "Shift change compensation",
"settings.lunchBreak": "Lunch break",
"settings.minutes": "minutes",
"settings.shiftHint": "Max 3 shifts, HH:mm",
"settings.shiftTo": "to",
"settings.shiftCompLabel": "Shift change compensation (min)",
"settings.lunchBreakLabel": "Lunch break (min)",
"settings.shift.defaultName": "Shift {index}",
"settings.thresholds": "Alert thresholds",
"settings.thresholdsSubtitle": "Tune production health alerts.",
"settings.thresholds.appliesAll": "Applies to all machines",
"settings.thresholds.oee": "OEE alert threshold",
"settings.thresholds.performance": "Performance threshold",
"settings.thresholds.qualitySpike": "Quality spike delta",
"settings.thresholds.stoppage": "Stoppage multiplier",
"settings.alerts": "Alerts",
"settings.alertsSubtitle": "Choose which alerts to notify.",
"settings.alerts.oeeDrop": "OEE drop alerts",
"settings.alerts.oeeDropHelper": "Notify when OEE falls below threshold",
"settings.alerts.performanceDegradation": "Performance degradation alerts",
"settings.alerts.performanceDegradationHelper": "Flag prolonged slow cycles",
"settings.alerts.qualitySpike": "Quality spike alerts",
"settings.alerts.qualitySpikeHelper": "Alert on scrap spikes",
"settings.alerts.predictive": "Predictive OEE decline alerts",
"settings.alerts.predictiveHelper": "Warn before OEE drops",
"settings.defaults": "Mold Defaults",
"settings.defaults.moldTotal": "Total molds",
"settings.defaults.moldActive": "Active molds",
"settings.updated": "Updated",
"settings.updatedBy": "Updated by",
"settings.team": "Team Members",
"settings.teamTotal": "{count} total",
"settings.teamNone": "No team members yet.",
"settings.invites": "Invitations",
"settings.inviteEmail": "Invite email",
"settings.inviteRole": "Role",
"settings.inviteSend": "Create invite",
"settings.inviteSending": "Creating...",
"settings.inviteStatus.copied": "Invite link copied",
"settings.inviteStatus.emailRequired": "Email is required",
"settings.inviteStatus.failed": "Failed to revoke invite",
"settings.inviteStatus.sent": "Invite email sent",
"settings.inviteStatus.createFailed": "Failed to create invite",
"settings.inviteStatus.emailFailed": "Invite created, email failed: {url}",
"settings.inviteNone": "No pending invites.",
"settings.inviteExpires": "Expires {date}",
"settings.inviteRole.member": "Member",
"settings.inviteRole.admin": "Admin",
"settings.inviteRole.owner": "Owner",
"settings.inviteCopy": "Copy link",
"settings.inviteRevoke": "Revoke",
"settings.role.owner": "Owner",
"settings.role.admin": "Admin",
"settings.role.member": "Member",
"settings.role.inactive": "Inactive",
"settings.integrations": "Integrations",
"settings.integrations.webhook": "Webhook URL",
"settings.integrations.erp": "ERP Sync",
"settings.integrations.erpNotConfigured": "Not configured"
}

333
lib/i18n/es-MX.json Normal file
View File

@@ -0,0 +1,333 @@
{
"---": "---",
"common.loading": "Cargando...",
"common.loadingShort": "Cargando",
"common.never": "nunca",
"common.na": "--",
"common.back": "Volver",
"common.cancel": "Cancelar",
"common.close": "Cerrar",
"common.save": "Guardar",
"common.copy": "Copiar",
"nav.overview": "Resumen",
"nav.machines": "Máquinas",
"nav.reports": "Reportes",
"nav.settings": "Configuración",
"sidebar.productTitle": "MIS",
"sidebar.productSubtitle": "Control Tower",
"sidebar.userFallback": "Usuario",
"sidebar.loadingOrg": "Cargando...",
"sidebar.themeTooltip": "Tema e idioma",
"sidebar.switchToDark": "Cambiar a modo oscuro",
"sidebar.switchToLight": "Cambiar a modo claro",
"sidebar.logout": "Cerrar sesión",
"sidebar.role.member": "MIEMBRO",
"sidebar.role.admin": "ADMIN",
"sidebar.role.owner": "PROPIETARIO",
"login.title": "Control Tower",
"login.subtitle": "Inicia sesión en tu organización",
"login.email": "Correo electrónico",
"login.password": "Contraseña",
"login.error.default": "Inicio de sesión fallido",
"login.error.network": "Error de red",
"login.submit.loading": "Iniciando sesión...",
"login.submit.default": "Iniciar sesión",
"login.newHere": "¿Nuevo aquí?",
"login.createAccount": "Crear cuenta",
"signup.verify.title": "Verifica tu correo",
"signup.verify.sent": "Enviamos un enlace de verificación a {email}.",
"signup.verify.failed": "No se pudo enviar el correo de verificación. Contacta a soporte.",
"signup.verify.notice": "Después de verificar, puedes iniciar sesión e invitar a tu equipo.",
"signup.verify.back": "Volver al inicio de sesión",
"signup.title": "Crea tu Control Tower",
"signup.subtitle": "Configura tu organización e invita al equipo.",
"signup.orgName": "Nombre de la organización",
"signup.yourName": "Tu nombre",
"signup.email": "Correo electrónico",
"signup.password": "Contraseña",
"signup.error.default": "Registro fallido",
"signup.error.network": "Error de red",
"signup.submit.loading": "Creando cuenta...",
"signup.submit.default": "Crear cuenta",
"signup.alreadyHave": "¿Ya tienes acceso?",
"signup.signIn": "Iniciar sesión",
"invite.loading": "Cargando invitación...",
"invite.notFound": "Invitación no encontrada.",
"invite.joinTitle": "Únete a {org}",
"invite.acceptCopy": "Acepta la invitación para {email} como {role}.",
"invite.yourName": "Tu nombre",
"invite.password": "Contraseña",
"invite.error.notFound": "Invitación no encontrada",
"invite.error.acceptFailed": "No se pudo aceptar la invitación",
"invite.submit.loading": "Uniéndote...",
"invite.submit.default": "Unirse a la organización",
"overview.title": "Resumen",
"overview.subtitle": "Pulso de flota, alertas y elementos prioritarios.",
"overview.viewMachines": "Ver Máquinas",
"overview.loading": "Cargando resumen...",
"overview.fleetHealth": "Salud de flota",
"overview.machinesTotal": "Máquinas totales",
"overview.online": "En línea",
"overview.offline": "Fuera de línea",
"overview.run": "En marcha",
"overview.idle": "En espera",
"overview.stop": "Paro",
"overview.productionTotals": "Totales de producción",
"overview.good": "Buenas",
"overview.scrap": "Scrap",
"overview.target": "Meta",
"overview.kpiSumNote": "Suma de los últimos KPIs por máquina.",
"overview.activityFeed": "Actividad",
"overview.eventsRefreshing": "Actualizando eventos recientes...",
"overview.eventsLast30": "Últimos 30 eventos combinados",
"overview.eventsNone": "Sin eventos recientes.",
"overview.oeeAvg": "OEE (avg)",
"overview.availabilityAvg": "Availability (avg)",
"overview.performanceAvg": "Performance (avg)",
"overview.qualityAvg": "Quality (avg)",
"overview.attentionList": "Lista de atención",
"overview.shown": "mostrados",
"overview.noUrgent": "No se detectaron problemas urgentes.",
"overview.timeline": "Línea de tiempo unificada",
"overview.items": "elementos",
"overview.noEvents": "Sin eventos aún.",
"overview.ack": "ACK",
"overview.severity.critical": "CRÍTICO",
"overview.severity.warning": "ADVERTENCIA",
"overview.severity.info": "INFO",
"overview.source.ingested": "ingestado",
"overview.source.derived": "derivado",
"overview.event.macrostop": "macroparo",
"overview.event.microstop": "microparo",
"overview.event.slow-cycle": "ciclo lento",
"overview.status.offline": "FUERA DE LÍNEA",
"overview.status.online": "EN LÍNEA",
"machines.title": "Máquinas",
"machines.subtitle": "Selecciona una máquina para ver KPIs en vivo.",
"machines.cancel": "Cancelar",
"machines.addMachine": "Agregar máquina",
"machines.backOverview": "Volver al Resumen",
"machines.addCardTitle": "Agregar máquina",
"machines.addCardSubtitle": "Genera el ID de máquina y la API key para tu edge Node-RED.",
"machines.field.name": "Nombre de la máquina",
"machines.field.code": "Código (opcional)",
"machines.field.location": "Ubicación (opcional)",
"machines.create.loading": "Creando...",
"machines.create.default": "Crear máquina",
"machines.create.error.nameRequired": "El nombre de la máquina es obligatorio",
"machines.create.error.failed": "No se pudo crear la máquina",
"machines.pairing.title": "Código de emparejamiento",
"machines.pairing.machine": "Máquina:",
"machines.pairing.codeLabel": "Código de emparejamiento",
"machines.pairing.expires": "Expira",
"machines.pairing.soon": "pronto",
"machines.pairing.instructions": "Ingresa este código en la pantalla de configuración de Node-RED Control Tower para vincular el dispositivo.",
"machines.pairing.copy": "Copiar código",
"machines.pairing.copied": "Copiado",
"machines.pairing.copyUnsupported": "Copiar no disponible",
"machines.pairing.copyFailed": "Falló la copia",
"machines.loading": "Cargando máquinas...",
"machines.empty": "No se encontraron máquinas para esta organización.",
"machines.status": "Estado",
"machines.status.noHeartbeat": "Sin heartbeat",
"machines.status.ok": "OK",
"machines.status.offline": "FUERA DE LÍNEA",
"machines.status.unknown": "DESCONOCIDO",
"machines.lastSeen": "Visto hace {time}",
"machine.detail.titleFallback": "Máquina",
"machine.detail.lastSeen": "Visto hace {time}",
"machine.detail.loading": "Cargando...",
"machine.detail.error.failed": "No se pudo cargar la máquina",
"machine.detail.error.network": "Error de red",
"machine.detail.back": "Volver",
"machine.detail.status.offline": "FUERA DE LÍNEA",
"machine.detail.status.unknown": "DESCONOCIDO",
"machine.detail.status.run": "EN MARCHA",
"machine.detail.status.idle": "EN ESPERA",
"machine.detail.status.stop": "PARO",
"machine.detail.status.down": "CAÍDA",
"machine.detail.bucket.normal": "Ciclo normal",
"machine.detail.bucket.slow": "Ciclo lento",
"machine.detail.bucket.microstop": "Microparo",
"machine.detail.bucket.macrostop": "Macroparo",
"machine.detail.bucket.unknown": "Desconocido",
"machine.detail.activity.title": "Línea de tiempo de actividad",
"machine.detail.activity.subtitle": "Análisis en tiempo real de ciclos de producción",
"machine.detail.activity.noData": "Sin datos de línea de tiempo.",
"machine.detail.tooltip.cycle": "Ciclo: {label}",
"machine.detail.tooltip.duration": "Duración",
"machine.detail.tooltip.ideal": "Ideal",
"machine.detail.tooltip.deviation": "Desviación",
"machine.detail.kpi.updated": "Actualizado {time}",
"machine.detail.currentWorkOrder": "Orden de trabajo actual",
"machine.detail.recentEvents": "Eventos recientes",
"machine.detail.noEvents": "Sin eventos aún.",
"machine.detail.cycleTarget": "Ciclo objetivo",
"machine.detail.mini.events": "Eventos detectados",
"machine.detail.mini.events.subtitle": "Conteo por tipo (ciclos)",
"machine.detail.mini.deviation": "Ciclo real vs estándar",
"machine.detail.mini.deviation.subtitle": "Desviación promedio",
"machine.detail.mini.impact": "Impacto en producción",
"machine.detail.mini.impact.subtitle": "Tiempo extra vs ideal",
"machine.detail.modal.events": "Eventos detectados",
"machine.detail.modal.deviation": "Ciclo real vs estándar",
"machine.detail.modal.impact": "Impacto en producción",
"machine.detail.modal.standardCycle": "Ciclo estándar (ideal)",
"machine.detail.modal.avgDeviation": "Desviación promedio",
"machine.detail.modal.sample": "Muestra",
"machine.detail.modal.cycles": "ciclos",
"machine.detail.modal.tip": "Tip: la línea tenue es el ideal. Cada punto es un ciclo real.",
"machine.detail.modal.totalExtra": "Tiempo extra total",
"machine.detail.modal.microstops": "Microparos",
"machine.detail.modal.macroStops": "Macroparos",
"machine.detail.modal.extraTimeLabel": "Tiempo extra",
"machine.detail.modal.extraTimeNote": "Esto es \"tiempo perdido\" vs ideal, distribuido por tipo de evento.",
"reports.title": "Reportes",
"reports.subtitle": "Tendencias, paros y analítica de calidad por máquina.",
"reports.exportCsv": "Exportar CSV",
"reports.exportPdf": "Exportar PDF",
"reports.filters": "Filtros",
"reports.rangeLabel.last24": "Últimas 24 horas",
"reports.rangeLabel.last7": "Últimos 7 días",
"reports.rangeLabel.last30": "Últimos 30 días",
"reports.rangeLabel.custom": "Rango personalizado",
"reports.filter.range": "Rango",
"reports.filter.machine": "Máquina",
"reports.filter.workOrder": "Orden de trabajo",
"reports.filter.sku": "SKU",
"reports.filter.allMachines": "Todas las máquinas",
"reports.filter.allWorkOrders": "Todas las órdenes",
"reports.filter.allSkus": "Todos los SKUs",
"reports.loading": "Cargando reportes...",
"reports.error.failed": "No se pudieron cargar los reportes",
"reports.error.network": "Error de red",
"reports.kpi.note.withData": "Calculado a partir de KPIs.",
"reports.kpi.note.noData": "Sin datos en el rango seleccionado.",
"reports.oeeTrend": "Tendencia de OEE",
"reports.downtimePareto": "Pareto de paros",
"reports.cycleDistribution": "Distribución de tiempos de ciclo",
"reports.scrapTrend": "Tendencia de scrap",
"reports.topLossDrivers": "Principales causas de pérdida",
"reports.qualitySummary": "Resumen de calidad",
"reports.notes": "Notas para operaciones",
"reports.notes.suggested": "Acciones sugeridas",
"reports.notes.none": "Sin insights todavía. Genera reportes tras recolectar datos.",
"reports.noTrend": "Sin datos de tendencia.",
"reports.noDowntime": "Sin datos de paros.",
"reports.noCycle": "Sin datos de ciclo.",
"reports.scrapRate": "Scrap Rate",
"reports.topScrapSku": "SKU con más scrap",
"reports.topScrapWorkOrder": "Orden con más scrap",
"reports.loss.macrostop": "Macroparo",
"reports.loss.microstop": "Microparo",
"reports.loss.slowCycle": "Ciclo lento",
"reports.loss.qualitySpike": "Pico de calidad",
"reports.loss.oeeDrop": "Caída de OEE",
"reports.loss.perfDegradation": "Baja de desempeño",
"reports.tooltip.cycles": "Ciclos",
"reports.tooltip.range": "Rango",
"reports.tooltip.below": "Debajo de",
"reports.tooltip.above": "Encima de",
"reports.tooltip.extremes": "Extremos",
"reports.tooltip.downtime": "Tiempo de paro",
"reports.tooltip.extraTime": "Tiempo extra",
"reports.csv.section": "sección",
"reports.csv.key": "clave",
"reports.csv.value": "valor",
"reports.pdf.title": "Exportación de reporte",
"reports.pdf.range": "Rango",
"reports.pdf.machine": "Máquina",
"reports.pdf.workOrder": "Orden de trabajo",
"reports.pdf.sku": "SKU",
"reports.pdf.metric": "Métrica",
"reports.pdf.value": "Valor",
"reports.pdf.topLoss": "Principales causas de pérdida",
"reports.pdf.qualitySummary": "Resumen de calidad",
"reports.pdf.cycleDistribution": "Distribución de tiempos de ciclo",
"reports.pdf.notes": "Notas para operaciones",
"reports.pdf.none": "Ninguna",
"settings.title": "Configuración",
"settings.subtitle": "Configuración en vivo para turnos, alertas y valores predeterminados.",
"settings.loading": "Cargando configuración...",
"settings.loadingTeam": "Cargando equipo...",
"settings.refresh": "Actualizar",
"settings.save": "Guardar cambios",
"settings.saving": "Guardando...",
"settings.saved": "Configuración guardada",
"settings.failedLoad": "No se pudo cargar la configuración",
"settings.failedTeam": "No se pudo cargar el equipo",
"settings.failedSave": "No se pudo guardar la configuración",
"settings.unavailable": "La configuración no está disponible.",
"settings.conflict": "La configuración cambió en otro lugar. Actualiza e intenta de nuevo.",
"settings.org.title": "Organización",
"settings.org.plantName": "Nombre de planta",
"settings.org.slug": "Slug",
"settings.org.timeZone": "Zona horaria",
"settings.shiftSchedule": "Turnos",
"settings.shiftSubtitle": "Define turnos activos y compensación de paros.",
"settings.shiftName": "Nombre del turno",
"settings.shiftStart": "Inicio",
"settings.shiftEnd": "Fin",
"settings.shiftEnabled": "Activo",
"settings.shiftAdd": "Agregar turno",
"settings.shiftRemove": "Eliminar",
"settings.shiftComp": "Compensación por cambio de turno",
"settings.lunchBreak": "Comida",
"settings.minutes": "minutos",
"settings.shiftHint": "Máx 3 turnos, HH:mm",
"settings.shiftTo": "a",
"settings.shiftCompLabel": "Compensación por cambio de turno (min)",
"settings.lunchBreakLabel": "Comida (min)",
"settings.shift.defaultName": "Turno {index}",
"settings.thresholds": "Umbrales de alertas",
"settings.thresholdsSubtitle": "Ajusta alertas de salud de producción.",
"settings.thresholds.appliesAll": "Aplica a todas las máquinas",
"settings.thresholds.oee": "Umbral de alerta OEE",
"settings.thresholds.performance": "Umbral de Performance",
"settings.thresholds.qualitySpike": "Delta de pico de calidad",
"settings.thresholds.stoppage": "Multiplicador de paro",
"settings.alerts": "Alertas",
"settings.alertsSubtitle": "Elige qué alertas notificar.",
"settings.alerts.oeeDrop": "Alertas por caída de OEE",
"settings.alerts.oeeDropHelper": "Notificar cuando OEE esté por debajo del umbral",
"settings.alerts.performanceDegradation": "Alertas por baja de Performance",
"settings.alerts.performanceDegradationHelper": "Marcar ciclos lentos prolongados",
"settings.alerts.qualitySpike": "Alertas por picos de calidad",
"settings.alerts.qualitySpikeHelper": "Alertar por picos de scrap",
"settings.alerts.predictive": "Alertas predictivas de caída OEE",
"settings.alerts.predictiveHelper": "Avisar antes de que OEE caiga",
"settings.defaults": "Valores predeterminados de moldes",
"settings.defaults.moldTotal": "Moldes totales",
"settings.defaults.moldActive": "Moldes activos",
"settings.updated": "Actualizado",
"settings.updatedBy": "Actualizado por",
"settings.team": "Miembros del equipo",
"settings.teamTotal": "{count} total",
"settings.teamNone": "Sin miembros del equipo.",
"settings.invites": "Invitaciones",
"settings.inviteEmail": "Correo de invitación",
"settings.inviteRole": "Rol",
"settings.inviteSend": "Crear invitación",
"settings.inviteSending": "Creando...",
"settings.inviteStatus.copied": "Enlace de invitación copiado",
"settings.inviteStatus.emailRequired": "El correo es obligatorio",
"settings.inviteStatus.failed": "No se pudo revocar la invitación",
"settings.inviteStatus.sent": "Correo de invitación enviado",
"settings.inviteStatus.createFailed": "No se pudo crear la invitación",
"settings.inviteStatus.emailFailed": "Invitación creada, falló el correo: {url}",
"settings.inviteNone": "Sin invitaciones pendientes.",
"settings.inviteExpires": "Expira {date}",
"settings.inviteRole.member": "Miembro",
"settings.inviteRole.admin": "Admin",
"settings.inviteRole.owner": "Propietario",
"settings.inviteCopy": "Copiar enlace",
"settings.inviteRevoke": "Revocar",
"settings.role.owner": "Propietario",
"settings.role.admin": "Admin",
"settings.role.member": "Miembro",
"settings.role.inactive": "Inactivo",
"settings.integrations": "Integraciones",
"settings.integrations.webhook": "Webhook URL",
"settings.integrations.erp": "ERP Sync",
"settings.integrations.erpNotConfigured": "No configurado"
}

30
lib/i18n/translations.ts Normal file
View File

@@ -0,0 +1,30 @@
import en from "./en.json";
import esMX from "./es-MX.json";
export type Locale = "en" | "es-MX";
type Dictionary = Record<string, string>;
export const translations: Record<Locale, Dictionary> = {
en,
"es-MX": esMX,
};
export const defaultLocale: Locale = "en";
export function translate(
locale: Locale,
key: string,
vars?: Record<string, string | number>
): string {
const table = translations[locale] ?? translations[defaultLocale];
const fallback = translations[defaultLocale];
let text = table[key] ?? fallback[key] ?? key;
if (vars) {
text = text.replace(/\{(\w+)\}/g, (match, token) => {
const value = vars[token];
return value == null ? match : String(value);
});
}
return text;
}

60
lib/i18n/useI18n.ts Normal file
View File

@@ -0,0 +1,60 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { defaultLocale, Locale, translate } from "./translations";
const LOCALE_COOKIE = "mis_locale";
const LOCALE_EVENT = "mis-locale-change";
function readCookieLocale(): Locale | null {
const match = document.cookie
.split(";")
.map((part) => part.trim())
.find((part) => part.startsWith(`${LOCALE_COOKIE}=`));
if (!match) return null;
const value = match.split("=")[1];
if (value === "es-MX" || value === "en") return value;
return null;
}
function readLocale(): Locale {
const docLang = document.documentElement.getAttribute("lang");
if (docLang === "es-MX" || docLang === "en") return docLang;
return readCookieLocale() ?? defaultLocale;
}
export function useI18n() {
const [locale, setLocale] = useState<Locale>(() => readLocale());
useEffect(() => {
const handler = (event: Event) => {
const detail = (event as CustomEvent).detail;
if (detail === "es-MX" || detail === "en") {
setLocale(detail);
}
};
window.addEventListener(LOCALE_EVENT, handler);
return () => window.removeEventListener(LOCALE_EVENT, handler);
}, []);
const setLocaleAndPersist = useCallback((next: Locale) => {
document.documentElement.setAttribute("lang", next);
document.cookie = `${LOCALE_COOKIE}=${next}; Path=/; Max-Age=31536000; SameSite=Lax`;
setLocale(next);
window.dispatchEvent(new CustomEvent(LOCALE_EVENT, { detail: next }));
}, [setLocale]);
const t = useCallback(
(key: string, vars?: Record<string, string | number>) => translate(locale, key, vars),
[locale]
);
return useMemo(
() => ({
locale,
setLocale: setLocaleAndPersist,
t,
}),
[locale, setLocaleAndPersist, t]
);
}

112
package-lock.json generated
View File

@@ -10,6 +10,7 @@
"dependencies": { "dependencies": {
"@prisma/client": "^6.19.1", "@prisma/client": "^6.19.1",
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"i18n": "^0.15.3",
"lucide-react": "^0.561.0", "lucide-react": "^0.561.0",
"next": "16.0.10", "next": "16.0.10",
"nodemailer": "^7.0.12", "nodemailer": "^7.0.12",
@@ -1776,6 +1777,50 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@jridgewell/sourcemap-codec": "^1.4.14"
} }
}, },
"node_modules/@messageformat/core": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/@messageformat/core/-/core-3.4.0.tgz",
"integrity": "sha512-NgCFubFFIdMWJGN5WuQhHCNmzk7QgiVfrViFxcS99j7F5dDS5EP6raR54I+2ydhe4+5/XTn/YIEppFaqqVWHsw==",
"license": "MIT",
"dependencies": {
"@messageformat/date-skeleton": "^1.0.0",
"@messageformat/number-skeleton": "^1.0.0",
"@messageformat/parser": "^5.1.0",
"@messageformat/runtime": "^3.0.1",
"make-plural": "^7.0.0",
"safe-identifier": "^0.4.1"
}
},
"node_modules/@messageformat/date-skeleton": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@messageformat/date-skeleton/-/date-skeleton-1.1.0.tgz",
"integrity": "sha512-rmGAfB1tIPER+gh3p/RgA+PVeRE/gxuQ2w4snFWPF5xtb5mbWR7Cbw7wCOftcUypbD6HVoxrVdyyghPm3WzP5A==",
"license": "MIT"
},
"node_modules/@messageformat/number-skeleton": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@messageformat/number-skeleton/-/number-skeleton-1.2.0.tgz",
"integrity": "sha512-xsgwcL7J7WhlHJ3RNbaVgssaIwcEyFkBqxHdcdaiJzwTZAWEOD8BuUFxnxV9k5S0qHN3v/KzUpq0IUpjH1seRg==",
"license": "MIT"
},
"node_modules/@messageformat/parser": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/@messageformat/parser/-/parser-5.1.1.tgz",
"integrity": "sha512-3p0YRGCcTUCYvBKLIxtDDyrJ0YijGIwrTRu1DT8gIviIDZru8H23+FkY6MJBzM1n9n20CiM4VeDYuBsrrwnLjg==",
"license": "MIT",
"dependencies": {
"moo": "^0.5.1"
}
},
"node_modules/@messageformat/runtime": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@messageformat/runtime/-/runtime-3.0.2.tgz",
"integrity": "sha512-dkIPDCjXcfhSHgNE1/qV6TeczQZR59Yx0xXeafVKgK3QVWoxc38ljwpksUpnzCGvN151KUbCJTDZVmahtf1YZw==",
"license": "MIT",
"dependencies": {
"make-plural": "^7.0.0"
}
},
"node_modules/@napi-rs/wasm-runtime": { "node_modules/@napi-rs/wasm-runtime": {
"version": "0.2.12", "version": "0.2.12",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz",
@@ -4519,7 +4564,6 @@
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ms": "^2.1.3" "ms": "^2.1.3"
@@ -5472,6 +5516,15 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/fast-printf": {
"version": "1.6.10",
"resolved": "https://registry.npmjs.org/fast-printf/-/fast-printf-1.6.10.tgz",
"integrity": "sha512-GwTgG9O4FVIdShhbVF3JxOgSBY2+ePGsu2V/UONgoCPzF9VY6ZdBMKsHKCYQHZwNk3qNouUolRDsgVxcVA5G1w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=10.0"
}
},
"node_modules/fast-xml-parser": { "node_modules/fast-xml-parser": {
"version": "5.2.5", "version": "5.2.5",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.2.5.tgz", "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.2.5.tgz",
@@ -5904,6 +5957,26 @@
"hermes-estree": "0.25.1" "hermes-estree": "0.25.1"
} }
}, },
"node_modules/i18n": {
"version": "0.15.3",
"resolved": "https://registry.npmjs.org/i18n/-/i18n-0.15.3.tgz",
"integrity": "sha512-tW/AA5R4lJZLnd60Agcd0PfXB1C2G7UqTrdNewuv/SIYdxcHkCE8w4Zx1SgCjJ+2BLuAAGIG/KXb/xNYF1lO5Q==",
"license": "MIT",
"dependencies": {
"@messageformat/core": "^3.4.0",
"debug": "^4.4.3",
"fast-printf": "^1.6.10",
"make-plural": "^7.4.0",
"math-interval-parser": "^2.0.1",
"mustache": "^4.2.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/mashpie"
}
},
"node_modules/ignore": { "node_modules/ignore": {
"version": "5.3.2", "version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -6885,6 +6958,21 @@
"@jridgewell/sourcemap-codec": "^1.5.5" "@jridgewell/sourcemap-codec": "^1.5.5"
} }
}, },
"node_modules/make-plural": {
"version": "7.5.0",
"resolved": "https://registry.npmjs.org/make-plural/-/make-plural-7.5.0.tgz",
"integrity": "sha512-0booA+aVYyVFoR67JBHdfVk0U08HmrBH2FrtmBqBa+NldlqXv/G2Z9VQuQq6Wgp2jDWdybEWGfBkk1cq5264WA==",
"license": "Unicode-DFS-2016"
},
"node_modules/math-interval-parser": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/math-interval-parser/-/math-interval-parser-2.0.1.tgz",
"integrity": "sha512-VmlAmb0UJwlvMyx8iPhXUDnVW1F9IrGEd9CIOmv+XL8AErCUUuozoDMrgImvnYt2A+53qVX/tPW6YJurMKYsvA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/math-intrinsics": { "node_modules/math-intrinsics": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -6942,13 +7030,27 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/moo": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/moo/-/moo-0.5.2.tgz",
"integrity": "sha512-iSAJLHYKnX41mKcJKjqvnAN9sf0LMDTXDEvFv+ffuRR9a1MIuXLjMNL6EsnDHSkKLTWNqQQ5uo61P4EbU4NU+Q==",
"license": "BSD-3-Clause"
},
"node_modules/ms": { "node_modules/ms": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/mustache": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz",
"integrity": "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==",
"license": "MIT",
"bin": {
"mustache": "bin/mustache"
}
},
"node_modules/nanoid": { "node_modules/nanoid": {
"version": "3.3.11", "version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
@@ -7817,6 +7919,12 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/safe-identifier": {
"version": "0.4.2",
"resolved": "https://registry.npmjs.org/safe-identifier/-/safe-identifier-0.4.2.tgz",
"integrity": "sha512-6pNbSMW6OhAi9j+N8V+U715yBQsaWJ7eyEUaOrawX+isg5ZxhUlV1NipNtgaKHmFGiABwt+ZF04Ii+3Xjkg+8w==",
"license": "ISC"
},
"node_modules/safe-push-apply": { "node_modules/safe-push-apply": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz",

View File

@@ -11,6 +11,7 @@
"dependencies": { "dependencies": {
"@prisma/client": "^6.19.1", "@prisma/client": "^6.19.1",
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"i18n": "^0.15.3",
"lucide-react": "^0.561.0", "lucide-react": "^0.561.0",
"next": "16.0.10", "next": "16.0.10",
"nodemailer": "^7.0.12", "nodemailer": "^7.0.12",