Finalish MVP
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
159
app/globals.css
159
app/globals.css
@@ -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; }
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
378
dictionary_en_es.md
Normal 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
333
lib/i18n/en.json
Normal 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
333
lib/i18n/es-MX.json
Normal 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
30
lib/i18n/translations.ts
Normal 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
60
lib/i18n/useI18n.ts
Normal 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
112
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user