This commit is contained in:
Marcelo
2026-04-24 02:01:40 +00:00
parent 2707fd974a
commit e705f5e965
19 changed files with 2255 additions and 805 deletions

View File

@@ -3,7 +3,18 @@
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { useEffect, useMemo, useState, useTransition } from "react";
import { BarChart3, Bell, DollarSign, LayoutGrid, Loader2, LogOut, Settings, Wrench, X } from "lucide-react";
import {
BarChart3,
Bell,
DollarSign,
LayoutGrid,
Loader2,
LogOut,
Settings,
Sunrise,
Wrench,
X,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { useI18n } from "@/lib/i18n/useI18n";
import { useScreenlessMode } from "@/lib/ui/screenlessMode";
@@ -24,10 +35,10 @@ const items: NavItem[] = [
{ href: "/reports", labelKey: "nav.reports", icon: BarChart3 },
{ href: "/alerts", labelKey: "nav.alerts", icon: Bell },
{ href: "/financial", labelKey: "nav.financial", icon: DollarSign, ownerOnly: true },
{ href: "/settings", labelKey: "nav.settings", icon: Settings },
{ href: "/downtime", labelKey: "nav.downtime", icon: BarChart3 },
{ href: "/recap", labelKey: "nav.recap", icon: Sunrise },
];
const settingsItem: NavItem = { href: "/settings", labelKey: "nav.settings", icon: Settings };
type SidebarProps = {
variant?: "desktop" | "drawer";
@@ -97,16 +108,7 @@ export function Sidebar({ variant = "desktop", onNavigate, onClose }: SidebarPro
}
}, [screenlessMode, pathname, router]);
useEffect(() => {
if (!pendingHref) return;
if (pathname === pendingHref || pathname.startsWith(`${pendingHref}/`)) {
setPendingHref(null);
} else if (!isPending) {
setPendingHref(null);
}
}, [pathname, pendingHref, isPending]);
const markNavStart = (href: string) => {
const markNavStart = (href: string, ts: number) => {
if (!PERF_ENABLED) return;
try {
sessionStorage.setItem(
@@ -114,7 +116,7 @@ export function Sidebar({ variant = "desktop", onNavigate, onClose }: SidebarPro
JSON.stringify({
href,
from: pathname,
ts: Date.now(),
ts,
})
);
} catch {
@@ -128,6 +130,58 @@ export function Sidebar({ variant = "desktop", onNavigate, onClose }: SidebarPro
"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]",
].join(" ");
const navLocked = isPending;
const renderNavItem = (it: NavItem) => {
const isCurrent = pathname === it.href;
const active = isCurrent || pathname.startsWith(it.href + "/");
const isPendingItem = isPending && pendingHref === it.href;
const Icon = it.icon;
return (
<Link
key={it.href}
href={it.href}
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, Math.round(performance.timeOrigin + event.timeStamp));
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>
);
};
return (
<aside className={shellClass} aria-label={t("sidebar.productTitle")}>
@@ -148,58 +202,9 @@ export function Sidebar({ variant = "desktop", onNavigate, onClose }: SidebarPro
)}
</div>
<nav className="px-3 py-2 flex-1 space-y-1">
{visibleItems.map((it) => {
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}
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>
);
})}
<nav className="px-3 py-2 flex-1 flex flex-col gap-2">
<div className="space-y-1">{visibleItems.map(renderNavItem)}</div>
<div className="mt-auto space-y-1 border-t border-white/10 pt-2">{renderNavItem(settingsItem)}</div>
</nav>
<div className="px-5 py-4 border-t border-white/10 space-y-3">

View File

@@ -0,0 +1,55 @@
"use client";
import { Bar, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis, BarChart } from "recharts";
import { useI18n } from "@/lib/i18n/useI18n";
type Row = {
reasonLabel: string;
minutes: number;
count: number;
};
type Props = {
rows: Row[];
};
export default function RecapDowntimeTop({ rows }: Props) {
const { t } = useI18n();
const data = rows.slice(0, 3).map((row) => ({ ...row, label: row.reasonLabel.slice(0, 20) }));
return (
<div className="rounded-2xl border border-white/10 bg-black/40 p-4">
<div className="mb-3 text-sm font-semibold text-white">{t("recap.downtime.title")}</div>
{data.length === 0 ? (
<div className="text-sm text-zinc-400">{t("recap.empty.production")}</div>
) : (
<>
<div className="h-[170px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={data} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.08)" />
<XAxis dataKey="label" tick={{ fill: "#a1a1aa", fontSize: 11 }} />
<YAxis tick={{ fill: "#a1a1aa", fontSize: 11 }} />
<Tooltip
contentStyle={{ background: "rgba(0,0,0,0.85)", border: "1px solid rgba(255,255,255,0.12)" }}
labelStyle={{ color: "#e4e4e7" }}
/>
<Bar dataKey="minutes" fill="#34d399" radius={[6, 6, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
<div className="mt-2 space-y-1">
{data.map((row) => (
<div key={row.reasonLabel} className="flex items-center justify-between text-xs text-zinc-300">
<span className="truncate">{row.reasonLabel}</span>
<span>
{row.minutes.toFixed(1)} min · {row.count}
</span>
</div>
))}
</div>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,37 @@
"use client";
import { useI18n } from "@/lib/i18n/useI18n";
type Props = {
oeeAvg: number | null;
goodParts: number;
totalStops: number;
scrapParts: number;
};
function fmtPct(v: number | null) {
if (v == null || Number.isNaN(v)) return "--";
return `${v.toFixed(1)}%`;
}
export default function RecapKpiRow({ oeeAvg, goodParts, totalStops, scrapParts }: Props) {
const { t } = useI18n();
const items = [
{ label: t("recap.kpi.oee"), value: fmtPct(oeeAvg), valueClass: "text-emerald-400" },
{ label: t("recap.kpi.good"), value: String(goodParts), valueClass: "text-white" },
{ label: t("recap.kpi.stops"), value: String(totalStops), valueClass: totalStops > 0 ? "text-amber-400" : "text-white" },
{ label: t("recap.kpi.scrap"), value: String(scrapParts), valueClass: scrapParts > 0 ? "text-red-400" : "text-white" },
];
return (
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-4">
{items.map((item) => (
<div key={item.label} className="rounded-2xl border border-white/10 bg-black/40 p-4">
<div className={`text-2xl font-semibold ${item.valueClass}`}>{item.value}</div>
<div className="mt-1 text-xs uppercase tracking-wide text-zinc-400">{item.label}</div>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,46 @@
"use client";
import { useI18n } from "@/lib/i18n/useI18n";
import type { RecapMachine } from "@/lib/recap/types";
type Props = {
machine: RecapMachine | null;
};
export default function RecapMachineStatus({ machine }: Props) {
const { t, locale } = useI18n();
if (!machine) {
return (
<div className="rounded-2xl border border-white/10 bg-black/40 p-4">
<div className="text-sm text-zinc-400">{t("recap.empty.production")}</div>
</div>
);
}
const isStopped = (machine.downtime.ongoingStopMin ?? 0) > 0;
return (
<div className="rounded-2xl border border-white/10 bg-black/40 p-4">
<div className="mb-3 text-sm font-semibold text-white">{t("recap.machine.title")}</div>
<ul className="space-y-2 text-sm text-zinc-200">
<li>
<span className={isStopped ? "text-red-400" : "text-emerald-400"}>
{isStopped ? t("recap.machine.stopped") : t("recap.machine.running")}
</span>
</li>
<li>
<span className={machine.workOrders.moldChangeInProgress ? "text-amber-400" : "text-zinc-300"}>
{t("recap.machine.mold")}: {machine.workOrders.moldChangeInProgress ? t("common.yes") : t("common.no")}
</span>
</li>
<li className="text-zinc-400">
{t("recap.machine.lastHeartbeat")}: {machine.heartbeat.lastSeenAt ? new Date(machine.heartbeat.lastSeenAt).toLocaleString(locale) : "--"}
</li>
<li className="text-zinc-400">
{t("recap.machine.uptime")}: {machine.heartbeat.uptimePct == null ? "--" : `${machine.heartbeat.uptimePct.toFixed(1)}%`}
</li>
</ul>
</div>
);
}

View File

@@ -0,0 +1,45 @@
"use client";
import { useI18n } from "@/lib/i18n/useI18n";
import type { RecapSkuRow } from "@/lib/recap/types";
type Props = {
rows: RecapSkuRow[];
};
export default function RecapProductionBySku({ rows }: Props) {
const { t } = useI18n();
return (
<div className="rounded-2xl border border-white/10 bg-black/40 p-4">
<div className="mb-3 text-sm font-semibold text-white">{t("recap.production.title")}</div>
{rows.length === 0 ? (
<div className="text-sm text-zinc-400">{t("recap.empty.production")}</div>
) : (
<div className="space-y-2">
<div className="grid grid-cols-5 gap-2 border-b border-white/10 pb-2 text-xs uppercase tracking-wide text-zinc-400">
<div>SKU</div>
<div>{t("recap.production.good")}</div>
<div>{t("recap.production.scrap")}</div>
<div>{t("recap.production.target")}</div>
<div>{t("recap.production.progress")}</div>
</div>
{rows.slice(0, 8).map((row) => {
const pct = row.progressPct == null ? "--" : `${Math.round(row.progressPct)}%`;
return (
<div key={row.sku} className="grid grid-cols-5 gap-2 text-sm text-zinc-200">
<div className="truncate">{row.sku}</div>
<div>{row.good}</div>
<div className={row.scrap > 0 ? "text-red-400" : "text-zinc-200"}>{row.scrap}</div>
<div>{row.target ?? "--"}</div>
<div>
<span className="text-emerald-400">{pct}</span>
</div>
</div>
);
})}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,57 @@
"use client";
import { useI18n } from "@/lib/i18n/useI18n";
import type { RecapMachine } from "@/lib/recap/types";
type Props = {
workOrders: RecapMachine["workOrders"];
};
export default function RecapWorkOrderStatus({ workOrders }: Props) {
const { t, locale } = useI18n();
return (
<div className="rounded-2xl border border-white/10 bg-black/40 p-4">
<div className="mb-3 text-sm font-semibold text-white">{t("recap.workOrders.title")}</div>
<div className="mb-3">
<div className="text-xs uppercase tracking-wide text-zinc-400">{t("recap.workOrders.active")}</div>
{!workOrders.active ? (
<div className="mt-1 text-sm text-zinc-400">{t("recap.workOrders.none")}</div>
) : (
<div className="mt-2 rounded-xl border border-white/10 bg-black/30 p-3 text-sm text-zinc-200">
<div className="font-medium text-white">{workOrders.active.id}</div>
<div className="text-zinc-400">SKU: {workOrders.active.sku || "--"}</div>
<div className="mt-2 h-2 rounded-full bg-white/10">
<div
className="h-2 rounded-full bg-emerald-400"
style={{ width: `${Math.max(0, Math.min(100, workOrders.active.progressPct ?? 0))}%` }}
/>
</div>
<div className="mt-2 text-xs text-zinc-400">
{t("recap.workOrders.startedAt")}: {workOrders.active.startedAt ? new Date(workOrders.active.startedAt).toLocaleString(locale) : "--"}
</div>
</div>
)}
</div>
<div>
<div className="text-xs uppercase tracking-wide text-zinc-400">{t("recap.workOrders.completed")}</div>
{workOrders.completed.length === 0 ? (
<div className="mt-1 text-sm text-zinc-400">{t("recap.workOrders.none")}</div>
) : (
<div className="mt-2 space-y-2">
{workOrders.completed.slice(0, 4).map((row) => (
<div key={row.id} className="rounded-xl border border-white/10 bg-black/30 p-3 text-xs text-zinc-300">
<div className="font-medium text-white">{row.id}</div>
<div>SKU: {row.sku || "--"}</div>
<div>{t("recap.workOrders.goodParts")}: {row.goodParts}</div>
<div>{t("recap.workOrders.duration")}: {row.durationHrs.toFixed(2)}h</div>
</div>
))}
</div>
)}
</div>
</div>
);
}