pre-bemis
This commit is contained in:
31
app/(app)/error.tsx
Normal file
31
app/(app)/error.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
export default function AppError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
console.error("[App Error]", error);
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[50vh] flex-col items-center justify-center gap-4 p-6">
|
||||
<h2 className="text-lg font-semibold text-white">Something went wrong</h2>
|
||||
<p className="max-w-md text-center text-sm text-zinc-400">
|
||||
An error occurred while loading this page. Please try again.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => reset()}
|
||||
className="rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-white hover:bg-white/10"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -76,6 +76,8 @@ export default function FinancialClient({
|
||||
const [currencyFilter, setCurrencyFilter] = useState("");
|
||||
const [loading, setLoading] = useState(() => initialMachines.length === 0);
|
||||
const skipInitialImpactRef = useRef(true);
|
||||
const forceRefreshRef = useRef(false);
|
||||
const [refreshSeed, setRefreshSeed] = useState(0);
|
||||
|
||||
const locations = useMemo(() => {
|
||||
const seen = new Set<string>();
|
||||
@@ -158,6 +160,8 @@ export default function FinancialClient({
|
||||
if (locationFilter) params.set("location", locationFilter);
|
||||
if (skuFilter) params.set("sku", skuFilter);
|
||||
if (currencyFilter) params.set("currency", currencyFilter);
|
||||
const forceRefresh = forceRefreshRef.current;
|
||||
if (forceRefresh) params.set("refresh", "1");
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/financial/impact?${params.toString()}`, {
|
||||
@@ -169,6 +173,8 @@ export default function FinancialClient({
|
||||
setImpact(json);
|
||||
} catch {
|
||||
if (alive) setImpact(null);
|
||||
} finally {
|
||||
if (forceRefresh) forceRefreshRef.current = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,7 +183,7 @@ export default function FinancialClient({
|
||||
alive = false;
|
||||
controller.abort();
|
||||
};
|
||||
}, [currencyFilter, initialImpact, locationFilter, machineFilter, range, role, skuFilter]);
|
||||
}, [currencyFilter, initialImpact, locationFilter, machineFilter, range, refreshSeed, role, skuFilter]);
|
||||
|
||||
const selectedSummary = impact?.currencySummaries?.[0] ?? null;
|
||||
const chartData = selectedSummary?.byDay ?? [];
|
||||
@@ -193,6 +199,10 @@ export default function FinancialClient({
|
||||
|
||||
const htmlHref = `/api/financial/export/pdf?${exportQuery}`;
|
||||
const csvHref = `/api/financial/export/excel?${exportQuery}`;
|
||||
const handleRefresh = () => {
|
||||
forceRefreshRef.current = true;
|
||||
setRefreshSeed((prev) => prev + 1);
|
||||
};
|
||||
|
||||
if (role && role !== "OWNER") {
|
||||
return (
|
||||
@@ -212,6 +222,13 @@ export default function FinancialClient({
|
||||
<p className="text-sm text-zinc-400">{t("financial.subtitle")}</p>
|
||||
</div>
|
||||
<div className="flex w-full flex-col gap-3 sm:w-auto sm:flex-row">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRefresh}
|
||||
className="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-center text-sm text-zinc-200 hover:bg-white/10 sm:w-auto"
|
||||
>
|
||||
{t("financial.refresh")}
|
||||
</button>
|
||||
<a
|
||||
className="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-center text-sm text-zinc-200 hover:bg-white/10 sm:w-auto"
|
||||
href={htmlHref}
|
||||
@@ -288,7 +305,7 @@ export default function FinancialClient({
|
||||
</div>
|
||||
|
||||
<div className="mt-4 h-64">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ResponsiveContainer width="100%" height="100%" minHeight={200}>
|
||||
<AreaChart data={chartData}>
|
||||
<defs>
|
||||
<linearGradient id="slowFill" x1="0" y1="0" x2="0" y2="1">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { requireSession } from "@/lib/auth/requireSession";
|
||||
import { computeFinancialImpact } from "@/lib/financial/impact";
|
||||
import { getFinancialImpactCached } from "@/lib/financial/cache";
|
||||
import FinancialClient from "./FinancialClient";
|
||||
|
||||
const RANGE_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
@@ -28,7 +28,7 @@ export default async function FinancialPage() {
|
||||
|
||||
const end = new Date();
|
||||
const start = new Date(end.getTime() - RANGE_MS);
|
||||
const impact = await computeFinancialImpact({
|
||||
const impact = await getFinancialImpactCached({
|
||||
orgId: session.orgId,
|
||||
start,
|
||||
end,
|
||||
|
||||
@@ -1,31 +1,22 @@
|
||||
import { AppShell } from "@/components/layout/AppShell";
|
||||
import { cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
const COOKIE_NAME = "mis_session";
|
||||
|
||||
export default async function AppLayout({ children }: { children: React.ReactNode }) {
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function AppLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const cookieJar = await cookies();
|
||||
const sessionId = cookieJar.get(COOKIE_NAME)?.value;
|
||||
const themeCookie = cookieJar.get("mis_theme")?.value;
|
||||
const initialTheme = themeCookie === "light" ? "light" : "dark";
|
||||
|
||||
if (!sessionId) redirect("/login?next=/machines");
|
||||
|
||||
// validate session in DB (don’t trust cookie existence)
|
||||
const session = await prisma.session.findFirst({
|
||||
where: {
|
||||
id: sessionId,
|
||||
revokedAt: null,
|
||||
expiresAt: { gt: new Date() },
|
||||
},
|
||||
include: { user: true, org: true },
|
||||
});
|
||||
|
||||
if (!session || !session.user?.isActive || !session.user?.emailVerifiedAt) {
|
||||
redirect("/login?next=/machines");
|
||||
}
|
||||
if (!sessionId) redirect("/login");
|
||||
|
||||
return <AppShell initialTheme={initialTheme}>{children}</AppShell>;
|
||||
}
|
||||
|
||||
13
app/(app)/loading.tsx
Normal file
13
app/(app)/loading.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
export default function AppLoading() {
|
||||
return (
|
||||
<div className="p-4 sm:p-6 space-y-6 animate-pulse">
|
||||
<div className="h-7 w-48 rounded-lg bg-white/10" />
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{Array.from({ length: 3 }).map((_, idx) => (
|
||||
<div key={idx} className="h-28 rounded-2xl border border-white/10 bg-white/5" />
|
||||
))}
|
||||
</div>
|
||||
<div className="h-80 rounded-2xl border border-white/10 bg-white/5" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -19,6 +19,7 @@ type MachineRow = {
|
||||
fwVersion?: string | null;
|
||||
};
|
||||
};
|
||||
const LIVE_REFRESH_MS = 5000;
|
||||
|
||||
function secondsAgo(ts: string | undefined, locale: string, fallback: string) {
|
||||
if (!ts) return fallback;
|
||||
@@ -52,7 +53,7 @@ export default function MachinesClient({ initialMachines = [] }: { initialMachin
|
||||
const { t, locale } = useI18n();
|
||||
const router = useRouter();
|
||||
const [machines, setMachines] = useState<MachineRow[]>(() => initialMachines);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loading, setLoading] = useState(() => initialMachines.length === 0);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [createName, setCreateName] = useState("");
|
||||
const [createCode, setCreateCode] = useState("");
|
||||
@@ -69,28 +70,36 @@ export default function MachinesClient({ initialMachines = [] }: { initialMachin
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
async function load() {
|
||||
async function load(initial: boolean) {
|
||||
try {
|
||||
if (!initial && typeof document !== "undefined" && document.hidden) {
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await fetch("/api/machines", { cache: "no-store" });
|
||||
const json = await res.json();
|
||||
if (alive) {
|
||||
setMachines(json.machines ?? []);
|
||||
setLoading(false);
|
||||
if (initial) setLoading(false);
|
||||
}
|
||||
} catch {
|
||||
if (alive) setLoading(false);
|
||||
if (alive && initial) setLoading(false);
|
||||
} finally {
|
||||
if (!alive) return;
|
||||
timer = setTimeout(() => {
|
||||
void load(false);
|
||||
}, LIVE_REFRESH_MS);
|
||||
}
|
||||
}
|
||||
|
||||
load();
|
||||
const t = setInterval(load, 15000);
|
||||
|
||||
void load(initialMachines.length === 0);
|
||||
return () => {
|
||||
alive = false;
|
||||
clearInterval(t);
|
||||
if (timer) clearTimeout(timer);
|
||||
};
|
||||
}, []);
|
||||
}, [initialMachines.length]);
|
||||
|
||||
async function createMachine() {
|
||||
if (!createName.trim()) {
|
||||
|
||||
@@ -128,6 +128,7 @@ const TOL = 0.10;
|
||||
const DEFAULT_MICRO_MULT = 1.5;
|
||||
const DEFAULT_MACRO_MULT = 5;
|
||||
const NORMAL_TOL_SEC = 0.1;
|
||||
const LIVE_REFRESH_MS = 5000;
|
||||
|
||||
const BUCKET = {
|
||||
normal: {
|
||||
@@ -289,6 +290,24 @@ function rowsToWorkOrders(rows: WorkOrderRow[]): WorkOrderUpload[] {
|
||||
return out;
|
||||
}
|
||||
|
||||
function toErrorMessage(value: unknown, fallback: string): string {
|
||||
if (typeof value === "string" && value.trim().length > 0) return value;
|
||||
if (value && typeof value === "object") {
|
||||
const maybeMessage =
|
||||
("message" in value && typeof value.message === "string" && value.message) ||
|
||||
("error" in value && typeof value.error === "string" && value.error) ||
|
||||
("text" in value && typeof value.text === "string" && value.text) ||
|
||||
null;
|
||||
if (maybeMessage && maybeMessage.trim().length > 0) return maybeMessage;
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export default function MachineDetailClient() {
|
||||
const { t, locale } = useI18n();
|
||||
const { screenlessMode } = useScreenlessMode();
|
||||
@@ -319,9 +338,14 @@ export default function MachineDetailClient() {
|
||||
if (!machineId) return;
|
||||
|
||||
let alive = true;
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
async function load() {
|
||||
async function load(initial: boolean) {
|
||||
try {
|
||||
if (!initial && typeof document !== "undefined" && document.hidden) {
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await fetch(`/api/machines/${machineId}?windowSec=3600&events=critical`, {
|
||||
cache: "no-cache",
|
||||
credentials: "include",
|
||||
@@ -329,7 +353,7 @@ export default function MachineDetailClient() {
|
||||
|
||||
if (res.status === 304) {
|
||||
if (!alive) return;
|
||||
setLoading(false);
|
||||
if (initial) setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -338,8 +362,8 @@ export default function MachineDetailClient() {
|
||||
if (!alive) return;
|
||||
|
||||
if (!res.ok || json?.ok === false) {
|
||||
setError(json?.error ?? t("machine.detail.error.failed"));
|
||||
setLoading(false);
|
||||
setError(toErrorMessage(json?.error, t("machine.detail.error.failed")));
|
||||
if (initial) setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -350,19 +374,25 @@ export default function MachineDetailClient() {
|
||||
setThresholds(json.thresholds ?? null);
|
||||
setActiveStoppage(json.activeStoppage ?? null);
|
||||
setError(null);
|
||||
setLoading(false);
|
||||
if (initial) setLoading(false);
|
||||
} catch {
|
||||
if (!alive) return;
|
||||
setError(t("machine.detail.error.network"));
|
||||
setLoading(false);
|
||||
if (initial) {
|
||||
setError(t("machine.detail.error.network"));
|
||||
setLoading(false);
|
||||
}
|
||||
} finally {
|
||||
if (!alive) return;
|
||||
timer = setTimeout(() => {
|
||||
void load(false);
|
||||
}, LIVE_REFRESH_MS);
|
||||
}
|
||||
}
|
||||
|
||||
load();
|
||||
const timer = setInterval(load, 15000);
|
||||
void load(true);
|
||||
return () => {
|
||||
alive = false;
|
||||
clearInterval(timer);
|
||||
if (timer) clearTimeout(timer);
|
||||
};
|
||||
}, [machineId, t]);
|
||||
|
||||
@@ -479,7 +509,7 @@ export default function MachineDetailClient() {
|
||||
} else {
|
||||
setUploadState({
|
||||
status: "error",
|
||||
message: json?.error ?? t("machine.detail.workOrders.uploadError"),
|
||||
message: toErrorMessage(json?.error, t("machine.detail.workOrders.uploadError")),
|
||||
});
|
||||
}
|
||||
event.target.value = "";
|
||||
@@ -508,7 +538,7 @@ export default function MachineDetailClient() {
|
||||
const res = await fetch(`/api/machines/${machineId}`, { method: "DELETE" });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok || !data.ok) {
|
||||
throw new Error(data.error || t("machines.delete.error.failed"));
|
||||
throw new Error(toErrorMessage(data?.error, t("machines.delete.error.failed")));
|
||||
}
|
||||
router.push("/machines");
|
||||
} catch (err: unknown) {
|
||||
@@ -886,9 +916,10 @@ export default function MachineDetailClient() {
|
||||
|
||||
const cycleDerived = useMemo(() => {
|
||||
const rows = cycles ?? [];
|
||||
const fallbackIdeal = cycleTarget && cycleTarget > 0 ? cycleTarget : null;
|
||||
|
||||
const mapped: CycleDerivedRow[] = rows.map((cycle) => {
|
||||
const ideal = cycle.ideal ?? null;
|
||||
const ideal = cycle.ideal ?? fallbackIdeal;
|
||||
const actual = cycle.actual ?? null;
|
||||
const extra = ideal != null && actual != null ? actual - ideal : null;
|
||||
|
||||
@@ -914,7 +945,7 @@ export default function MachineDetailClient() {
|
||||
const avgDeltaPct = deltas.length ? deltas.reduce((a, b) => a + b, 0) / deltas.length : null;
|
||||
|
||||
return { mapped, counts, avgDeltaPct };
|
||||
}, [cycles, thresholds]);
|
||||
}, [cycles, cycleTarget, thresholds]);
|
||||
|
||||
const deviationSeries = useMemo(() => {
|
||||
const last = cycleDerived.mapped.slice(-100);
|
||||
@@ -1313,7 +1344,7 @@ export default function MachineDetailClient() {
|
||||
className="h-[380px] rounded-3xl border border-white/10 bg-black/30 p-4 backdrop-blur"
|
||||
style={{ boxShadow: "var(--app-chart-shadow)" }}
|
||||
>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ResponsiveContainer width="100%" height="100%" minHeight={200}>
|
||||
<ComposedChart data={deviationSeries}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--app-chart-grid)" />
|
||||
<XAxis
|
||||
@@ -1407,7 +1438,7 @@ export default function MachineDetailClient() {
|
||||
className="h-[380px] rounded-3xl border border-white/10 bg-black/30 p-4 backdrop-blur"
|
||||
style={{ boxShadow: "var(--app-chart-shadow)" }}
|
||||
>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ResponsiveContainer width="100%" height="100%" minHeight={200}>
|
||||
<BarChart data={impactAgg.rows}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--app-chart-grid)" />
|
||||
<XAxis dataKey="label" tick={{ fill: "var(--app-chart-tick)" }} />
|
||||
|
||||
22
app/(app)/machines/loading.tsx
Normal file
22
app/(app)/machines/loading.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
export default function MachinesLoading() {
|
||||
return (
|
||||
<div className="p-4 sm:p-6 space-y-6 animate-pulse">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="space-y-2">
|
||||
<div className="h-6 w-36 rounded-lg bg-white/10" />
|
||||
<div className="h-4 w-60 rounded-lg bg-white/5" />
|
||||
</div>
|
||||
<div className="flex w-full gap-2 sm:w-auto">
|
||||
<div className="h-9 w-full rounded-xl border border-emerald-400/40 bg-emerald-500/10 sm:w-36" />
|
||||
<div className="h-9 w-full rounded-xl border border-white/10 bg-white/5 sm:w-32" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{Array.from({ length: 6 }).map((_, idx) => (
|
||||
<div key={idx} className="h-40 rounded-2xl border border-white/10 bg-white/5" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { requireSession } from "@/lib/auth/requireSession";
|
||||
import {
|
||||
fetchLatestHeartbeats,
|
||||
fetchMachineBase,
|
||||
mergeMachineOverviewRows,
|
||||
} from "@/lib/machines/withLatest";
|
||||
import MachinesClient from "./MachinesClient";
|
||||
|
||||
function toIso(value?: Date | null) {
|
||||
@@ -11,34 +15,32 @@ export default async function MachinesPage() {
|
||||
const session = await requireSession();
|
||||
if (!session) redirect("/login?next=/machines");
|
||||
|
||||
const machines = await prisma.machine.findMany({
|
||||
where: { orgId: session.orgId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
code: true,
|
||||
location: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
heartbeats: {
|
||||
orderBy: { tsServer: "desc" },
|
||||
take: 1,
|
||||
select: { ts: true, tsServer: true, status: true, message: true, ip: true, fwVersion: true },
|
||||
},
|
||||
},
|
||||
const machines = await fetchMachineBase(session.orgId);
|
||||
const heartbeats = await fetchLatestHeartbeats(
|
||||
session.orgId,
|
||||
machines.map((machine) => machine.id)
|
||||
);
|
||||
const rows = mergeMachineOverviewRows({
|
||||
machines,
|
||||
heartbeats,
|
||||
includeKpi: false,
|
||||
});
|
||||
|
||||
const initialMachines = machines.map((machine) => ({
|
||||
...machine,
|
||||
latestHeartbeat: machine.heartbeats[0]
|
||||
const initialMachines = rows.map((machine) => ({
|
||||
id: machine.id,
|
||||
name: machine.name,
|
||||
code: machine.code ?? null,
|
||||
location: machine.location ?? null,
|
||||
latestHeartbeat: machine.latestHeartbeat
|
||||
? {
|
||||
...machine.heartbeats[0],
|
||||
ts: toIso(machine.heartbeats[0].ts) ?? "",
|
||||
tsServer: toIso(machine.heartbeats[0].tsServer),
|
||||
ts: toIso(machine.latestHeartbeat.ts) ?? "",
|
||||
tsServer: toIso(machine.latestHeartbeat.tsServer),
|
||||
status: machine.latestHeartbeat.status,
|
||||
message: machine.latestHeartbeat.message ?? null,
|
||||
ip: machine.latestHeartbeat.ip ?? null,
|
||||
fwVersion: machine.latestHeartbeat.fwVersion ?? null,
|
||||
}
|
||||
: null,
|
||||
heartbeats: undefined,
|
||||
}));
|
||||
|
||||
return <MachinesClient initialMachines={initialMachines} />;
|
||||
|
||||
@@ -1,57 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Suspense, lazy, useEffect, useMemo, useState } from "react";
|
||||
import { useI18n } from "@/lib/i18n/useI18n";
|
||||
|
||||
type Heartbeat = {
|
||||
ts: string;
|
||||
tsServer?: string | null;
|
||||
status: string;
|
||||
message?: string | null;
|
||||
ip?: string | null;
|
||||
fwVersion?: string | null;
|
||||
};
|
||||
|
||||
type Kpi = {
|
||||
ts: string;
|
||||
oee?: number | null;
|
||||
availability?: number | null;
|
||||
performance?: number | null;
|
||||
quality?: number | null;
|
||||
workOrderId?: string | null;
|
||||
sku?: string | null;
|
||||
good?: number | null;
|
||||
scrap?: number | null;
|
||||
target?: number | null;
|
||||
cycleTime?: number | null;
|
||||
};
|
||||
|
||||
type MachineRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
code?: string | null;
|
||||
location?: string | null;
|
||||
latestHeartbeat: Heartbeat | null;
|
||||
latestKpi?: Kpi | null;
|
||||
};
|
||||
|
||||
type EventRow = {
|
||||
id: string;
|
||||
ts: string;
|
||||
topic?: string;
|
||||
eventType: string;
|
||||
severity: string;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
requiresAck: boolean;
|
||||
machineId?: string;
|
||||
machineName?: string;
|
||||
source: "ingested";
|
||||
};
|
||||
import type { EventRow, Heartbeat, MachineRow } from "./types";
|
||||
|
||||
const OFFLINE_MS = 30000;
|
||||
const MAX_EVENT_MACHINES = 6;
|
||||
const OverviewTimeline = lazy(() => import("./OverviewTimeline"));
|
||||
|
||||
function secondsAgo(ts: string | undefined, locale: string, fallback: string) {
|
||||
if (!ts) return fallback;
|
||||
@@ -87,17 +43,20 @@ function fmtNum(v?: number | null) {
|
||||
return `${Math.round(v)}`;
|
||||
}
|
||||
|
||||
function severityClass(sev?: string) {
|
||||
const s = (sev ?? "").toLowerCase();
|
||||
if (s === "critical") return "bg-red-500/15 text-red-300";
|
||||
if (s === "warning") return "bg-yellow-500/15 text-yellow-300";
|
||||
if (s === "info") return "bg-blue-500/15 text-blue-300";
|
||||
return "bg-white/10 text-zinc-200";
|
||||
}
|
||||
|
||||
function sourceClass(src: EventRow["source"]) {
|
||||
if (src === "ingested") return "bg-white/10 text-zinc-200";
|
||||
return "bg-white/10 text-zinc-200";
|
||||
function OverviewTimelineSkeleton() {
|
||||
return (
|
||||
<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="h-4 w-32 rounded bg-white/10" />
|
||||
<div className="h-3 w-20 rounded bg-white/5" />
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 4 }).map((_, idx) => (
|
||||
<div key={idx} className="h-20 rounded-xl border border-white/10 bg-black/20" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function OverviewClient({
|
||||
@@ -111,7 +70,7 @@ export default function OverviewClient({
|
||||
const [machines, setMachines] = useState<MachineRow[]>(() => initialMachines);
|
||||
const [events, setEvents] = useState<EventRow[]>(() => initialEvents);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [eventsLoading, setEventsLoading] = useState(false);
|
||||
const [eventsLoading, setEventsLoading] = useState(() => initialEvents.length === 0);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
@@ -119,9 +78,12 @@ export default function OverviewClient({
|
||||
async function load() {
|
||||
try {
|
||||
setEventsLoading(true);
|
||||
const res = await fetch(`/api/overview?events=critical&eventMachines=${MAX_EVENT_MACHINES}`, {
|
||||
cache: "no-cache",
|
||||
});
|
||||
const res = await fetch(
|
||||
`/api/overview?detail=1&events=critical&eventMachines=${MAX_EVENT_MACHINES}`,
|
||||
{
|
||||
cache: "no-cache",
|
||||
}
|
||||
);
|
||||
if (res.status === 304) {
|
||||
if (alive) setLoading(false);
|
||||
return;
|
||||
@@ -166,6 +128,7 @@ export default function OverviewClient({
|
||||
let goodSum = 0;
|
||||
let scrapSum = 0;
|
||||
let targetSum = 0;
|
||||
let hasKpi = false;
|
||||
|
||||
for (const m of machines) {
|
||||
const hb = m.latestHeartbeat;
|
||||
@@ -183,22 +146,35 @@ export default function OverviewClient({
|
||||
if (k?.oee != null) {
|
||||
oeeSum += Number(k.oee);
|
||||
oeeCount += 1;
|
||||
hasKpi = true;
|
||||
}
|
||||
if (k?.availability != null) {
|
||||
availSum += Number(k.availability);
|
||||
availCount += 1;
|
||||
hasKpi = true;
|
||||
}
|
||||
if (k?.performance != null) {
|
||||
perfSum += Number(k.performance);
|
||||
perfCount += 1;
|
||||
hasKpi = true;
|
||||
}
|
||||
if (k?.quality != null) {
|
||||
qualSum += Number(k.quality);
|
||||
qualCount += 1;
|
||||
hasKpi = true;
|
||||
}
|
||||
if (k?.good != null) {
|
||||
goodSum += Number(k.good);
|
||||
hasKpi = true;
|
||||
}
|
||||
if (k?.scrap != null) {
|
||||
scrapSum += Number(k.scrap);
|
||||
hasKpi = true;
|
||||
}
|
||||
if (k?.target != null) {
|
||||
targetSum += Number(k.target);
|
||||
hasKpi = true;
|
||||
}
|
||||
if (k?.good != null) goodSum += Number(k.good);
|
||||
if (k?.scrap != null) scrapSum += Number(k.scrap);
|
||||
if (k?.target != null) targetSum += Number(k.target);
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -212,9 +188,9 @@ export default function OverviewClient({
|
||||
availability: availCount ? availSum / availCount : null,
|
||||
performance: perfCount ? perfSum / perfCount : null,
|
||||
quality: qualCount ? qualSum / qualCount : null,
|
||||
goodSum,
|
||||
scrapSum,
|
||||
targetSum,
|
||||
goodSum: hasKpi ? goodSum : null,
|
||||
scrapSum: hasKpi ? scrapSum : null,
|
||||
targetSum: hasKpi ? targetSum : null,
|
||||
};
|
||||
}, [machines]);
|
||||
|
||||
@@ -238,27 +214,6 @@ export default function OverviewClient({
|
||||
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-4 sm:p-6">
|
||||
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
@@ -409,56 +364,9 @@ export default function OverviewClient({
|
||||
)}
|
||||
</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">
|
||||
<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">{t("overview.noEvents")}</div>
|
||||
) : (
|
||||
<div className="h-[360px] space-y-3 overflow-y-auto no-scrollbar">
|
||||
{events.map((e) => (
|
||||
<div key={`${e.id}-${e.source}`} className="rounded-xl border border-white/10 bg-black/20 p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<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)}`}>
|
||||
{formatSeverity(e.severity)}
|
||||
</span>
|
||||
<span className="rounded-full bg-white/10 px-2 py-0.5 text-xs text-zinc-200">
|
||||
{formatEventType(e.eventType)}
|
||||
</span>
|
||||
<span className={`rounded-full px-2 py-0.5 text-xs ${sourceClass(e.source)}`}>
|
||||
{formatSource(e.source)}
|
||||
</span>
|
||||
{e.requiresAck ? (
|
||||
<span className="rounded-full bg-white/10 px-2 py-0.5 text-xs text-white">
|
||||
{t("overview.ack")}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-2 truncate text-sm font-semibold text-white">
|
||||
{e.machineName ? `${e.machineName}: ` : ""}
|
||||
{e.title}
|
||||
</div>
|
||||
{e.description ? (
|
||||
<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, locale, t("common.never"))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Suspense fallback={<OverviewTimelineSkeleton />}>
|
||||
<OverviewTimeline events={events} eventsLoading={eventsLoading} locale={locale} t={t} />
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
129
app/(app)/overview/OverviewTimeline.tsx
Normal file
129
app/(app)/overview/OverviewTimeline.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
"use client";
|
||||
|
||||
import type { EventRow } from "./types";
|
||||
|
||||
type Translator = (key: string, vars?: Record<string, string | number>) => string;
|
||||
|
||||
function secondsAgo(ts: string | undefined, locale: string, fallback: string) {
|
||||
if (!ts) return fallback;
|
||||
const diff = Math.floor((Date.now() - new Date(ts).getTime()) / 1000);
|
||||
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 severityClass(sev?: string) {
|
||||
const s = (sev ?? "").toLowerCase();
|
||||
if (s === "critical") return "bg-red-500/15 text-red-300";
|
||||
if (s === "warning") return "bg-yellow-500/15 text-yellow-300";
|
||||
if (s === "info") return "bg-blue-500/15 text-blue-300";
|
||||
return "bg-white/10 text-zinc-200";
|
||||
}
|
||||
|
||||
function sourceClass(src: EventRow["source"]) {
|
||||
if (src === "ingested") return "bg-white/10 text-zinc-200";
|
||||
return "bg-white/10 text-zinc-200";
|
||||
}
|
||||
|
||||
function formatEventType(eventType: string | undefined, t: Translator) {
|
||||
if (!eventType) return "";
|
||||
const key = `overview.event.${eventType}`;
|
||||
const label = t(key);
|
||||
return label === key ? eventType : label;
|
||||
}
|
||||
|
||||
function formatSource(source: string | undefined, t: Translator) {
|
||||
if (!source) return "";
|
||||
const key = `overview.source.${source}`;
|
||||
const label = t(key);
|
||||
return label === key ? source : label;
|
||||
}
|
||||
|
||||
function formatSeverity(severity: string | undefined, t: Translator) {
|
||||
if (!severity) return "";
|
||||
const key = `overview.severity.${severity}`;
|
||||
const label = t(key);
|
||||
return label === key ? severity.toUpperCase() : label;
|
||||
}
|
||||
|
||||
export default function OverviewTimeline({
|
||||
events,
|
||||
eventsLoading,
|
||||
locale,
|
||||
t,
|
||||
}: {
|
||||
events: EventRow[];
|
||||
eventsLoading: boolean;
|
||||
locale: string;
|
||||
t: Translator;
|
||||
}) {
|
||||
if (eventsLoading && events.length === 0) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-white/10 bg-white/5 p-5 xl:col-span-2 animate-pulse">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="h-4 w-32 rounded bg-white/10" />
|
||||
<div className="h-3 w-20 rounded bg-white/5" />
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 4 }).map((_, idx) => (
|
||||
<div key={idx} className="h-20 rounded-xl border border-white/10 bg-black/20" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<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">{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">{t("overview.noEvents")}</div>
|
||||
) : (
|
||||
<div className="h-[360px] space-y-3 overflow-y-auto no-scrollbar">
|
||||
{events.map((e) => (
|
||||
<div key={`${e.id}-${e.source}`} className="rounded-xl border border-white/10 bg-black/20 p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<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)}`}>
|
||||
{formatSeverity(e.severity, t)}
|
||||
</span>
|
||||
<span className="rounded-full bg-white/10 px-2 py-0.5 text-xs text-zinc-200">
|
||||
{formatEventType(e.eventType, t)}
|
||||
</span>
|
||||
<span className={`rounded-full px-2 py-0.5 text-xs ${sourceClass(e.source)}`}>
|
||||
{formatSource(e.source, t)}
|
||||
</span>
|
||||
{e.requiresAck ? (
|
||||
<span className="rounded-full bg-white/10 px-2 py-0.5 text-xs text-white">
|
||||
{t("overview.ack")}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-2 truncate text-sm font-semibold text-white">
|
||||
{e.machineName ? `${e.machineName}: ` : ""}
|
||||
{e.title}
|
||||
</div>
|
||||
{e.description ? (
|
||||
<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, locale, t("common.never"))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
30
app/(app)/overview/loading.tsx
Normal file
30
app/(app)/overview/loading.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
export default function OverviewLoading() {
|
||||
return (
|
||||
<div className="p-4 sm:p-6 space-y-6 animate-pulse">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="space-y-2">
|
||||
<div className="h-6 w-40 rounded-lg bg-white/10" />
|
||||
<div className="h-4 w-64 rounded-lg bg-white/5" />
|
||||
</div>
|
||||
<div className="h-9 w-40 rounded-xl border border-white/10 bg-white/5" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 xl:grid-cols-3">
|
||||
{Array.from({ length: 3 }).map((_, idx) => (
|
||||
<div key={idx} className="h-36 rounded-2xl border border-white/10 bg-white/5" />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
{Array.from({ length: 4 }).map((_, idx) => (
|
||||
<div key={idx} className="h-24 rounded-2xl border border-white/10 bg-white/5" />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 xl:grid-cols-3">
|
||||
<div className="h-64 rounded-2xl border border-white/10 bg-white/5 xl:col-span-1" />
|
||||
<div className="h-64 rounded-2xl border border-white/10 bg-white/5 xl:col-span-2" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { requireSession } from "@/lib/auth/requireSession";
|
||||
import { getOverviewData } from "@/lib/overview/getOverviewData";
|
||||
import { getOverviewSummary } from "@/lib/overview/getOverviewSummary";
|
||||
import type { getOverviewData } from "@/lib/overview/getOverviewData";
|
||||
import { logLine } from "@/lib/logger";
|
||||
import OverviewClient from "./OverviewClient";
|
||||
|
||||
function toIso(value?: Date | null) {
|
||||
@@ -11,12 +13,18 @@ export default async function OverviewPage() {
|
||||
const session = await requireSession();
|
||||
if (!session) redirect("/login?next=/overview");
|
||||
|
||||
const { machines, events } = await getOverviewData({
|
||||
orgId: session.orgId,
|
||||
eventsMode: "critical",
|
||||
eventsWindowSec: 21600,
|
||||
eventMachines: 6,
|
||||
});
|
||||
let machines: Awaited<ReturnType<typeof getOverviewData>>["machines"];
|
||||
let events: Awaited<ReturnType<typeof getOverviewData>>["events"] = [];
|
||||
try {
|
||||
const data = await getOverviewSummary({ orgId: session.orgId });
|
||||
machines = data.machines;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const stack = err instanceof Error ? err.stack : undefined;
|
||||
logLine("OverviewPage.getOverviewSummary.error", { message, stack });
|
||||
console.error("[OverviewPage] getOverviewSummary:", err);
|
||||
machines = [];
|
||||
}
|
||||
|
||||
const initialMachines = machines.map((machine) => ({
|
||||
...machine,
|
||||
|
||||
45
app/(app)/overview/types.ts
Normal file
45
app/(app)/overview/types.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
export type Heartbeat = {
|
||||
ts: string;
|
||||
tsServer?: string | null;
|
||||
status: string;
|
||||
message?: string | null;
|
||||
ip?: string | null;
|
||||
fwVersion?: string | null;
|
||||
};
|
||||
|
||||
export type Kpi = {
|
||||
ts: string;
|
||||
oee?: number | null;
|
||||
availability?: number | null;
|
||||
performance?: number | null;
|
||||
quality?: number | null;
|
||||
workOrderId?: string | null;
|
||||
sku?: string | null;
|
||||
good?: number | null;
|
||||
scrap?: number | null;
|
||||
target?: number | null;
|
||||
cycleTime?: number | null;
|
||||
};
|
||||
|
||||
export type MachineRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
code?: string | null;
|
||||
location?: string | null;
|
||||
latestHeartbeat: Heartbeat | null;
|
||||
latestKpi?: Kpi | null;
|
||||
};
|
||||
|
||||
export type EventRow = {
|
||||
id: string;
|
||||
ts: string;
|
||||
topic?: string;
|
||||
eventType: string;
|
||||
severity: string;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
requiresAck: boolean;
|
||||
machineId?: string;
|
||||
machineName?: string;
|
||||
source: "ingested";
|
||||
};
|
||||
249
app/(app)/reports/ReportsCharts.tsx
Normal file
249
app/(app)/reports/ReportsCharts.tsx
Normal file
@@ -0,0 +1,249 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Cell,
|
||||
Line,
|
||||
LineChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
|
||||
type Translator = (key: string, vars?: Record<string, string | number>) => string;
|
||||
type TooltipPayload<T> = { payload?: T; name?: string; value?: number | string };
|
||||
type SimpleTooltipProps<T> = {
|
||||
active?: boolean;
|
||||
payload?: Array<TooltipPayload<T>>;
|
||||
label?: string | number;
|
||||
};
|
||||
|
||||
type ChartPoint = { ts: string; label: string; value: number };
|
||||
type CycleHistogramRow = {
|
||||
label: string;
|
||||
count: number;
|
||||
rangeStart?: number;
|
||||
rangeEnd?: number;
|
||||
overflow?: "low" | "high";
|
||||
minValue?: number;
|
||||
maxValue?: number;
|
||||
};
|
||||
|
||||
function CycleTooltip({ active, payload, t }: SimpleTooltipProps<CycleHistogramRow> & { t: Translator }) {
|
||||
if (!active || !payload?.length) return null;
|
||||
const p = payload[0]?.payload;
|
||||
if (!p) return null;
|
||||
|
||||
let detail = "";
|
||||
if (p.overflow === "low") {
|
||||
detail = `${t("reports.tooltip.below")} ${p.rangeEnd?.toFixed(1)}s`;
|
||||
} else if (p.overflow === "high") {
|
||||
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)
|
||||
? `${t("reports.tooltip.extremes")}: ${p.minValue?.toFixed(1) ?? "--"}s - ${p.maxValue?.toFixed(1) ?? "--"}s`
|
||||
: "";
|
||||
|
||||
return (
|
||||
<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">{p.label}</div>
|
||||
<div className="mt-2 space-y-1 text-xs text-zinc-300">
|
||||
<div>
|
||||
{t("reports.tooltip.cycles")}: <span className="text-white">{p.count}</span>
|
||||
</div>
|
||||
{detail ? (
|
||||
<div>
|
||||
{t("reports.tooltip.range")}: <span className="text-white">{detail}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{extreme ? <div className="text-zinc-400">{extreme}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DowntimeTooltip({
|
||||
active,
|
||||
payload,
|
||||
t,
|
||||
}: SimpleTooltipProps<{ name?: string; value?: number }> & { t: Translator }) {
|
||||
if (!active || !payload?.length) return null;
|
||||
const row = payload[0]?.payload ?? {};
|
||||
const label = row.name ?? payload[0]?.name ?? "";
|
||||
const value = row.value ?? payload[0]?.value ?? 0;
|
||||
|
||||
return (
|
||||
<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">
|
||||
{t("reports.tooltip.downtime")}: <span className="text-white">{Number(value)} min</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ReportsCharts({
|
||||
oeeSeries,
|
||||
downtimeSeries,
|
||||
downtimeColors,
|
||||
cycleHistogram,
|
||||
scrapSeries,
|
||||
lossRows,
|
||||
locale,
|
||||
t,
|
||||
}: {
|
||||
oeeSeries: ChartPoint[];
|
||||
downtimeSeries: { name: string; value: number }[];
|
||||
downtimeColors: Record<string, string>;
|
||||
cycleHistogram: CycleHistogramRow[];
|
||||
scrapSeries: ChartPoint[];
|
||||
lossRows: Array<{ label: string; value: string }>;
|
||||
locale: string;
|
||||
t: Translator;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<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">{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%" minHeight={200}>
|
||||
<LineChart data={oeeSeries}>
|
||||
<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: "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(locale) : "";
|
||||
}}
|
||||
formatter={(val: number | string | undefined) => [
|
||||
val == null ? "--" : `${Number(val).toFixed(1)}%`,
|
||||
"OEE",
|
||||
]}
|
||||
/>
|
||||
<Line type="monotone" dataKey="value" stroke="#34d399" dot={false} strokeWidth={2} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-sm text-zinc-500">
|
||||
{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">{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%" minHeight={200}>
|
||||
<BarChart data={downtimeSeries}>
|
||||
<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"} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-sm text-zinc-500">
|
||||
{t("reports.noTrend")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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">{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%" minHeight={200}>
|
||||
<BarChart data={cycleHistogram}>
|
||||
<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">
|
||||
{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">{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%" minHeight={200}>
|
||||
<LineChart data={scrapSeries}>
|
||||
<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: "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(locale) : "";
|
||||
}}
|
||||
formatter={(val: number | string | undefined) => [
|
||||
val == null ? "--" : `${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">
|
||||
{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">{t("reports.topLossDrivers")}</div>
|
||||
<div className="space-y-3 text-sm text-zinc-300">
|
||||
{lossRows.map((row) => (
|
||||
<div
|
||||
key={row.label}
|
||||
className="flex items-center justify-between rounded-xl border border-white/10 bg-black/20 p-3"
|
||||
>
|
||||
<span>{row.label}</span>
|
||||
<span className="text-xs text-zinc-400">{row.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function LegacyDowntimeParetoPage({
|
||||
export default async function LegacyDowntimeParetoPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Record<string, string | string[] | undefined>;
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
}) {
|
||||
const params = await searchParams;
|
||||
const qs = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(searchParams)) {
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
if (typeof v === "string") qs.set(k, v);
|
||||
else if (Array.isArray(v)) v.forEach((vv) => qs.append(k, vv));
|
||||
}
|
||||
|
||||
@@ -1,19 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Suspense, lazy, useEffect, useMemo, useState } from "react";
|
||||
import { useI18n } from "@/lib/i18n/useI18n";
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Cell,
|
||||
Line,
|
||||
LineChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
|
||||
const ReportsCharts = lazy(() => import("./ReportsCharts"));
|
||||
|
||||
type RangeKey = "24h" | "7d" | "30d" | "custom";
|
||||
|
||||
@@ -68,13 +58,6 @@ type ReportPayload = {
|
||||
type MachineOption = { id: string; name: string };
|
||||
type FilterOptions = { workOrders: string[]; skus: string[] };
|
||||
type Translator = (key: string, vars?: Record<string, string | number>) => string;
|
||||
type TooltipPayload<T> = { payload?: T; name?: string; value?: number | string };
|
||||
type SimpleTooltipProps<T> = {
|
||||
active?: boolean;
|
||||
payload?: Array<TooltipPayload<T>>;
|
||||
label?: string | number;
|
||||
};
|
||||
type CycleHistogramRow = ReportPayload["distribution"]["cycleTime"][number];
|
||||
|
||||
function fmtPct(v?: number | null) {
|
||||
if (v === null || v === undefined || Number.isNaN(v)) return "--";
|
||||
@@ -106,56 +89,20 @@ function formatTickLabel(ts: string, range: RangeKey) {
|
||||
return `${month}-${day}`;
|
||||
}
|
||||
|
||||
function CycleTooltip({ active, payload, t }: SimpleTooltipProps<CycleHistogramRow> & { t: Translator }) {
|
||||
if (!active || !payload?.length) return null;
|
||||
const p = payload[0]?.payload;
|
||||
if (!p) return null;
|
||||
|
||||
let detail = "";
|
||||
if (p.overflow === "low") {
|
||||
detail = `${t("reports.tooltip.below")} ${p.rangeEnd?.toFixed(1)}s`;
|
||||
} else if (p.overflow === "high") {
|
||||
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)
|
||||
? `${t("reports.tooltip.extremes")}: ${p.minValue?.toFixed(1) ?? "--"}s - ${p.maxValue?.toFixed(1) ?? "--"}s`
|
||||
: "";
|
||||
|
||||
function ReportsChartsSkeleton() {
|
||||
return (
|
||||
<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">{p.label}</div>
|
||||
<div className="mt-2 space-y-1 text-xs text-zinc-300">
|
||||
<div>
|
||||
{t("reports.tooltip.cycles")}: <span className="text-white">{p.count}</span>
|
||||
</div>
|
||||
{detail ? (
|
||||
<div>
|
||||
{t("reports.tooltip.range")}: <span className="text-white">{detail}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{extreme ? <div className="text-zinc-400">{extreme}</div> : null}
|
||||
<>
|
||||
<div className="mt-6 grid grid-cols-1 gap-4 xl:grid-cols-2">
|
||||
{Array.from({ length: 2 }).map((_, idx) => (
|
||||
<div key={idx} className="h-[320px] rounded-2xl border border-white/10 bg-white/5" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DowntimeTooltip({ active, payload, t }: SimpleTooltipProps<{ name?: string; value?: number }> & { t: Translator }) {
|
||||
if (!active || !payload?.length) return null;
|
||||
const row = payload[0]?.payload ?? {};
|
||||
const label = row.name ?? payload[0]?.name ?? "";
|
||||
const value = row.value ?? payload[0]?.value ?? 0;
|
||||
|
||||
return (
|
||||
<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">
|
||||
{t("reports.tooltip.downtime")}: <span className="text-white">{Number(value)} min</span>
|
||||
<div className="mt-6 grid grid-cols-1 gap-4 xl:grid-cols-3">
|
||||
{Array.from({ length: 3 }).map((_, idx) => (
|
||||
<div key={idx} className="h-[280px] rounded-2xl border border-white/10 bg-white/5" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -534,6 +481,21 @@ export default function ReportsPage() {
|
||||
Microstop: "#FF7A00",
|
||||
};
|
||||
|
||||
const lossRows = useMemo(
|
||||
() => [
|
||||
{ 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: t("reports.loss.perfDegradation"),
|
||||
value: downtime ? `${downtime.performanceDegradationCount}` : "--",
|
||||
},
|
||||
],
|
||||
[downtime, t]
|
||||
);
|
||||
|
||||
const machineLabel = useMemo(() => {
|
||||
if (!machineId) return t("reports.filter.allMachines");
|
||||
return machines.find((m) => m.id === machineId)?.name ?? machineId;
|
||||
@@ -696,147 +658,18 @@ export default function ReportsPage() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<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">{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="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: "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(locale) : "";
|
||||
}}
|
||||
formatter={(val: number | string | undefined) => [
|
||||
val == null ? "--" : `${Number(val).toFixed(1)}%`,
|
||||
"OEE",
|
||||
]}
|
||||
/>
|
||||
<Line type="monotone" dataKey="value" stroke="#34d399" dot={false} strokeWidth={2} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-sm text-zinc-500">
|
||||
{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">{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="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"} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-sm text-zinc-500">
|
||||
{t("reports.noTrend")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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">{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="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">
|
||||
{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">{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="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: "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(locale) : "";
|
||||
}}
|
||||
formatter={(val: number | string | undefined) => [
|
||||
val == null ? "--" : `${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">
|
||||
{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">{t("reports.topLossDrivers")}</div>
|
||||
<div className="space-y-3 text-sm text-zinc-300">
|
||||
{[
|
||||
{ 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: t("reports.loss.perfDegradation"),
|
||||
value: downtime ? `${downtime.performanceDegradationCount}` : "--",
|
||||
},
|
||||
].map((row) => (
|
||||
<div key={row.label} className="flex items-center justify-between rounded-xl border border-white/10 bg-black/20 p-3">
|
||||
<span>{row.label}</span>
|
||||
<span className="text-xs text-zinc-400">{row.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Suspense fallback={<ReportsChartsSkeleton />}>
|
||||
<ReportsCharts
|
||||
oeeSeries={oeeSeries}
|
||||
downtimeSeries={downtimeSeries}
|
||||
downtimeColors={downtimeColors}
|
||||
cycleHistogram={cycleHistogram}
|
||||
scrapSeries={scrapSeries}
|
||||
lossRows={lossRows}
|
||||
locale={locale}
|
||||
t={t}
|
||||
/>
|
||||
</Suspense>
|
||||
|
||||
<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">
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { AlertsConfig } from "@/components/settings/AlertsConfig";
|
||||
import { FinancialCostConfig } from "@/components/settings/FinancialCostConfig";
|
||||
import { useI18n } from "@/lib/i18n/useI18n";
|
||||
import { SHIFT_OVERRIDE_DAYS, type ShiftOverrideDay } from "@/lib/settings";
|
||||
import { useScreenlessMode } from "@/lib/ui/screenlessMode";
|
||||
|
||||
|
||||
@@ -25,6 +26,7 @@ type SettingsPayload = {
|
||||
|
||||
shiftSchedule: {
|
||||
shifts: Shift[];
|
||||
overrides?: Partial<Record<ShiftOverrideDay, Shift[]>>;
|
||||
shiftChangeCompensationMin: number;
|
||||
lunchBreakMin: number;
|
||||
};
|
||||
@@ -88,6 +90,7 @@ const DEFAULT_SETTINGS: SettingsPayload = {
|
||||
modules: { screenlessMode: false },
|
||||
shiftSchedule: {
|
||||
shifts: [],
|
||||
overrides: {},
|
||||
shiftChangeCompensationMin: 10,
|
||||
lunchBreakMin: 30,
|
||||
},
|
||||
@@ -199,6 +202,21 @@ function normalizeShift(raw: unknown, fallbackName: string): Shift {
|
||||
return { name, start, end, enabled };
|
||||
}
|
||||
|
||||
function normalizeShiftOverrides(
|
||||
raw: unknown,
|
||||
fallbackName: (index: number) => string
|
||||
): Partial<Record<ShiftOverrideDay, Shift[]>> {
|
||||
const record = asRecord(raw);
|
||||
if (!record) return {};
|
||||
const out: Partial<Record<ShiftOverrideDay, Shift[]>> = {};
|
||||
for (const day of SHIFT_OVERRIDE_DAYS) {
|
||||
const shiftsRaw = Array.isArray(record[day]) ? (record[day] as unknown[]) : null;
|
||||
if (!shiftsRaw) continue;
|
||||
out[day] = shiftsRaw.map((shift, idx) => normalizeShift(shift, fallbackName(idx + 1)));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function normalizeSettings(raw: unknown, fallbackName: (index: number) => string): SettingsPayload {
|
||||
const record = asRecord(raw);
|
||||
const modules = asRecord(record?.modules) ?? {};
|
||||
@@ -217,6 +235,7 @@ function normalizeSettings(raw: unknown, fallbackName: (index: number) => string
|
||||
const shifts = shiftsRaw.length
|
||||
? shiftsRaw.map((s, idx) => normalizeShift(s, fallbackName(idx + 1)))
|
||||
: [{ name: fallbackName(1), ...DEFAULT_SHIFT }];
|
||||
const overrides = normalizeShiftOverrides(shiftSchedule.overrides, fallbackName);
|
||||
const thresholds = asRecord(record.thresholds) ?? {};
|
||||
const alerts = asRecord(record.alerts) ?? {};
|
||||
const defaults = asRecord(record.defaults) ?? {};
|
||||
@@ -227,6 +246,7 @@ function normalizeSettings(raw: unknown, fallbackName: (index: number) => string
|
||||
timezone: String(record.timezone ?? DEFAULT_SETTINGS.timezone),
|
||||
shiftSchedule: {
|
||||
shifts,
|
||||
overrides,
|
||||
shiftChangeCompensationMin: Number(
|
||||
shiftSchedule.shiftChangeCompensationMin ?? DEFAULT_SETTINGS.shiftSchedule.shiftChangeCompensationMin
|
||||
),
|
||||
@@ -326,16 +346,26 @@ export default function SettingsPage() {
|
||||
const [inviteStatus, setInviteStatus] = useState<string | null>(null);
|
||||
const [inviteSubmitting, setInviteSubmitting] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<(typeof SETTINGS_TABS)[number]["id"]>("general");
|
||||
const hasMountedRef = useRef(false);
|
||||
const defaultShiftName = useCallback(
|
||||
(index: number) => t("settings.shift.defaultName", { index }),
|
||||
[t]
|
||||
);
|
||||
const shiftOverrideDays = useMemo(
|
||||
() =>
|
||||
SHIFT_OVERRIDE_DAYS.map((day) => ({
|
||||
key: day,
|
||||
label: t(`settings.shiftOverrides.${day}`),
|
||||
})),
|
||||
[t]
|
||||
);
|
||||
|
||||
const loadSettings = useCallback(async () => {
|
||||
const loadSettings = useCallback(async (forceRefresh = false) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await fetch("/api/settings", { cache: "no-store" });
|
||||
const url = forceRefresh ? "/api/settings?refresh=1" : "/api/settings";
|
||||
const response = await fetch(url, { cache: forceRefresh ? "no-store" : "default" });
|
||||
const { data, text } = await readResponse(response);
|
||||
const api = unwrapApiResponse(data);
|
||||
if (!response.ok || !api.ok) {
|
||||
@@ -350,7 +380,7 @@ export default function SettingsPage() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [defaultShiftName, t]);
|
||||
}, [defaultShiftName, t, setScreenlessMode]);
|
||||
|
||||
const buildInviteUrl = useCallback((token: string) => {
|
||||
if (typeof window === "undefined") return `/invite/${token}`;
|
||||
@@ -380,10 +410,14 @@ export default function SettingsPage() {
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
// Only run once on mount to prevent infinite loops from dependency changes
|
||||
useEffect(() => {
|
||||
if (hasMountedRef.current) return;
|
||||
hasMountedRef.current = true;
|
||||
loadSettings();
|
||||
loadTeam();
|
||||
}, [loadSettings, loadTeam]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const updateShift = useCallback((index: number, patch: Partial<Shift>) => {
|
||||
setDraft((prev) => {
|
||||
@@ -448,6 +482,96 @@ export default function SettingsPage() {
|
||||
});
|
||||
}, []);
|
||||
|
||||
const toggleShiftOverride = useCallback((day: ShiftOverrideDay) => {
|
||||
setDraft((prev) => {
|
||||
if (!prev) return prev;
|
||||
const overrides = { ...(prev.shiftSchedule.overrides ?? {}) };
|
||||
if (overrides[day]) {
|
||||
delete overrides[day];
|
||||
} else {
|
||||
overrides[day] = prev.shiftSchedule.shifts.map((shift) => ({ ...shift }));
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
shiftSchedule: {
|
||||
...prev.shiftSchedule,
|
||||
overrides,
|
||||
},
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
const updateShiftOverride = useCallback((day: ShiftOverrideDay, index: number, patch: Partial<Shift>) => {
|
||||
setDraft((prev) => {
|
||||
if (!prev) return prev;
|
||||
const current = prev.shiftSchedule.overrides?.[day];
|
||||
if (!current) return prev;
|
||||
const overrides = { ...(prev.shiftSchedule.overrides ?? {}) };
|
||||
overrides[day] = current.map((shift, idx) => (idx === index ? { ...shift, ...patch } : shift));
|
||||
return {
|
||||
...prev,
|
||||
shiftSchedule: {
|
||||
...prev.shiftSchedule,
|
||||
overrides,
|
||||
},
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
const addShiftOverride = useCallback(
|
||||
(day: ShiftOverrideDay) => {
|
||||
setDraft((prev) => {
|
||||
if (!prev) return prev;
|
||||
const overrides = { ...(prev.shiftSchedule.overrides ?? {}) };
|
||||
const current = overrides[day] ? [...overrides[day]!] : [];
|
||||
if (current.length >= 3) return prev;
|
||||
const nextIndex = current.length + 1;
|
||||
current.push({ name: defaultShiftName(nextIndex), ...DEFAULT_SHIFT });
|
||||
overrides[day] = current;
|
||||
return {
|
||||
...prev,
|
||||
shiftSchedule: {
|
||||
...prev.shiftSchedule,
|
||||
overrides,
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
[defaultShiftName]
|
||||
);
|
||||
|
||||
const removeShiftOverride = useCallback((day: ShiftOverrideDay, index: number) => {
|
||||
setDraft((prev) => {
|
||||
if (!prev) return prev;
|
||||
const current = prev.shiftSchedule.overrides?.[day];
|
||||
if (!current) return prev;
|
||||
const overrides = { ...(prev.shiftSchedule.overrides ?? {}) };
|
||||
overrides[day] = current.filter((_, idx) => idx !== index);
|
||||
return {
|
||||
...prev,
|
||||
shiftSchedule: {
|
||||
...prev.shiftSchedule,
|
||||
overrides,
|
||||
},
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
const clearShiftOverride = useCallback((day: ShiftOverrideDay) => {
|
||||
setDraft((prev) => {
|
||||
if (!prev) return prev;
|
||||
const overrides = { ...(prev.shiftSchedule.overrides ?? {}) };
|
||||
overrides[day] = [];
|
||||
return {
|
||||
...prev,
|
||||
shiftSchedule: {
|
||||
...prev.shiftSchedule,
|
||||
overrides,
|
||||
},
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
const updateThreshold = useCallback(
|
||||
(
|
||||
key:
|
||||
@@ -665,7 +789,7 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
<div className="flex w-full flex-wrap items-center gap-2 sm:w-auto">
|
||||
<button
|
||||
onClick={loadSettings}
|
||||
onClick={() => loadSettings(true)}
|
||||
className="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-center text-sm text-white hover:bg-white/10 sm:w-auto"
|
||||
>
|
||||
{t("settings.refresh")}
|
||||
@@ -994,6 +1118,119 @@ export default function SettingsPage() {
|
||||
</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">{t("settings.shiftOverrides.title")}</div>
|
||||
<div className="text-xs text-zinc-400">{t("settings.shiftOverrides.subtitle")}</div>
|
||||
<div className="mt-4 grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
{shiftOverrideDays.map((day) => {
|
||||
const dayOverrides = draft.shiftSchedule.overrides?.[day.key];
|
||||
const overrideShifts = dayOverrides ?? [];
|
||||
const isCustom = dayOverrides !== undefined;
|
||||
return (
|
||||
<div key={day.key} className="rounded-xl border border-white/10 bg-black/20 p-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="text-sm font-semibold text-white">{day.label}</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleShiftOverride(day.key)}
|
||||
className="rounded-lg border border-white/10 bg-white/5 px-2 py-1 text-xs text-white"
|
||||
>
|
||||
{isCustom
|
||||
? t("settings.shiftOverrides.useDefault")
|
||||
: t("settings.shiftOverrides.customize")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!isCustom && (
|
||||
<div className="mt-2 text-xs text-zinc-400">{t("settings.shiftOverrides.inherits")}</div>
|
||||
)}
|
||||
|
||||
{isCustom && (
|
||||
<>
|
||||
{overrideShifts.length === 0 ? (
|
||||
<div className="mt-2 text-xs text-zinc-400">
|
||||
{t("settings.shiftOverrides.dayOff")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-3 space-y-2">
|
||||
{overrideShifts.map((shift, index) => (
|
||||
<div key={`${day.key}-${index}`} className="rounded-lg border border-white/10 bg-black/30 p-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<input
|
||||
value={shift.name}
|
||||
onChange={(event) =>
|
||||
updateShiftOverride(day.key, index, { name: event.target.value })
|
||||
}
|
||||
className="w-full rounded-md border border-white/10 bg-black/30 px-2 py-1 text-xs text-white"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeShiftOverride(day.key, index)}
|
||||
className="rounded-lg border border-white/10 bg-white/5 px-2 py-1 text-xs text-white"
|
||||
>
|
||||
{t("settings.shiftRemove")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<input
|
||||
type="time"
|
||||
value={shift.start}
|
||||
onChange={(event) =>
|
||||
updateShiftOverride(day.key, index, { start: event.target.value })
|
||||
}
|
||||
className="w-full rounded-md border border-white/10 bg-black/30 px-2 py-1 text-xs text-white"
|
||||
/>
|
||||
<span className="text-xs text-zinc-400">{t("settings.shiftTo")}</span>
|
||||
<input
|
||||
type="time"
|
||||
value={shift.end}
|
||||
onChange={(event) =>
|
||||
updateShiftOverride(day.key, index, { end: event.target.value })
|
||||
}
|
||||
className="w-full rounded-md border border-white/10 bg-black/30 px-2 py-1 text-xs text-white"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-2 text-xs text-zinc-400">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={shift.enabled}
|
||||
onChange={(event) =>
|
||||
updateShiftOverride(day.key, index, { enabled: event.target.checked })
|
||||
}
|
||||
className="h-4 w-4 rounded border border-white/20 bg-black/20"
|
||||
/>
|
||||
{t("settings.shiftEnabled")}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => addShiftOverride(day.key)}
|
||||
disabled={overrideShifts.length >= 3}
|
||||
className="rounded-lg border border-white/10 bg-white/5 px-2 py-1 text-xs text-white disabled:opacity-40"
|
||||
>
|
||||
{t("settings.shiftAdd")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => clearShiftOverride(day.key)}
|
||||
className="rounded-lg border border-white/10 bg-white/5 px-2 py-1 text-xs text-white"
|
||||
>
|
||||
{t("settings.shiftOverrides.clear")}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user