pre-bemis
This commit is contained in:
@@ -80,6 +80,24 @@ type ApiDowntimeEventsRes = {
|
||||
events?: ApiDowntimeEvent[];
|
||||
};
|
||||
|
||||
type ApiReasonCatalogRow = {
|
||||
kind: "downtime" | "scrap";
|
||||
categoryId: string;
|
||||
categoryLabel: string;
|
||||
detailId: string;
|
||||
detailLabel: string;
|
||||
reasonCode: string;
|
||||
reasonLabel: string;
|
||||
};
|
||||
|
||||
type ApiReasonCatalogRes = {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
kind?: "downtime" | "scrap";
|
||||
catalogVersion?: number;
|
||||
rows?: ApiReasonCatalogRow[];
|
||||
};
|
||||
|
||||
function fmtDT(iso: string | null) {
|
||||
if (!iso) return "—";
|
||||
const d = new Date(iso);
|
||||
@@ -1155,6 +1173,8 @@ export default function DowntimePageClient() {
|
||||
const [eventsRes, setEventsRes] = useState<ApiDowntimeEventsRes | null>(null);
|
||||
const [eventsLoading, setEventsLoading] = useState(false);
|
||||
const [eventsErr, setEventsErr] = useState<string | null>(null);
|
||||
const [catalogRows, setCatalogRows] = useState<ApiReasonCatalogRow[]>([]);
|
||||
const [catalogErr, setCatalogErr] = useState<string | null>(null);
|
||||
|
||||
const [eventsLimit, setEventsLimit] = useState<number>(200);
|
||||
const [eventsBefore, setEventsBefore] = useState<string | null>(null);
|
||||
@@ -1251,6 +1271,41 @@ export default function DowntimePageClient() {
|
||||
ac.abort();
|
||||
};
|
||||
}, [range, machineId]);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
const ac = new AbortController();
|
||||
|
||||
async function run() {
|
||||
setCatalogErr(null);
|
||||
try {
|
||||
const res = await fetch("/api/reasons/catalog?kind=downtime", {
|
||||
cache: "no-cache",
|
||||
credentials: "include",
|
||||
signal: ac.signal,
|
||||
});
|
||||
const json = (await res.json().catch(() => ({}))) as ApiReasonCatalogRes;
|
||||
if (!alive) return;
|
||||
if (!res.ok || json.ok === false) {
|
||||
setCatalogRows([]);
|
||||
setCatalogErr(json.error ?? "Failed to load reason catalog");
|
||||
return;
|
||||
}
|
||||
setCatalogRows(Array.isArray(json.rows) ? json.rows : []);
|
||||
} catch (err: unknown) {
|
||||
if (!alive) return;
|
||||
setCatalogRows([]);
|
||||
setCatalogErr(err instanceof Error ? err.message : "Network error");
|
||||
}
|
||||
}
|
||||
|
||||
run();
|
||||
return () => {
|
||||
alive = false;
|
||||
ac.abort();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
const ac = new AbortController();
|
||||
@@ -1308,6 +1363,29 @@ export default function DowntimePageClient() {
|
||||
return metricRowsAll.filter((r) => r.reasonCode === reasonCode);
|
||||
}, [metricRowsAll, reasonCode]);
|
||||
|
||||
const selectedReasonLabel = useMemo(() => {
|
||||
if (!reasonCode) return null;
|
||||
const fromMetrics = metricRowsAll.find((row) => row.reasonCode === reasonCode)?.reasonLabel;
|
||||
if (fromMetrics) return fromMetrics;
|
||||
const fromCatalog = catalogRows.find((row) => row.reasonCode === reasonCode)?.reasonLabel;
|
||||
return fromCatalog ?? reasonCode;
|
||||
}, [catalogRows, metricRowsAll, reasonCode]);
|
||||
|
||||
const catalogByCategory = useMemo(() => {
|
||||
const grouped = new Map<string, { categoryLabel: string; rows: ApiReasonCatalogRow[] }>();
|
||||
for (const row of catalogRows) {
|
||||
const key = row.categoryId;
|
||||
const slot = grouped.get(key) ?? { categoryLabel: row.categoryLabel, rows: [] };
|
||||
slot.rows.push(row);
|
||||
grouped.set(key, slot);
|
||||
}
|
||||
return [...grouped.entries()].map(([categoryId, value]) => ({
|
||||
categoryId,
|
||||
categoryLabel: value.categoryLabel,
|
||||
rows: value.rows,
|
||||
}));
|
||||
}, [catalogRows]);
|
||||
|
||||
const totalMinutes = pareto?.totalMinutesLost ?? 0;
|
||||
const totalStops = useMemo(
|
||||
() => baseRows.reduce((acc, r) => acc + (r.count ?? 0), 0),
|
||||
@@ -1365,6 +1443,7 @@ const filteredEvents = useMemo(() => {
|
||||
e.machineName ?? "",
|
||||
e.reasonLabel ?? "",
|
||||
e.reasonCode ?? "",
|
||||
e.reasonText ?? "",
|
||||
e.workOrderId ?? "",
|
||||
e.episodeId ?? "",
|
||||
]
|
||||
@@ -1467,7 +1546,7 @@ const estImpactMxn = rate > 0 ? totalDowntimeMin * rate : 0;
|
||||
)}
|
||||
{reasonCode ? (
|
||||
<span className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-3 py-1 text-xs text-white">
|
||||
Reason: {reasonCode}
|
||||
Reason: {selectedReasonLabel ?? reasonCode}
|
||||
<button
|
||||
className="rounded-full border border-white/10 bg-black/20 px-2 py-0.5 text-[11px] text-zinc-200 hover:bg-white/10"
|
||||
onClick={() => setParams({ reasonCode: null })}
|
||||
@@ -1805,7 +1884,7 @@ const estImpactMxn = rate > 0 ? totalDowntimeMin * rate : 0;
|
||||
className="mt-4 h-[360px] 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={heroData}
|
||||
onClick={(st: any) => {
|
||||
@@ -1883,6 +1962,45 @@ const estImpactMxn = rate > 0 ? totalDowntimeMin * rate : 0;
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 rounded-2xl border border-white/10 bg-black/20 p-3">
|
||||
<div className="text-xs font-semibold text-white">Downtime reason menu</div>
|
||||
<div className="mt-1 text-[11px] text-zinc-400">
|
||||
From settings or `downtime_menu.md` fallback
|
||||
</div>
|
||||
{catalogErr ? (
|
||||
<div className="mt-2 text-[11px] text-rose-300">{catalogErr}</div>
|
||||
) : null}
|
||||
<div className="mt-3 max-h-[180px] space-y-2 overflow-y-auto no-scrollbar pr-1">
|
||||
{catalogByCategory.map((group) => (
|
||||
<div key={group.categoryId} className="rounded-xl border border-white/10 bg-white/5 p-2">
|
||||
<div className="mb-1 text-[11px] font-semibold text-zinc-300">{group.categoryLabel}</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{group.rows.map((option) => {
|
||||
const active = reasonCode === option.reasonCode;
|
||||
return (
|
||||
<button
|
||||
key={option.reasonCode}
|
||||
onClick={() => setParams({ reasonCode: option.reasonCode })}
|
||||
className={cn(
|
||||
"rounded-lg border px-2 py-1 text-[11px]",
|
||||
active
|
||||
? "border-emerald-500/40 bg-emerald-500/15 text-emerald-200"
|
||||
: "border-white/10 bg-black/20 text-zinc-300 hover:bg-white/10"
|
||||
)}
|
||||
>
|
||||
{option.detailLabel}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{!catalogErr && catalogByCategory.length === 0 ? (
|
||||
<div className="text-[11px] text-zinc-500">No reason menu available.</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 max-h-[360px] overflow-y-auto no-scrollbar rounded-2xl border border-white/10 bg-black/20">
|
||||
<div className="grid grid-cols-12 gap-2 border-b border-white/10 px-4 py-3 text-[11px] text-zinc-500">
|
||||
<div className="col-span-8">Reason</div>
|
||||
@@ -2162,6 +2280,9 @@ const estImpactMxn = rate > 0 ? totalDowntimeMin * rate : 0;
|
||||
<td className="px-4 py-3">
|
||||
<div className="truncate text-white">{e.reasonLabel}</div>
|
||||
<div className="mt-1 text-[11px] text-zinc-500">{e.reasonCode}</div>
|
||||
{e.reasonText && e.reasonText !== e.reasonLabel ? (
|
||||
<div className="mt-1 text-[11px] text-zinc-400">{e.reasonText}</div>
|
||||
) : null}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-zinc-200">{e.workOrderId ?? "—"}</td>
|
||||
<td className="px-4 py-3 text-right text-white">
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Menu } from "lucide-react";
|
||||
import { Sidebar } from "@/components/layout/Sidebar";
|
||||
import { RouteAudit } from "@/components/perf/RouteAudit";
|
||||
import { UtilityControls } from "@/components/layout/UtilityControls";
|
||||
import { useI18n } from "@/lib/i18n/useI18n";
|
||||
|
||||
@@ -31,6 +32,7 @@ export function AppShell({
|
||||
|
||||
return (
|
||||
<div className="h-screen overflow-hidden bg-black text-white">
|
||||
<RouteAudit />
|
||||
<div className="flex h-full">
|
||||
<Sidebar />
|
||||
<div className="flex h-full flex-1 flex-col">
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { BarChart3, Bell, DollarSign, LayoutGrid, LogOut, Settings, Wrench, X } from "lucide-react";
|
||||
import { useEffect, useMemo, useState, useTransition } from "react";
|
||||
import { BarChart3, Bell, DollarSign, LayoutGrid, Loader2, LogOut, Settings, Wrench, X } from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { useI18n } from "@/lib/i18n/useI18n";
|
||||
import { useScreenlessMode } from "@/lib/ui/screenlessMode";
|
||||
|
||||
const PERF_ENABLED = process.env.NEXT_PUBLIC_PERF_LOGS === "1";
|
||||
const NAV_MARK_KEY = "perf_nav_start";
|
||||
|
||||
type NavItem = {
|
||||
href: string;
|
||||
@@ -38,6 +40,8 @@ export function Sidebar({ variant = "desktop", onNavigate, onClose }: SidebarPro
|
||||
const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
const { screenlessMode } = useScreenlessMode();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [pendingHref, setPendingHref] = useState<string | null>(null);
|
||||
const [me, setMe] = useState<{
|
||||
user?: { name?: string | null; email?: string | null };
|
||||
org?: { name?: string | null };
|
||||
@@ -93,13 +97,33 @@ export function Sidebar({ variant = "desktop", onNavigate, onClose }: SidebarPro
|
||||
}
|
||||
}, [screenlessMode, pathname, router]);
|
||||
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
visibleItems.forEach((it) => {
|
||||
router.prefetch(it.href);
|
||||
});
|
||||
}, [router, visibleItems]);
|
||||
if (!pendingHref) return;
|
||||
if (pathname === pendingHref || pathname.startsWith(`${pendingHref}/`)) {
|
||||
setPendingHref(null);
|
||||
} else if (!isPending) {
|
||||
setPendingHref(null);
|
||||
}
|
||||
}, [pathname, pendingHref, isPending]);
|
||||
|
||||
const markNavStart = (href: string) => {
|
||||
if (!PERF_ENABLED) return;
|
||||
try {
|
||||
sessionStorage.setItem(
|
||||
NAV_MARK_KEY,
|
||||
JSON.stringify({
|
||||
href,
|
||||
from: pathname,
|
||||
ts: Date.now(),
|
||||
})
|
||||
);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
// Prefetch disabled: Next.js 16 has RSC prefetch bugs that can cause 404 on
|
||||
// client-side navigation (see e.g. vercel/next.js#85374). Use fresh fetch on click.
|
||||
const shellClass = [
|
||||
"relative z-20 flex flex-col border-r border-white/10 bg-black/40 shrink-0",
|
||||
variant === "desktop" ? "hidden md:flex h-screen w-64" : "flex h-full w-72 max-w-[85vw]",
|
||||
@@ -126,23 +150,53 @@ export function Sidebar({ variant = "desktop", onNavigate, onClose }: SidebarPro
|
||||
|
||||
<nav className="px-3 py-2 flex-1 space-y-1">
|
||||
{visibleItems.map((it) => {
|
||||
const active = pathname === it.href || pathname.startsWith(it.href + "/");
|
||||
const isCurrent = pathname === it.href;
|
||||
const active = isCurrent || pathname.startsWith(it.href + "/");
|
||||
const isPendingItem = isPending && pendingHref === it.href;
|
||||
const navLocked = isPending;
|
||||
const Icon = it.icon;
|
||||
return (
|
||||
<Link
|
||||
key={it.href}
|
||||
href={it.href}
|
||||
onMouseEnter={() => router.prefetch(it.href)}
|
||||
onClick={onNavigate}
|
||||
prefetch={false}
|
||||
aria-disabled={navLocked}
|
||||
onClick={(event) => {
|
||||
if (
|
||||
navLocked ||
|
||||
event.defaultPrevented ||
|
||||
event.button !== 0 ||
|
||||
event.metaKey ||
|
||||
event.altKey ||
|
||||
event.ctrlKey ||
|
||||
event.shiftKey
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (isCurrent) {
|
||||
onNavigate?.();
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
markNavStart(it.href);
|
||||
setPendingHref(it.href);
|
||||
startTransition(() => {
|
||||
router.push(it.href);
|
||||
});
|
||||
onNavigate?.();
|
||||
}}
|
||||
className={[
|
||||
"flex items-center gap-3 rounded-xl px-3 py-2 text-sm transition",
|
||||
active
|
||||
? "bg-emerald-500/15 text-emerald-300 border border-emerald-500/20"
|
||||
: "text-zinc-300 hover:bg-white/5 hover:text-white",
|
||||
navLocked ? "pointer-events-none" : "",
|
||||
navLocked && !isPendingItem ? "opacity-60" : "",
|
||||
].join(" ")}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
<span>{t(it.labelKey)}</span>
|
||||
{isPendingItem ? <Loader2 className="ml-auto h-4 w-4 animate-spin text-emerald-300" /> : null}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
|
||||
68
components/perf/RouteAudit.tsx
Normal file
68
components/perf/RouteAudit.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
|
||||
const PERF_ENABLED = process.env.NEXT_PUBLIC_PERF_LOGS === "1";
|
||||
const STORAGE_KEY = "perf_nav_start";
|
||||
|
||||
type NavMark = {
|
||||
href?: string;
|
||||
from?: string;
|
||||
ts: number;
|
||||
};
|
||||
|
||||
function readNavMark(): NavMark | null {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as NavMark;
|
||||
if (!parsed || typeof parsed.ts !== "number") return null;
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function clearNavMark() {
|
||||
try {
|
||||
sessionStorage.removeItem(STORAGE_KEY);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
export function RouteAudit() {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
useEffect(() => {
|
||||
if (!PERF_ENABLED) return;
|
||||
|
||||
const params = searchParams?.toString();
|
||||
const to = params ? `${pathname}?${params}` : pathname;
|
||||
const mark = readNavMark();
|
||||
if (!mark) return;
|
||||
|
||||
const durationMs = Date.now() - mark.ts;
|
||||
const payload = {
|
||||
from: mark.from ?? "",
|
||||
to,
|
||||
href: mark.href ?? "",
|
||||
durationMs,
|
||||
startedAt: mark.ts,
|
||||
};
|
||||
|
||||
console.info("[perf.nav]", payload);
|
||||
fetch("/api/debug/perf", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ event: "nav", data: payload }),
|
||||
keepalive: true,
|
||||
}).catch(() => {});
|
||||
|
||||
clearNavMark();
|
||||
}, [pathname, searchParams]);
|
||||
|
||||
return null;
|
||||
}
|
||||
Reference in New Issue
Block a user