Finalish MVP

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

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,7 @@
import Link from "next/link";
import { 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>
);
}

View File

@@ -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>
))}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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; }

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>