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 { useEffect, useState } from "react";
|
||||
import { useI18n } from "@/lib/i18n/useI18n";
|
||||
|
||||
type MachineRow = {
|
||||
id: string;
|
||||
@@ -17,11 +18,12 @@ type MachineRow = {
|
||||
};
|
||||
};
|
||||
|
||||
function secondsAgo(ts?: string) {
|
||||
if (!ts) return "never";
|
||||
function secondsAgo(ts: string | undefined, locale: string, fallback: string) {
|
||||
if (!ts) return fallback;
|
||||
const diff = Math.floor((Date.now() - new Date(ts).getTime()) / 1000);
|
||||
if (diff < 60) return `${diff}s ago`;
|
||||
return `${Math.floor(diff / 60)}m ago`;
|
||||
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: "auto" });
|
||||
if (diff < 60) return rtf.format(-diff, "second");
|
||||
return rtf.format(-Math.floor(diff / 60), "minute");
|
||||
}
|
||||
|
||||
function isOffline(ts?: string) {
|
||||
@@ -45,6 +47,7 @@ function badgeClass(status?: string, offline?: boolean) {
|
||||
}
|
||||
|
||||
export default function MachinesPage() {
|
||||
const { t, locale } = useI18n();
|
||||
const [machines, setMachines] = useState<MachineRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
@@ -88,7 +91,7 @@ export default function MachinesPage() {
|
||||
|
||||
async function createMachine() {
|
||||
if (!createName.trim()) {
|
||||
setCreateError("Machine name is required");
|
||||
setCreateError(t("machines.create.error.nameRequired"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -107,7 +110,7 @@ export default function MachinesPage() {
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok || !data.ok) {
|
||||
throw new Error(data.error || "Failed to create machine");
|
||||
throw new Error(data.error || t("machines.create.error.failed"));
|
||||
}
|
||||
|
||||
const nextMachine = {
|
||||
@@ -126,7 +129,7 @@ export default function MachinesPage() {
|
||||
setCreateLocation("");
|
||||
setShowCreate(false);
|
||||
} catch (err: any) {
|
||||
setCreateError(err?.message || "Failed to create machine");
|
||||
setCreateError(err?.message || t("machines.create.error.failed"));
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
@@ -136,12 +139,12 @@ export default function MachinesPage() {
|
||||
try {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopyStatus("Copied");
|
||||
setCopyStatus(t("machines.pairing.copied"));
|
||||
} else {
|
||||
setCopyStatus("Copy not supported");
|
||||
setCopyStatus(t("machines.pairing.copyUnsupported"));
|
||||
}
|
||||
} catch {
|
||||
setCopyStatus("Copy failed");
|
||||
setCopyStatus(t("machines.pairing.copyFailed"));
|
||||
}
|
||||
setTimeout(() => setCopyStatus(null), 2000);
|
||||
}
|
||||
@@ -152,8 +155,8 @@ export default function MachinesPage() {
|
||||
<div className="p-6">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-white">Machines</h1>
|
||||
<p className="text-sm text-zinc-400">Select a machine to view live KPIs.</p>
|
||||
<h1 className="text-2xl font-semibold text-white">{t("machines.title")}</h1>
|
||||
<p className="text-sm text-zinc-400">{t("machines.subtitle")}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -162,13 +165,13 @@ export default function MachinesPage() {
|
||||
onClick={() => setShowCreate((prev) => !prev)}
|
||||
className="rounded-xl border border-emerald-400/40 bg-emerald-500/20 px-4 py-2 text-sm text-emerald-100 hover:bg-emerald-500/30"
|
||||
>
|
||||
{showCreate ? "Cancel" : "Add Machine"}
|
||||
{showCreate ? t("machines.cancel") : t("machines.addMachine")}
|
||||
</button>
|
||||
<Link
|
||||
href="/overview"
|
||||
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>
|
||||
</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="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-white">Add a machine</div>
|
||||
<div className="text-xs text-zinc-400">
|
||||
Generate the machine ID and API key for your Node-RED edge.
|
||||
</div>
|
||||
<div className="text-sm font-semibold text-white">{t("machines.addCardTitle")}</div>
|
||||
<div className="text-xs text-zinc-400">{t("machines.addCardSubtitle")}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid grid-cols-1 gap-3 md:grid-cols-3">
|
||||
<label className="rounded-xl border border-white/10 bg-black/20 p-3 text-xs text-zinc-400">
|
||||
Machine Name
|
||||
{t("machines.field.name")}
|
||||
<input
|
||||
value={createName}
|
||||
onChange={(event) => setCreateName(event.target.value)}
|
||||
@@ -194,7 +195,7 @@ export default function MachinesPage() {
|
||||
/>
|
||||
</label>
|
||||
<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
|
||||
value={createCode}
|
||||
onChange={(event) => setCreateCode(event.target.value)}
|
||||
@@ -202,7 +203,7 @@ export default function MachinesPage() {
|
||||
/>
|
||||
</label>
|
||||
<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
|
||||
value={createLocation}
|
||||
onChange={(event) => setCreateLocation(event.target.value)}
|
||||
@@ -218,8 +219,8 @@ export default function MachinesPage() {
|
||||
disabled={creating}
|
||||
className="rounded-xl border border-emerald-400/40 bg-emerald-500/20 px-4 py-2 text-sm text-emerald-100 hover:bg-emerald-500/30 disabled:opacity-60"
|
||||
>
|
||||
{creating ? "Creating..." : "Create Machine"}
|
||||
</button>
|
||||
{creating ? t("machines.create.loading") : t("machines.create.default")}
|
||||
</button>
|
||||
{createError && <div className="text-xs text-red-200">{createError}</div>}
|
||||
</div>
|
||||
</div>
|
||||
@@ -227,22 +228,22 @@ export default function MachinesPage() {
|
||||
|
||||
{createdMachine && (
|
||||
<div className="mb-6 rounded-2xl border border-emerald-500/20 bg-emerald-500/10 p-5">
|
||||
<div className="text-sm font-semibold text-white">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">
|
||||
Machine: <span className="text-white">{createdMachine.name}</span>
|
||||
{t("machines.pairing.machine")} <span className="text-white">{createdMachine.name}</span>
|
||||
</div>
|
||||
<div className="mt-3 rounded-xl border border-white/10 bg-black/30 p-4">
|
||||
<div className="text-xs uppercase tracking-wide text-zinc-400">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-xs text-zinc-400">
|
||||
Expires{" "}
|
||||
{t("machines.pairing.expires")}{" "}
|
||||
{createdMachine.pairingExpiresAt
|
||||
? new Date(createdMachine.pairingExpiresAt).toLocaleString()
|
||||
: "soon"}
|
||||
? new Date(createdMachine.pairingExpiresAt).toLocaleString(locale)
|
||||
: t("machines.pairing.soon")}
|
||||
</div>
|
||||
</div>
|
||||
<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 className="mt-3 flex flex-wrap items-center gap-3">
|
||||
<button
|
||||
@@ -250,17 +251,17 @@ export default function MachinesPage() {
|
||||
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"
|
||||
>
|
||||
Copy Code
|
||||
{t("machines.pairing.copy")}
|
||||
</button>
|
||||
{copyStatus && <div className="text-xs text-zinc-300">{copyStatus}</div>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && <div className="mb-4 text-sm text-zinc-400">Loading machines…</div>}
|
||||
{loading && <div className="mb-4 text-sm text-zinc-400">{t("machines.loading")}</div>}
|
||||
|
||||
{!loading && machines.length === 0 && (
|
||||
<div className="mb-4 text-sm text-zinc-400">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">
|
||||
@@ -268,8 +269,8 @@ export default function MachinesPage() {
|
||||
const hb = m.latestHeartbeat;
|
||||
const offline = isOffline(hb?.ts);
|
||||
const normalizedStatus = normalizeStatus(hb?.status);
|
||||
const statusLabel = offline ? "OFFLINE" : normalizedStatus || "UNKNOWN";
|
||||
const lastSeen = secondsAgo(hb?.ts);
|
||||
const statusLabel = offline ? t("machines.status.offline") : (normalizedStatus || t("machines.status.unknown"));
|
||||
const lastSeen = secondsAgo(hb?.ts, locale, t("common.never"));
|
||||
|
||||
return (
|
||||
<Link
|
||||
@@ -281,7 +282,7 @@ export default function MachinesPage() {
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-lg font-semibold text-white">{m.name}</div>
|
||||
<div className="mt-1 text-xs text-zinc-400">
|
||||
{m.code ? m.code : "—"} • Last seen {lastSeen}
|
||||
{m.code ? m.code : t("common.na")} - {t("machines.lastSeen", { time: lastSeen })}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -295,9 +296,9 @@ export default function MachinesPage() {
|
||||
</span>
|
||||
</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">
|
||||
{offline ? "No heartbeat" : hb?.message ?? "OK"}
|
||||
{offline ? t("machines.status.noHeartbeat") : (hb?.message ?? t("machines.status.ok"))}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
@@ -306,3 +307,7 @@ export default function MachinesPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useI18n } from "@/lib/i18n/useI18n";
|
||||
|
||||
type Heartbeat = {
|
||||
ts: string;
|
||||
@@ -61,12 +62,13 @@ const EVENT_WINDOW_SEC = 1800;
|
||||
const MAX_EVENT_MACHINES = 6;
|
||||
const TOL = 0.10;
|
||||
|
||||
function secondsAgo(ts?: string) {
|
||||
if (!ts) return "never";
|
||||
function secondsAgo(ts: string | undefined, locale: string, fallback: string) {
|
||||
if (!ts) return fallback;
|
||||
const diff = Math.floor((Date.now() - new Date(ts).getTime()) / 1000);
|
||||
if (diff < 60) return `${diff}s ago`;
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
||||
return `${Math.floor(diff / 3600)}h ago`;
|
||||
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: "auto" });
|
||||
if (diff < 60) return rtf.format(-diff, "second");
|
||||
if (diff < 3600) return rtf.format(-Math.floor(diff / 60), "minute");
|
||||
return rtf.format(-Math.floor(diff / 3600), "hour");
|
||||
}
|
||||
|
||||
function isOffline(ts?: string) {
|
||||
@@ -135,6 +137,7 @@ function classifyDerivedEvent(c: CycleRow) {
|
||||
}
|
||||
|
||||
export default function OverviewPage() {
|
||||
const { t, locale } = useI18n();
|
||||
const [machines, setMachines] = useState<MachineRow[]>([]);
|
||||
const [events, setEvents] = useState<EventRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -345,72 +348,93 @@ export default function OverviewPage() {
|
||||
return list;
|
||||
}, [machines]);
|
||||
|
||||
const formatEventType = (eventType?: string) => {
|
||||
if (!eventType) return "";
|
||||
const key = `overview.event.${eventType}`;
|
||||
const label = t(key);
|
||||
return label === key ? eventType : label;
|
||||
};
|
||||
|
||||
const formatSource = (source?: string) => {
|
||||
if (!source) return "";
|
||||
const key = `overview.source.${source}`;
|
||||
const label = t(key);
|
||||
return label === key ? source : label;
|
||||
};
|
||||
|
||||
const formatSeverity = (severity?: string) => {
|
||||
if (!severity) return "";
|
||||
const key = `overview.severity.${severity}`;
|
||||
const label = t(key);
|
||||
return label === key ? severity.toUpperCase() : label;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="mb-6 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-white">Overview</h1>
|
||||
<p className="text-sm text-zinc-400">Fleet pulse, alerts, and top attention items.</p>
|
||||
<h1 className="text-2xl font-semibold text-white">{t("overview.title")}</h1>
|
||||
<p className="text-sm text-zinc-400">{t("overview.subtitle")}</p>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href="/machines"
|
||||
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>
|
||||
</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="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-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">
|
||||
<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 className="rounded-full bg-white/10 px-2 py-0.5 text-zinc-300">
|
||||
Offline {stats.offline}
|
||||
{t("overview.offline")} {stats.offline}
|
||||
</span>
|
||||
<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 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 className="rounded-full bg-red-500/15 px-2 py-0.5 text-red-300">
|
||||
Stop {stats.stopped}
|
||||
{t("overview.stop")} {stats.stopped}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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="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>
|
||||
<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>
|
||||
<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>
|
||||
</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 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-xs text-zinc-400">
|
||||
{eventsLoading ? "Refreshing recent events..." : "Last 30 merged events"}
|
||||
{eventsLoading ? t("overview.eventsRefreshing") : t("overview.eventsLast30")}
|
||||
</div>
|
||||
<div className="mt-4 space-y-2">
|
||||
{events.slice(0, 3).map((e) => (
|
||||
@@ -419,11 +443,13 @@ export default function OverviewPage() {
|
||||
{e.machineName ? `${e.machineName}: ` : ""}
|
||||
{e.title}
|
||||
</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>
|
||||
))}
|
||||
{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}
|
||||
</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="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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
</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="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="text-sm font-semibold text-white">Attention List</div>
|
||||
<div className="text-xs text-zinc-400">{attention.length} shown</div>
|
||||
<div className="text-sm font-semibold text-white">{t("overview.attentionList")}</div>
|
||||
<div className="text-xs text-zinc-400">
|
||||
{attention.length} {t("overview.shown")}
|
||||
</div>
|
||||
</div>
|
||||
{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">
|
||||
{attention.map(({ machine, offline, oee }) => (
|
||||
@@ -467,7 +495,9 @@ export default function OverviewPage() {
|
||||
{machine.code ?? ""} {machine.location ? `- ${machine.location}` : ""}
|
||||
</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 className="mt-2 flex items-center gap-2 text-xs">
|
||||
<span
|
||||
@@ -475,7 +505,7 @@ export default function OverviewPage() {
|
||||
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>
|
||||
{oee != null && (
|
||||
<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="mb-3 flex items-center justify-between">
|
||||
<div className="text-sm font-semibold text-white">Unified Timeline</div>
|
||||
<div className="text-xs text-zinc-400">{events.length} items</div>
|
||||
<div className="text-sm font-semibold text-white">{t("overview.timeline")}</div>
|
||||
<div className="text-xs text-zinc-400">
|
||||
{events.length} {t("overview.items")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{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">
|
||||
{events.map((e) => (
|
||||
@@ -505,16 +537,18 @@ export default function OverviewPage() {
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className={`rounded-full px-2 py-0.5 text-xs ${severityClass(e.severity)}`}>
|
||||
{e.severity.toUpperCase()}
|
||||
{formatSeverity(e.severity)}
|
||||
</span>
|
||||
<span className="rounded-full bg-white/10 px-2 py-0.5 text-xs text-zinc-200">
|
||||
{e.eventType}
|
||||
{formatEventType(e.eventType)}
|
||||
</span>
|
||||
<span className={`rounded-full px-2 py-0.5 text-xs ${sourceClass(e.source)}`}>
|
||||
{e.source}
|
||||
{formatSource(e.source)}
|
||||
</span>
|
||||
{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}
|
||||
</div>
|
||||
|
||||
@@ -526,7 +560,9 @@ export default function OverviewPage() {
|
||||
<div className="mt-1 text-sm text-zinc-300">{e.description}</div>
|
||||
) : null}
|
||||
</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>
|
||||
))}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useI18n } from "@/lib/i18n/useI18n";
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
@@ -66,6 +67,7 @@ type ReportPayload = {
|
||||
|
||||
type MachineOption = { id: string; name: string };
|
||||
type FilterOptions = { workOrders: string[]; skus: string[] };
|
||||
type Translator = (key: string, vars?: Record<string, string | number>) => string;
|
||||
|
||||
function fmtPct(v?: number | null) {
|
||||
if (v === null || v === undefined || Number.isNaN(v)) return "--";
|
||||
@@ -102,23 +104,23 @@ function formatTickLabel(ts: string, range: RangeKey) {
|
||||
return `${month}-${day}`;
|
||||
}
|
||||
|
||||
function CycleTooltip({ active, payload }: any) {
|
||||
function CycleTooltip({ active, payload, t }: any) {
|
||||
if (!active || !payload?.length) return null;
|
||||
const p = payload[0]?.payload;
|
||||
if (!p) return null;
|
||||
|
||||
let detail = "";
|
||||
if (p.overflow === "low") {
|
||||
detail = `Below ${p.rangeEnd?.toFixed(1)}s`;
|
||||
detail = `${t("reports.tooltip.below")} ${p.rangeEnd?.toFixed(1)}s`;
|
||||
} else if (p.overflow === "high") {
|
||||
detail = `Above ${p.rangeStart?.toFixed(1)}s`;
|
||||
detail = `${t("reports.tooltip.above")} ${p.rangeStart?.toFixed(1)}s`;
|
||||
} else if (p.rangeStart != null && p.rangeEnd != null) {
|
||||
detail = `${p.rangeStart.toFixed(1)}s - ${p.rangeEnd.toFixed(1)}s`;
|
||||
}
|
||||
|
||||
const extreme =
|
||||
p.overflow && (p.minValue != null || p.maxValue != null)
|
||||
? `Extremes: ${p.minValue?.toFixed(1) ?? "--"}s - ${p.maxValue?.toFixed(1) ?? "--"}s`
|
||||
? `${t("reports.tooltip.extremes")}: ${p.minValue?.toFixed(1) ?? "--"}s - ${p.maxValue?.toFixed(1) ?? "--"}s`
|
||||
: "";
|
||||
|
||||
return (
|
||||
@@ -126,11 +128,11 @@ function CycleTooltip({ active, payload }: any) {
|
||||
<div className="text-sm font-semibold text-white">{p.label}</div>
|
||||
<div className="mt-2 space-y-1 text-xs text-zinc-300">
|
||||
<div>
|
||||
Cycles: <span className="text-white">{p.count}</span>
|
||||
{t("reports.tooltip.cycles")}: <span className="text-white">{p.count}</span>
|
||||
</div>
|
||||
{detail ? (
|
||||
<div>
|
||||
Range: <span className="text-white">{detail}</span>
|
||||
{t("reports.tooltip.range")}: <span className="text-white">{detail}</span>
|
||||
</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;
|
||||
const row = payload[0]?.payload ?? {};
|
||||
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="text-sm font-semibold text-white">{label}</div>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
function buildCsv(report: ReportPayload) {
|
||||
function buildCsv(report: ReportPayload, t: Translator) {
|
||||
const rows = new Map<string, Record<string, string | number>>();
|
||||
const addSeries = (series: ReportTrendPoint[], key: string) => {
|
||||
for (const p of series) {
|
||||
@@ -195,7 +197,9 @@ function buildCsv(report: ReportPayload) {
|
||||
const downtime = report.downtime;
|
||||
|
||||
const sectionLines: string[] = [];
|
||||
sectionLines.push("section,key,value");
|
||||
sectionLines.push(
|
||||
[t("reports.csv.section"), t("reports.csv.key"), t("reports.csv.value")].join(",")
|
||||
);
|
||||
const addRow = (section: string, key: string, value: string | number | null | undefined) => {
|
||||
sectionLines.push(
|
||||
[section, key, value == null ? "" : String(value)]
|
||||
@@ -248,7 +252,8 @@ function downloadText(filename: string, content: string) {
|
||||
function buildPdfHtml(
|
||||
report: ReportPayload,
|
||||
rangeLabel: string,
|
||||
filters: { machine: string; workOrder: string; sku: string }
|
||||
filters: { machine: string; workOrder: string; sku: string },
|
||||
t: Translator
|
||||
) {
|
||||
const summary = report.summary;
|
||||
const downtime = report.downtime;
|
||||
@@ -260,7 +265,7 @@ function buildPdfHtml(
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Report Export</title>
|
||||
<title>${t("reports.pdf.title")}</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; color: #111; margin: 24px; }
|
||||
h1 { margin: 0 0 6px; }
|
||||
@@ -275,8 +280,8 @@ function buildPdfHtml(
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Reports</h1>
|
||||
<div class="meta">Range: ${rangeLabel} | Machine: ${filters.machine} | Work Order: ${filters.workOrder} | SKU: ${filters.sku}</div>
|
||||
<h1>${t("reports.title")}</h1>
|
||||
<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="card">
|
||||
@@ -298,44 +303,44 @@ function buildPdfHtml(
|
||||
</div>
|
||||
|
||||
<div class="card" style="margin-top: 16px;">
|
||||
<div class="label">Top Loss Drivers</div>
|
||||
<div class="label">${t("reports.pdf.topLoss")}</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Metric</th><th>Value</th></tr>
|
||||
<tr><th>${t("reports.pdf.metric")}</th><th>${t("reports.pdf.value")}</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td>Macrostop (sec)</td><td>${downtime.macrostopSec}</td></tr>
|
||||
<tr><td>Microstop (sec)</td><td>${downtime.microstopSec}</td></tr>
|
||||
<tr><td>Slow Cycles</td><td>${downtime.slowCycleCount}</td></tr>
|
||||
<tr><td>Quality Spikes</td><td>${downtime.qualitySpikeCount}</td></tr>
|
||||
<tr><td>Performance Degradation</td><td>${downtime.performanceDegradationCount}</td></tr>
|
||||
<tr><td>OEE Drops</td><td>${downtime.oeeDropCount}</td></tr>
|
||||
<tr><td>${t("reports.loss.macrostop")} (sec)</td><td>${downtime.macrostopSec}</td></tr>
|
||||
<tr><td>${t("reports.loss.microstop")} (sec)</td><td>${downtime.microstopSec}</td></tr>
|
||||
<tr><td>${t("reports.loss.slowCycle")}</td><td>${downtime.slowCycleCount}</td></tr>
|
||||
<tr><td>${t("reports.loss.qualitySpike")}</td><td>${downtime.qualitySpikeCount}</td></tr>
|
||||
<tr><td>${t("reports.loss.perfDegradation")}</td><td>${downtime.performanceDegradationCount}</td></tr>
|
||||
<tr><td>${t("reports.loss.oeeDrop")}</td><td>${downtime.oeeDropCount}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="card" style="margin-top: 16px;">
|
||||
<div class="label">Quality Summary</div>
|
||||
<div class="label">${t("reports.pdf.qualitySummary")}</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Metric</th><th>Value</th></tr>
|
||||
<tr><th>${t("reports.pdf.metric")}</th><th>${t("reports.pdf.value")}</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td>Scrap Rate</td><td>${summary.scrapRate != null ? summary.scrapRate.toFixed(1) + "%" : "--"}</td></tr>
|
||||
<tr><td>Good Total</td><td>${summary.goodTotal ?? "--"}</td></tr>
|
||||
<tr><td>Scrap Total</td><td>${summary.scrapTotal ?? "--"}</td></tr>
|
||||
<tr><td>Target Total</td><td>${summary.targetTotal ?? "--"}</td></tr>
|
||||
<tr><td>Top Scrap SKU</td><td>${summary.topScrapSku ?? "--"}</td></tr>
|
||||
<tr><td>Top Scrap Work Order</td><td>${summary.topScrapWorkOrder ?? "--"}</td></tr>
|
||||
<tr><td>${t("reports.scrapRate")}</td><td>${summary.scrapRate != null ? summary.scrapRate.toFixed(1) + "%" : "--"}</td></tr>
|
||||
<tr><td>${t("overview.good")}</td><td>${summary.goodTotal ?? "--"}</td></tr>
|
||||
<tr><td>${t("overview.scrap")}</td><td>${summary.scrapTotal ?? "--"}</td></tr>
|
||||
<tr><td>${t("overview.target")}</td><td>${summary.targetTotal ?? "--"}</td></tr>
|
||||
<tr><td>${t("reports.topScrapSku")}</td><td>${summary.topScrapSku ?? "--"}</td></tr>
|
||||
<tr><td>${t("reports.topScrapWorkOrder")}</td><td>${summary.topScrapWorkOrder ?? "--"}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="card" style="margin-top: 16px;">
|
||||
<div class="label">Cycle Time Distribution</div>
|
||||
<div class="label">${t("reports.pdf.cycleDistribution")}</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Bin</th><th>Count</th></tr>
|
||||
<tr><th>${t("reports.tooltip.range")}</th><th>${t("reports.tooltip.cycles")}</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${cycleBins
|
||||
@@ -346,8 +351,8 @@ function buildPdfHtml(
|
||||
</div>
|
||||
|
||||
<div class="card" style="margin-top: 16px;">
|
||||
<div class="label">Notes for Ops</div>
|
||||
${insights.length ? `<ul>${insights.map((n) => `<li>${n}</li>`).join("")}</ul>` : "<div>None</div>"}
|
||||
<div class="label">${t("reports.pdf.notes")}</div>
|
||||
${insights.length ? `<ul>${insights.map((n) => `<li>${n}</li>`).join("")}</ul>` : `<div>${t("reports.pdf.none")}</div>`}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -355,6 +360,7 @@ function buildPdfHtml(
|
||||
}
|
||||
|
||||
export default function ReportsPage() {
|
||||
const { t, locale } = useI18n();
|
||||
const [range, setRange] = useState<RangeKey>("24h");
|
||||
const [report, setReport] = useState<ReportPayload | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -366,11 +372,11 @@ export default function ReportsPage() {
|
||||
const [sku, setSku] = useState("");
|
||||
|
||||
const rangeLabel = useMemo(() => {
|
||||
if (range === "24h") return "Last 24 hours";
|
||||
if (range === "7d") return "Last 7 days";
|
||||
if (range === "30d") return "Last 30 days";
|
||||
return "Custom range";
|
||||
}, [range]);
|
||||
if (range === "24h") return t("reports.rangeLabel.last24");
|
||||
if (range === "7d") return t("reports.rangeLabel.last7");
|
||||
if (range === "30d") return t("reports.rangeLabel.last30");
|
||||
return t("reports.rangeLabel.custom");
|
||||
}, [range, t]);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
@@ -400,14 +406,14 @@ export default function ReportsPage() {
|
||||
const json = await res.json();
|
||||
if (!alive) return;
|
||||
if (!res.ok || json?.ok === false) {
|
||||
setError(json?.error ?? "Failed to load reports");
|
||||
setError(json?.error ?? t("reports.error.failed"));
|
||||
setReport(null);
|
||||
} else {
|
||||
setReport(json);
|
||||
}
|
||||
} catch {
|
||||
if (!alive) return;
|
||||
setError("Network error");
|
||||
setError(t("reports.error.network"));
|
||||
setReport(null);
|
||||
} finally {
|
||||
if (alive) setLoading(false);
|
||||
@@ -494,26 +500,31 @@ export default function ReportsPage() {
|
||||
};
|
||||
|
||||
const machineLabel = useMemo(() => {
|
||||
if (!machineId) return "All machines";
|
||||
if (!machineId) return t("reports.filter.allMachines");
|
||||
return machines.find((m) => m.id === machineId)?.name ?? machineId;
|
||||
}, [machineId, machines]);
|
||||
}, [machineId, machines, t]);
|
||||
|
||||
const workOrderLabel = workOrderId || "All work orders";
|
||||
const skuLabel = sku || "All SKUs";
|
||||
const workOrderLabel = workOrderId || t("reports.filter.allWorkOrders");
|
||||
const skuLabel = sku || t("reports.filter.allSkus");
|
||||
|
||||
const handleExportCsv = () => {
|
||||
if (!report) return;
|
||||
const csv = buildCsv(report);
|
||||
const csv = buildCsv(report, t);
|
||||
downloadText("reports.csv", csv);
|
||||
};
|
||||
|
||||
const handleExportPdf = () => {
|
||||
if (!report) return;
|
||||
const html = buildPdfHtml(report, rangeLabel, {
|
||||
machine: machineLabel,
|
||||
workOrder: workOrderLabel,
|
||||
sku: skuLabel,
|
||||
});
|
||||
const html = buildPdfHtml(
|
||||
report,
|
||||
rangeLabel,
|
||||
{
|
||||
machine: machineLabel,
|
||||
workOrder: workOrderLabel,
|
||||
sku: skuLabel,
|
||||
},
|
||||
t
|
||||
);
|
||||
|
||||
const win = window.open("", "_blank", "width=900,height=650");
|
||||
if (!win) return;
|
||||
@@ -528,10 +539,8 @@ export default function ReportsPage() {
|
||||
<div className="p-6">
|
||||
<div className="mb-6 flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-white">Reports</h1>
|
||||
<p className="text-sm text-zinc-400">
|
||||
Trends, downtime, and quality analytics across machines.
|
||||
</p>
|
||||
<h1 className="text-2xl font-semibold text-white">{t("reports.title")}</h1>
|
||||
<p className="text-sm text-zinc-400">{t("reports.subtitle")}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -539,26 +548,26 @@ export default function ReportsPage() {
|
||||
onClick={handleExportCsv}
|
||||
className="rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-white hover:bg-white/10"
|
||||
>
|
||||
Export CSV
|
||||
{t("reports.exportCsv")}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleExportPdf}
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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="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>
|
||||
|
||||
<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="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">
|
||||
{(["24h", "7d", "30d", "custom"] as RangeKey[]).map((k) => (
|
||||
<button
|
||||
@@ -577,13 +586,13 @@ export default function ReportsPage() {
|
||||
</div>
|
||||
|
||||
<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
|
||||
value={machineId}
|
||||
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"
|
||||
>
|
||||
<option value="">All machines</option>
|
||||
<option value="">{t("reports.filter.allMachines")}</option>
|
||||
{machines.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.name}
|
||||
@@ -593,12 +602,12 @@ export default function ReportsPage() {
|
||||
</div>
|
||||
|
||||
<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
|
||||
list="work-order-list"
|
||||
value={workOrderId}
|
||||
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"
|
||||
/>
|
||||
<datalist id="work-order-list">
|
||||
@@ -609,12 +618,12 @@ export default function ReportsPage() {
|
||||
</div>
|
||||
|
||||
<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
|
||||
list="sku-list"
|
||||
value={sku}
|
||||
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"
|
||||
/>
|
||||
<datalist id="sku-list">
|
||||
@@ -627,7 +636,7 @@ export default function ReportsPage() {
|
||||
</div>
|
||||
|
||||
<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 && (
|
||||
<div className="rounded-2xl border border-red-500/20 bg-red-500/10 p-4 text-sm text-red-200">
|
||||
{error}
|
||||
@@ -646,7 +655,7 @@ export default function ReportsPage() {
|
||||
<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-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>
|
||||
))}
|
||||
@@ -654,19 +663,23 @@ export default function ReportsPage() {
|
||||
|
||||
<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="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">
|
||||
{oeeSeries.length ? (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={oeeSeries}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.08)" />
|
||||
<XAxis dataKey="label" tick={{ fill: "#a1a1aa" }} />
|
||||
<YAxis domain={[0, 100]} tick={{ fill: "#a1a1aa" }} />
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--app-chart-grid)" />
|
||||
<XAxis dataKey="label" tick={{ fill: "var(--app-chart-tick)" }} />
|
||||
<YAxis domain={[0, 100]} tick={{ fill: "var(--app-chart-tick)" }} />
|
||||
<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) => {
|
||||
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"]}
|
||||
/>
|
||||
@@ -675,22 +688,22 @@ export default function ReportsPage() {
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<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 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">
|
||||
{downtimeSeries.length ? (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={downtimeSeries}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.08)" />
|
||||
<XAxis dataKey="name" tick={{ fill: "#a1a1aa" }} />
|
||||
<YAxis tick={{ fill: "#a1a1aa" }} />
|
||||
<Tooltip content={<DowntimeTooltip />} />
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--app-chart-grid)" />
|
||||
<XAxis dataKey="name" tick={{ fill: "var(--app-chart-tick)" }} />
|
||||
<YAxis tick={{ fill: "var(--app-chart-tick)" }} />
|
||||
<Tooltip content={<DowntimeTooltip t={t} />} />
|
||||
<Bar dataKey="value" radius={[10, 10, 0, 0]} isAnimationActive={false}>
|
||||
{downtimeSeries.map((row, idx) => (
|
||||
<Cell key={`${row.name}-${idx}`} fill={downtimeColors[row.name] ?? "#94a3b8"} />
|
||||
@@ -700,7 +713,7 @@ export default function ReportsPage() {
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-sm text-zinc-500">
|
||||
No downtime data yet.
|
||||
{t("reports.noTrend")}
|
||||
</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="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">
|
||||
{cycleHistogram.length ? (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={cycleHistogram}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.08)" />
|
||||
<XAxis dataKey="label" tick={{ fill: "#a1a1aa", fontSize: 10 }} />
|
||||
<YAxis tick={{ fill: "#a1a1aa" }} />
|
||||
<Tooltip content={<CycleTooltip />} />
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--app-chart-grid)" />
|
||||
<XAxis dataKey="label" tick={{ fill: "var(--app-chart-tick)", fontSize: 10 }} />
|
||||
<YAxis tick={{ fill: "var(--app-chart-tick)" }} />
|
||||
<Tooltip content={<CycleTooltip t={t} />} />
|
||||
<Bar dataKey="count" radius={[8, 8, 0, 0]} fill="#60a5fa" isAnimationActive={false} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<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 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">
|
||||
{scrapSeries.length ? (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={scrapSeries}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.08)" />
|
||||
<XAxis dataKey="label" tick={{ fill: "#a1a1aa" }} />
|
||||
<YAxis domain={[0, 100]} tick={{ fill: "#a1a1aa" }} />
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--app-chart-grid)" />
|
||||
<XAxis dataKey="label" tick={{ fill: "var(--app-chart-tick)" }} />
|
||||
<YAxis domain={[0, 100]} tick={{ fill: "var(--app-chart-tick)" }} />
|
||||
<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) => {
|
||||
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} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<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 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">
|
||||
{[
|
||||
{ label: "Macrostop", value: fmtDuration(downtime?.macrostopSec) },
|
||||
{ label: "Microstop", value: fmtDuration(downtime?.microstopSec) },
|
||||
{ label: "Slow Cycle", value: downtime ? `${downtime.slowCycleCount}` : "--" },
|
||||
{ label: "Quality Spike", value: downtime ? `${downtime.qualitySpikeCount}` : "--" },
|
||||
{ label: "OEE Drop", value: downtime ? `${downtime.oeeDropCount}` : "--" },
|
||||
{ label: t("reports.loss.macrostop"), value: fmtDuration(downtime?.macrostopSec) },
|
||||
{ label: t("reports.loss.microstop"), value: fmtDuration(downtime?.microstopSec) },
|
||||
{ label: t("reports.loss.slowCycle"), value: downtime ? `${downtime.slowCycleCount}` : "--" },
|
||||
{ label: t("reports.loss.qualitySpike"), value: downtime ? `${downtime.qualitySpikeCount}` : "--" },
|
||||
{ label: t("reports.loss.oeeDrop"), value: downtime ? `${downtime.oeeDropCount}` : "--" },
|
||||
{
|
||||
label: "Perf Degradation",
|
||||
label: t("reports.loss.perfDegradation"),
|
||||
value: downtime ? `${downtime.performanceDegradationCount}` : "--",
|
||||
},
|
||||
].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="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="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">
|
||||
{summary?.scrapRate != null ? fmtPct(summary.scrapRate) : "--"}
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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="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 ? (
|
||||
<div className="space-y-2">
|
||||
{report.insights.map((note, idx) => (
|
||||
@@ -812,7 +829,7 @@ export default function ReportsPage() {
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div>No insights yet. Generate reports after data collection.</div>
|
||||
<div>{t("reports.notes.none")}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useI18n } from "@/lib/i18n/useI18n";
|
||||
|
||||
type Shift = {
|
||||
name: string;
|
||||
@@ -63,8 +64,7 @@ type InviteRow = {
|
||||
expiresAt: string;
|
||||
};
|
||||
|
||||
const DEFAULT_SHIFT: Shift = {
|
||||
name: "Shift 1",
|
||||
const DEFAULT_SHIFT: Omit<Shift, "name"> = {
|
||||
start: "06:00",
|
||||
end: "15:00",
|
||||
enabled: true,
|
||||
@@ -75,7 +75,7 @@ const DEFAULT_SETTINGS: SettingsPayload = {
|
||||
version: 0,
|
||||
timezone: "UTC",
|
||||
shiftSchedule: {
|
||||
shifts: [DEFAULT_SHIFT],
|
||||
shifts: [],
|
||||
shiftChangeCompensationMin: 10,
|
||||
lunchBreakMin: 30,
|
||||
},
|
||||
@@ -111,22 +111,30 @@ async function readResponse(response: Response) {
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeShift(raw: any, index: number): Shift {
|
||||
const name = String(raw?.name || `Shift ${index + 1}`);
|
||||
function normalizeShift(raw: any, index: number, fallbackName: string): Shift {
|
||||
const name = String(raw?.name || fallbackName);
|
||||
const start = String(raw?.start || raw?.startTime || DEFAULT_SHIFT.start);
|
||||
const end = String(raw?.end || raw?.endTime || DEFAULT_SHIFT.end);
|
||||
const enabled = raw?.enabled !== false;
|
||||
return { name, start, end, enabled };
|
||||
}
|
||||
|
||||
function normalizeSettings(raw: any): SettingsPayload {
|
||||
if (!raw || typeof raw !== "object") return { ...DEFAULT_SETTINGS };
|
||||
function normalizeSettings(raw: any, fallbackName: (index: number) => string): SettingsPayload {
|
||||
if (!raw || typeof raw !== "object") {
|
||||
return {
|
||||
...DEFAULT_SETTINGS,
|
||||
shiftSchedule: {
|
||||
...DEFAULT_SETTINGS.shiftSchedule,
|
||||
shifts: [{ name: fallbackName(1), ...DEFAULT_SHIFT }],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const shiftSchedule = raw.shiftSchedule || {};
|
||||
const shiftsRaw = Array.isArray(shiftSchedule.shifts) ? shiftSchedule.shifts : [];
|
||||
const shifts = shiftsRaw.length
|
||||
? shiftsRaw.map((s: any, idx: number) => normalizeShift(s, idx))
|
||||
: [DEFAULT_SHIFT];
|
||||
? shiftsRaw.map((s: any, idx: number) => normalizeShift(s, idx, fallbackName(idx + 1)))
|
||||
: [{ name: fallbackName(1), ...DEFAULT_SHIFT }];
|
||||
|
||||
return {
|
||||
orgId: String(raw.orgId || ""),
|
||||
@@ -207,11 +215,12 @@ function Toggle({
|
||||
}
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { t, locale } = useI18n();
|
||||
const [draft, setDraft] = useState<SettingsPayload | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
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 [members, setMembers] = useState<MemberRow[]>([]);
|
||||
const [invites, setInvites] = useState<InviteRow[]>([]);
|
||||
@@ -221,6 +230,10 @@ export default function SettingsPage() {
|
||||
const [inviteRole, setInviteRole] = useState("MEMBER");
|
||||
const [inviteStatus, setInviteStatus] = useState<string | null>(null);
|
||||
const [inviteSubmitting, setInviteSubmitting] = useState(false);
|
||||
const defaultShiftName = useCallback(
|
||||
(index: number) => t("settings.shift.defaultName", { index }),
|
||||
[t]
|
||||
);
|
||||
|
||||
const loadSettings = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -229,18 +242,17 @@ export default function SettingsPage() {
|
||||
const response = await fetch("/api/settings", { cache: "no-store" });
|
||||
const { data, text } = await readResponse(response);
|
||||
if (!response.ok || !data?.ok) {
|
||||
const message =
|
||||
data?.error || data?.message || text || `Failed to load settings (${response.status})`;
|
||||
const message = data?.error || data?.message || text || t("settings.failedLoad");
|
||||
throw new Error(message);
|
||||
}
|
||||
const next = normalizeSettings(data.settings);
|
||||
const next = normalizeSettings(data.settings, defaultShiftName);
|
||||
setDraft(next);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load settings");
|
||||
setError(err instanceof Error ? err.message : t("settings.failedLoad"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
}, [defaultShiftName, t]);
|
||||
|
||||
const buildInviteUrl = useCallback((token: string) => {
|
||||
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 { data, text } = await readResponse(response);
|
||||
if (!response.ok || !data?.ok) {
|
||||
const message =
|
||||
data?.error || data?.message || text || `Failed to load team (${response.status})`;
|
||||
const message = data?.error || data?.message || text || t("settings.failedTeam");
|
||||
throw new Error(message);
|
||||
}
|
||||
setOrgInfo(data.org ?? null);
|
||||
setMembers(Array.isArray(data.members) ? data.members : []);
|
||||
setInvites(Array.isArray(data.invites) ? data.invites : []);
|
||||
} catch (err) {
|
||||
setTeamError(err instanceof Error ? err.message : "Failed to load team");
|
||||
setTeamError(err instanceof Error ? err.message : t("settings.failedTeam"));
|
||||
} finally {
|
||||
setTeamLoading(false);
|
||||
}
|
||||
}, []);
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
loadSettings();
|
||||
@@ -295,10 +306,8 @@ export default function SettingsPage() {
|
||||
if (prev.shiftSchedule.shifts.length >= 3) return prev;
|
||||
const nextIndex = prev.shiftSchedule.shifts.length + 1;
|
||||
const newShift: Shift = {
|
||||
name: `Shift ${nextIndex}`,
|
||||
start: DEFAULT_SHIFT.start,
|
||||
end: DEFAULT_SHIFT.end,
|
||||
enabled: true,
|
||||
name: defaultShiftName(nextIndex),
|
||||
...DEFAULT_SHIFT,
|
||||
};
|
||||
return {
|
||||
...prev,
|
||||
@@ -308,7 +317,7 @@ export default function SettingsPage() {
|
||||
},
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
}, [defaultShiftName]);
|
||||
|
||||
const removeShift = useCallback((index: number) => {
|
||||
setDraft((prev) => {
|
||||
@@ -403,7 +412,7 @@ export default function SettingsPage() {
|
||||
try {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(url);
|
||||
setInviteStatus("Invite link copied");
|
||||
setInviteStatus(t("settings.inviteStatus.copied"));
|
||||
} else {
|
||||
setInviteStatus(url);
|
||||
}
|
||||
@@ -411,7 +420,7 @@ export default function SettingsPage() {
|
||||
setInviteStatus(url);
|
||||
}
|
||||
},
|
||||
[buildInviteUrl]
|
||||
[buildInviteUrl, t]
|
||||
);
|
||||
|
||||
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 { data, text } = await readResponse(response);
|
||||
if (!response.ok || !data?.ok) {
|
||||
const message =
|
||||
data?.error || data?.message || text || `Failed to revoke invite (${response.status})`;
|
||||
const message = data?.error || data?.message || text || t("settings.inviteStatus.failed");
|
||||
throw new Error(message);
|
||||
}
|
||||
setInvites((prev) => prev.filter((invite) => invite.id !== inviteId));
|
||||
} 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 () => {
|
||||
if (!inviteEmail.trim()) {
|
||||
setInviteStatus("Email is required");
|
||||
setInviteStatus(t("settings.inviteStatus.emailRequired"));
|
||||
return;
|
||||
}
|
||||
setInviteSubmitting(true);
|
||||
@@ -445,8 +453,7 @@ export default function SettingsPage() {
|
||||
});
|
||||
const { data, text } = await readResponse(response);
|
||||
if (!response.ok || !data?.ok) {
|
||||
const message =
|
||||
data?.error || data?.message || text || `Failed to create invite (${response.status})`;
|
||||
const message = data?.error || data?.message || text || t("settings.inviteStatus.createFailed");
|
||||
throw new Error(message);
|
||||
}
|
||||
const nextInvite = data.invite;
|
||||
@@ -454,19 +461,19 @@ export default function SettingsPage() {
|
||||
setInvites((prev) => [nextInvite, ...prev.filter((invite) => invite.id !== nextInvite.id)]);
|
||||
const inviteUrl = buildInviteUrl(nextInvite.token);
|
||||
if (data.emailSent === false) {
|
||||
setInviteStatus(`Invite created, email failed: ${inviteUrl}`);
|
||||
setInviteStatus(t("settings.inviteStatus.emailFailed", { url: inviteUrl }));
|
||||
} else {
|
||||
setInviteStatus("Invite email sent");
|
||||
setInviteStatus(t("settings.inviteStatus.sent"));
|
||||
}
|
||||
}
|
||||
setInviteEmail("");
|
||||
await loadTeam();
|
||||
} catch (err) {
|
||||
setInviteStatus(err instanceof Error ? err.message : "Failed to create invite");
|
||||
setInviteStatus(err instanceof Error ? err.message : t("settings.inviteStatus.createFailed"));
|
||||
} finally {
|
||||
setInviteSubmitting(false);
|
||||
}
|
||||
}, [buildInviteUrl, inviteEmail, inviteRole, loadTeam]);
|
||||
}, [buildInviteUrl, inviteEmail, inviteRole, loadTeam, t]);
|
||||
|
||||
const saveSettings = useCallback(async () => {
|
||||
if (!draft) return;
|
||||
@@ -490,33 +497,43 @@ export default function SettingsPage() {
|
||||
const { data, text } = await readResponse(response);
|
||||
if (!response.ok || !data?.ok) {
|
||||
if (response.status === 409) {
|
||||
throw new Error("Settings changed elsewhere. Refresh and try again.");
|
||||
throw new Error(t("settings.conflict"));
|
||||
}
|
||||
const message =
|
||||
data?.error || data?.message || text || `Failed to save settings (${response.status})`;
|
||||
const message = data?.error || data?.message || text || t("settings.failedSave");
|
||||
throw new Error(message);
|
||||
}
|
||||
const next = normalizeSettings(data.settings);
|
||||
const next = normalizeSettings(data.settings, defaultShiftName);
|
||||
setDraft(next);
|
||||
setSaveStatus("Saved");
|
||||
setSaveStatus("saved");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to save settings");
|
||||
setError(err instanceof Error ? err.message : t("settings.failedSave"));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [draft]);
|
||||
}, [defaultShiftName, draft, t]);
|
||||
|
||||
const statusLabel = useMemo(() => {
|
||||
if (loading) return "Loading settings...";
|
||||
if (saving) return "Saving...";
|
||||
return saveStatus;
|
||||
}, [loading, saving, saveStatus]);
|
||||
if (loading) return t("settings.loading");
|
||||
if (saving) return t("settings.saving");
|
||||
if (saveStatus === "saved") return t("settings.saved");
|
||||
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) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<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>
|
||||
);
|
||||
@@ -524,11 +541,11 @@ export default function SettingsPage() {
|
||||
|
||||
if (!draft) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="rounded-2xl border border-red-500/30 bg-red-500/10 p-6 text-sm text-red-200">
|
||||
{error || "Settings are unavailable."}
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<div className="rounded-2xl border border-red-500/30 bg-red-500/10 p-6 text-sm text-red-200">
|
||||
{error || t("settings.unavailable")}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -536,22 +553,22 @@ export default function SettingsPage() {
|
||||
<div className="p-6">
|
||||
<div className="mb-6 flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-white">Settings</h1>
|
||||
<p className="text-sm text-zinc-400">Live configuration for shifts, alerts, and defaults.</p>
|
||||
<h1 className="text-2xl font-semibold text-white">{t("settings.title")}</h1>
|
||||
<p className="text-sm text-zinc-400">{t("settings.subtitle")}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={loadSettings}
|
||||
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
|
||||
onClick={saveSettings}
|
||||
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"
|
||||
>
|
||||
Save Changes
|
||||
{t("settings.save")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -564,17 +581,19 @@ export default function SettingsPage() {
|
||||
|
||||
<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="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="rounded-xl border border-white/10 bg-black/20 p-3">
|
||||
<div className="text-xs text-zinc-400">Plant Name</div>
|
||||
<div className="mt-1 text-sm text-zinc-300">{orgInfo?.name || "Loading..."}</div>
|
||||
<div className="text-xs text-zinc-400">{t("settings.org.plantName")}</div>
|
||||
<div className="mt-1 text-sm text-zinc-300">{orgInfo?.name || t("common.loading")}</div>
|
||||
{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}
|
||||
</div>
|
||||
<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
|
||||
value={draft.timezone || ""}
|
||||
onChange={(event) =>
|
||||
@@ -591,20 +610,21 @@ export default function SettingsPage() {
|
||||
/>
|
||||
</label>
|
||||
<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 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="text-sm font-semibold text-white">Alert Thresholds</div>
|
||||
<div className="text-xs text-zinc-400">Applies to all machines</div>
|
||||
<div className="text-sm font-semibold text-white">{t("settings.thresholds")}</div>
|
||||
<div className="text-xs text-zinc-400">{t("settings.thresholds.appliesAll")}</div>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
OEE Alert (%)
|
||||
{t("settings.thresholds.oee")} (%)
|
||||
<input
|
||||
type="number"
|
||||
min={50}
|
||||
@@ -617,7 +637,7 @@ export default function SettingsPage() {
|
||||
/>
|
||||
</label>
|
||||
<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
|
||||
type="number"
|
||||
min={1.1}
|
||||
@@ -631,7 +651,7 @@ export default function SettingsPage() {
|
||||
/>
|
||||
</label>
|
||||
<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
|
||||
type="number"
|
||||
min={50}
|
||||
@@ -644,7 +664,7 @@ export default function SettingsPage() {
|
||||
/>
|
||||
</label>
|
||||
<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
|
||||
type="number"
|
||||
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="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="text-sm font-semibold text-white">Shift Schedule</div>
|
||||
<div className="text-xs text-zinc-400">Max 3 shifts, HH:mm</div>
|
||||
<div className="text-sm font-semibold text-white">{t("settings.shiftSchedule")}</div>
|
||||
<div className="text-xs text-zinc-400">{t("settings.shiftHint")}</div>
|
||||
</div>
|
||||
|
||||
<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}
|
||||
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>
|
||||
</div>
|
||||
<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 })}
|
||||
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
|
||||
type="time"
|
||||
value={shift.end}
|
||||
@@ -707,7 +727,7 @@ export default function SettingsPage() {
|
||||
onChange={(event) => updateShift(index, { enabled: event.target.checked })}
|
||||
className="h-4 w-4 rounded border border-white/20 bg-black/20"
|
||||
/>
|
||||
Enabled
|
||||
{t("settings.shiftEnabled")}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -720,11 +740,11 @@ export default function SettingsPage() {
|
||||
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"
|
||||
>
|
||||
Add Shift
|
||||
{t("settings.shiftAdd")}
|
||||
</button>
|
||||
<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">
|
||||
Shift Change Compensation (min)
|
||||
{t("settings.shiftCompLabel")}
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
@@ -737,7 +757,7 @@ export default function SettingsPage() {
|
||||
/>
|
||||
</label>
|
||||
<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
|
||||
type="number"
|
||||
min={0}
|
||||
@@ -752,29 +772,29 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<Toggle
|
||||
label="OEE Drop"
|
||||
helper="Notify when OEE falls below threshold"
|
||||
label={t("settings.alerts.oeeDrop")}
|
||||
helper={t("settings.alerts.oeeDropHelper")}
|
||||
enabled={draft.alerts.oeeDropEnabled}
|
||||
onChange={(next) => updateAlerts("oeeDropEnabled", next)}
|
||||
/>
|
||||
<Toggle
|
||||
label="Performance Degradation"
|
||||
helper="Flag prolonged slow cycles"
|
||||
label={t("settings.alerts.performanceDegradation")}
|
||||
helper={t("settings.alerts.performanceDegradationHelper")}
|
||||
enabled={draft.alerts.performanceDegradationEnabled}
|
||||
onChange={(next) => updateAlerts("performanceDegradationEnabled", next)}
|
||||
/>
|
||||
<Toggle
|
||||
label="Quality Spike"
|
||||
helper="Alert on scrap spikes"
|
||||
label={t("settings.alerts.qualitySpike")}
|
||||
helper={t("settings.alerts.qualitySpikeHelper")}
|
||||
enabled={draft.alerts.qualitySpikeEnabled}
|
||||
onChange={(next) => updateAlerts("qualitySpikeEnabled", next)}
|
||||
/>
|
||||
<Toggle
|
||||
label="Predictive OEE Decline"
|
||||
helper="Warn before OEE drops"
|
||||
label={t("settings.alerts.predictive")}
|
||||
helper={t("settings.alerts.predictiveHelper")}
|
||||
enabled={draft.alerts.predictiveOeeDeclineEnabled}
|
||||
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="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">
|
||||
<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
|
||||
type="number"
|
||||
min={0}
|
||||
@@ -797,7 +817,7 @@ export default function SettingsPage() {
|
||||
/>
|
||||
</label>
|
||||
<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
|
||||
type="number"
|
||||
min={0}
|
||||
@@ -810,15 +830,15 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
|
||||
<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="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>
|
||||
<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="mt-1 text-sm text-zinc-300">Not configured</div>
|
||||
<div className="text-xs text-zinc-400">{t("settings.integrations.erp")}</div>
|
||||
<div className="mt-1 text-sm text-zinc-300">{t("settings.integrations.erpNotConfigured")}</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="rounded-2xl border border-white/10 bg-white/5 p-5">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="text-sm font-semibold text-white">Team Members</div>
|
||||
<div className="text-xs text-zinc-400">{members.length} total</div>
|
||||
<div className="text-sm font-semibold text-white">{t("settings.team")}</div>
|
||||
<div className="text-xs text-zinc-400">{t("settings.teamTotal", { count: members.length })}</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 && (
|
||||
<div className="rounded-xl border border-red-500/20 bg-red-500/10 p-3 text-sm text-red-200">
|
||||
{teamError}
|
||||
@@ -839,7 +859,7 @@ export default function SettingsPage() {
|
||||
)}
|
||||
|
||||
{!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 && (
|
||||
@@ -857,11 +877,11 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
<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">
|
||||
{member.role}
|
||||
{formatRole(member.role)}
|
||||
</span>
|
||||
{!member.isActive ? (
|
||||
<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>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -872,10 +892,10 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<label className="rounded-xl border border-white/10 bg-black/20 p-3 text-xs text-zinc-400">
|
||||
Invite Email
|
||||
{t("settings.inviteEmail")}
|
||||
<input
|
||||
value={inviteEmail}
|
||||
onChange={(event) => setInviteEmail(event.target.value)}
|
||||
@@ -883,15 +903,15 @@ export default function SettingsPage() {
|
||||
/>
|
||||
</label>
|
||||
<label className="rounded-xl border border-white/10 bg-black/20 p-3 text-xs text-zinc-400">
|
||||
Role
|
||||
{t("settings.inviteRole")}
|
||||
<select
|
||||
value={inviteRole}
|
||||
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"
|
||||
>
|
||||
<option value="MEMBER">Member</option>
|
||||
<option value="ADMIN">Admin</option>
|
||||
<option value="OWNER">Owner</option>
|
||||
<option value="MEMBER">{t("settings.inviteRole.member")}</option>
|
||||
<option value="ADMIN">{t("settings.inviteRole.admin")}</option>
|
||||
<option value="OWNER">{t("settings.inviteRole.owner")}</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
@@ -903,21 +923,21 @@ export default function SettingsPage() {
|
||||
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"
|
||||
>
|
||||
{inviteSubmitting ? "Creating..." : "Create Invite"}
|
||||
{inviteSubmitting ? t("settings.inviteSending") : t("settings.inviteSend")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={loadTeam}
|
||||
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>
|
||||
{inviteStatus && <div className="text-xs text-zinc-400">{inviteStatus}</div>}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-3">
|
||||
{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) => (
|
||||
<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="truncate text-sm font-semibold text-white">{invite.email}</div>
|
||||
<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 className="flex shrink-0 items-center gap-2">
|
||||
@@ -934,14 +957,14 @@ export default function SettingsPage() {
|
||||
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"
|
||||
>
|
||||
Copy Link
|
||||
{t("settings.inviteCopy")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
>
|
||||
Revoke
|
||||
{t("settings.inviteRevoke")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
159
app/globals.css
159
app/globals.css
@@ -1,31 +1,166 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
color-scheme: dark;
|
||||
--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 {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-background: var(--app-bg);
|
||||
--color-foreground: var(--app-text);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
}
|
||||
:root[data-theme="light"] {
|
||||
color-scheme: light;
|
||||
--app-bg: #f4f6f9;
|
||||
--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 {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
background: var(--app-bg);
|
||||
color: var(--app-text);
|
||||
font-family: var(--font-geist-sans), "Segoe UI", system-ui, sans-serif;
|
||||
}
|
||||
|
||||
/* Hide scrollbar but keep scrolling */
|
||||
.no-scrollbar::-webkit-scrollbar { display: 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 { useRouter } from "next/navigation";
|
||||
import { useI18n } from "@/lib/i18n/useI18n";
|
||||
|
||||
type InviteInfo = {
|
||||
email: string;
|
||||
@@ -22,6 +23,7 @@ export default function InviteAcceptForm({
|
||||
initialError = null,
|
||||
}: InviteAcceptFormProps) {
|
||||
const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
const cleanedToken = token.trim();
|
||||
const [invite, setInvite] = useState<InviteInfo | null>(initialInvite);
|
||||
const [loading, setLoading] = useState(!initialInvite && !initialError);
|
||||
@@ -46,11 +48,11 @@ export default function InviteAcceptForm({
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
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);
|
||||
} catch (err: any) {
|
||||
if (alive) setError(err?.message || "Invite not found");
|
||||
if (alive) setError(err?.message || t("invite.error.notFound"));
|
||||
} finally {
|
||||
if (alive) setLoading(false);
|
||||
}
|
||||
@@ -74,12 +76,12 @@ export default function InviteAcceptForm({
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
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.refresh();
|
||||
} catch (err: any) {
|
||||
setError(err?.message || "Invite acceptance failed");
|
||||
setError(err?.message || t("invite.error.acceptFailed"));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
@@ -88,7 +90,7 @@ export default function InviteAcceptForm({
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-black flex items-center justify-center p-6 text-zinc-300">
|
||||
Loading invite...
|
||||
{t("invite.loading")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -97,7 +99,7 @@ export default function InviteAcceptForm({
|
||||
return (
|
||||
<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">
|
||||
{error || "Invite not found."}
|
||||
{error || t("invite.notFound")}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -106,14 +108,16 @@ export default function InviteAcceptForm({
|
||||
return (
|
||||
<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">
|
||||
<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">
|
||||
Accept the invite for {invite.email} as {invite.role}.
|
||||
{t("invite.acceptCopy", { email: invite.email, role: invite.role })}
|
||||
</p>
|
||||
|
||||
<div className="mt-6 space-y-4">
|
||||
<div>
|
||||
<label className="text-sm text-zinc-300">Your name</label>
|
||||
<label className="text-sm text-zinc-300">{t("invite.yourName")}</label>
|
||||
<input
|
||||
className="mt-1 w-full rounded-xl border border-white/10 bg-black/40 px-4 py-3 text-white outline-none"
|
||||
value={name}
|
||||
@@ -123,7 +127,7 @@ export default function InviteAcceptForm({
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm text-zinc-300">Password</label>
|
||||
<label className="text-sm text-zinc-300">{t("invite.password")}</label>
|
||||
<input
|
||||
type="password"
|
||||
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}
|
||||
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>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import { cookies } from "next/headers";
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({ variable: "--font-geist-sans", subsets: ["latin"] });
|
||||
@@ -10,9 +11,15 @@ export const metadata: Metadata = {
|
||||
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 (
|
||||
<html lang="en">
|
||||
<html lang={locale} data-theme={theme}>
|
||||
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
|
||||
{children}
|
||||
</body>
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useI18n } from "@/lib/i18n/useI18n";
|
||||
|
||||
export default function LoginForm() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const next = searchParams.get("next") || "/machines";
|
||||
const { t } = useI18n();
|
||||
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
@@ -27,14 +29,14 @@ export default function LoginForm() {
|
||||
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok || !data.ok) {
|
||||
setErr(data.error || "Login failed");
|
||||
setErr(data.error || t("login.error.default"));
|
||||
return;
|
||||
}
|
||||
|
||||
router.push(next);
|
||||
router.refresh();
|
||||
} catch (e: any) {
|
||||
setErr(e?.message || "Network error");
|
||||
setErr(e?.message || t("login.error.network"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -43,12 +45,12 @@ export default function LoginForm() {
|
||||
return (
|
||||
<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">
|
||||
<h1 className="text-2xl font-semibold text-white">Control Tower</h1>
|
||||
<p className="mt-1 text-sm text-zinc-400">Sign in to your organization</p>
|
||||
<h1 className="text-2xl font-semibold text-white">{t("login.title")}</h1>
|
||||
<p className="mt-1 text-sm text-zinc-400">{t("login.subtitle")}</p>
|
||||
|
||||
<div className="mt-6 space-y-4">
|
||||
<div>
|
||||
<label className="text-sm text-zinc-300">Email</label>
|
||||
<label className="text-sm text-zinc-300">{t("login.email")}</label>
|
||||
<input
|
||||
className="mt-1 w-full rounded-xl border border-white/10 bg-black/40 px-4 py-3 text-white outline-none"
|
||||
value={email}
|
||||
@@ -58,7 +60,7 @@ export default function LoginForm() {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm text-zinc-300">Password</label>
|
||||
<label className="text-sm text-zinc-300">{t("login.password")}</label>
|
||||
<input
|
||||
type="password"
|
||||
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}
|
||||
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>
|
||||
|
||||
<div className="text-xs text-zinc-500">
|
||||
New here?{" "}
|
||||
{t("login.newHere")}{" "}
|
||||
<a href="/signup" className="text-emerald-300 hover:text-emerald-200">
|
||||
Create an account
|
||||
{t("login.createAccount")}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useI18n } from "@/lib/i18n/useI18n";
|
||||
|
||||
export default function SignupForm() {
|
||||
const { t } = useI18n();
|
||||
const [orgName, setOrgName] = useState("");
|
||||
const [name, setName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
@@ -26,14 +28,14 @@ export default function SignupForm() {
|
||||
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok || !data.ok) {
|
||||
setErr(data.error || "Signup failed");
|
||||
setErr(data.error || t("signup.error.default"));
|
||||
return;
|
||||
}
|
||||
|
||||
setVerificationSent(true);
|
||||
setEmailSent(data.emailSent !== false);
|
||||
} catch (e: any) {
|
||||
setErr(e?.message || "Network error");
|
||||
setErr(e?.message || t("signup.error.network"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -43,24 +45,22 @@ export default function SignupForm() {
|
||||
return (
|
||||
<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">
|
||||
<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">
|
||||
We sent a verification link to <span className="text-white">{email}</span>.
|
||||
{t("signup.verify.sent", { email: email || t("common.na") })}
|
||||
</p>
|
||||
{!emailSent && (
|
||||
<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 className="mt-4 text-xs text-zinc-500">
|
||||
Once verified, you can sign in and invite your team.
|
||||
</div>
|
||||
<div className="mt-4 text-xs text-zinc-500">{t("signup.verify.notice")}</div>
|
||||
<div className="mt-6">
|
||||
<a
|
||||
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"
|
||||
>
|
||||
Back to login
|
||||
{t("signup.verify.back")}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -71,14 +71,12 @@ export default function SignupForm() {
|
||||
return (
|
||||
<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">
|
||||
<h1 className="text-2xl font-semibold text-white">Create your Control Tower</h1>
|
||||
<p className="mt-1 text-sm text-zinc-400">
|
||||
Set up your organization and invite the team.
|
||||
</p>
|
||||
<h1 className="text-2xl font-semibold text-white">{t("signup.title")}</h1>
|
||||
<p className="mt-1 text-sm text-zinc-400">{t("signup.subtitle")}</p>
|
||||
|
||||
<div className="mt-6 space-y-4">
|
||||
<div>
|
||||
<label className="text-sm text-zinc-300">Organization name</label>
|
||||
<label className="text-sm text-zinc-300">{t("signup.orgName")}</label>
|
||||
<input
|
||||
className="mt-1 w-full rounded-xl border border-white/10 bg-black/40 px-4 py-3 text-white outline-none"
|
||||
value={orgName}
|
||||
@@ -89,7 +87,7 @@ export default function SignupForm() {
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label className="text-sm text-zinc-300">Your name</label>
|
||||
<label className="text-sm text-zinc-300">{t("signup.yourName")}</label>
|
||||
<input
|
||||
className="mt-1 w-full rounded-xl border border-white/10 bg-black/40 px-4 py-3 text-white outline-none"
|
||||
value={name}
|
||||
@@ -98,7 +96,7 @@ export default function SignupForm() {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm text-zinc-300">Email</label>
|
||||
<label className="text-sm text-zinc-300">{t("signup.email")}</label>
|
||||
<input
|
||||
className="mt-1 w-full rounded-xl border border-white/10 bg-black/40 px-4 py-3 text-white outline-none"
|
||||
value={email}
|
||||
@@ -109,7 +107,7 @@ export default function SignupForm() {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm text-zinc-300">Password</label>
|
||||
<label className="text-sm text-zinc-300">{t("signup.password")}</label>
|
||||
<input
|
||||
type="password"
|
||||
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}
|
||||
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>
|
||||
|
||||
<div className="text-xs text-zinc-500">
|
||||
Already have access?{" "}
|
||||
{t("signup.alreadyHave")}{" "}
|
||||
<a href="/login" className="text-emerald-300 hover:text-emerald-200">
|
||||
Sign in
|
||||
{t("signup.signIn")}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user