Files
MIS-Contro-Tower/app/(app)/financial/FinancialClient.tsx

389 lines
14 KiB
TypeScript

"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import Link from "next/link";
import {
Area,
AreaChart,
CartesianGrid,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { useI18n } from "@/lib/i18n/useI18n";
type MachineRow = {
id: string;
name: string;
location?: string | null;
};
type ImpactSummary = {
currency: string;
totals: {
total: number;
slowCycle: number;
microstop: number;
macrostop: number;
scrap: number;
};
byDay: Array<{
day: string;
total: number;
slowCycle: number;
microstop: number;
macrostop: number;
scrap: number;
}>;
};
type ImpactResponse = {
ok: boolean;
currencySummaries: ImpactSummary[];
};
function formatMoney(value: number, currency: string, locale: string) {
if (!Number.isFinite(value)) return "--";
try {
return new Intl.NumberFormat(locale, {
style: "currency",
currency,
maximumFractionDigits: 0,
}).format(value);
} catch {
return `${value.toFixed(0)} ${currency}`;
}
}
export default function FinancialClient({
initialRole = null,
initialMachines = [],
initialImpact = null,
}: {
initialRole?: string | null;
initialMachines?: MachineRow[];
initialImpact?: ImpactResponse | null;
}) {
const { locale, t } = useI18n();
const [role, setRole] = useState<string | null>(initialRole);
const [machines, setMachines] = useState<MachineRow[]>(() => initialMachines);
const [impact, setImpact] = useState<ImpactResponse | null>(initialImpact);
const [range, setRange] = useState("7d");
const [machineFilter, setMachineFilter] = useState("");
const [locationFilter, setLocationFilter] = useState("");
const [skuFilter, setSkuFilter] = useState("");
const [currencyFilter, setCurrencyFilter] = useState("");
const [loading, setLoading] = useState(() => initialMachines.length === 0);
const skipInitialImpactRef = useRef(true);
const locations = useMemo(() => {
const seen = new Set<string>();
for (const m of machines) {
if (!m.location) continue;
seen.add(m.location);
}
return Array.from(seen).sort();
}, [machines]);
useEffect(() => {
if (initialRole != null) return;
let alive = true;
async function loadMe() {
try {
const res = await fetch("/api/me", { cache: "no-store" });
const data = await res.json().catch(() => ({}));
if (!alive) return;
setRole(data?.membership?.role ?? null);
} catch {
if (alive) setRole(null);
}
}
loadMe();
return () => {
alive = false;
};
}, [initialRole]);
useEffect(() => {
if (initialMachines.length) {
setLoading(false);
return;
}
let alive = true;
async function loadMachines() {
try {
const res = await fetch("/api/machines", { cache: "no-store" });
const json = await res.json().catch(() => ({}));
if (!alive) return;
setMachines(json.machines ?? []);
} catch {
if (!alive) return;
} finally {
if (alive) setLoading(false);
}
}
loadMachines();
return () => {
alive = false;
};
}, [initialMachines]);
useEffect(() => {
let alive = true;
const controller = new AbortController();
async function loadImpact() {
if (role == null) return;
if (role !== "OWNER") return;
const isDefault =
range === "7d" &&
!machineFilter &&
!locationFilter &&
!skuFilter &&
!currencyFilter;
if (skipInitialImpactRef.current) {
skipInitialImpactRef.current = false;
if (initialImpact && isDefault) return;
}
const params = new URLSearchParams();
params.set("range", range);
if (machineFilter) params.set("machineId", machineFilter);
if (locationFilter) params.set("location", locationFilter);
if (skuFilter) params.set("sku", skuFilter);
if (currencyFilter) params.set("currency", currencyFilter);
try {
const res = await fetch(`/api/financial/impact?${params.toString()}`, {
cache: "no-store",
signal: controller.signal,
});
const json = await res.json().catch(() => ({}));
if (!alive) return;
setImpact(json);
} catch {
if (alive) setImpact(null);
}
}
loadImpact();
return () => {
alive = false;
controller.abort();
};
}, [currencyFilter, initialImpact, locationFilter, machineFilter, range, role, skuFilter]);
const selectedSummary = impact?.currencySummaries?.[0] ?? null;
const chartData = selectedSummary?.byDay ?? [];
const exportQuery = useMemo(() => {
const params = new URLSearchParams();
params.set("range", range);
if (machineFilter) params.set("machineId", machineFilter);
if (locationFilter) params.set("location", locationFilter);
if (skuFilter) params.set("sku", skuFilter);
if (currencyFilter) params.set("currency", currencyFilter);
return params.toString();
}, [range, machineFilter, locationFilter, skuFilter, currencyFilter]);
const htmlHref = `/api/financial/export/pdf?${exportQuery}`;
const csvHref = `/api/financial/export/excel?${exportQuery}`;
if (role && role !== "OWNER") {
return (
<div className="p-4 sm:p-6">
<div className="rounded-2xl border border-white/10 bg-black/40 p-6 text-zinc-300">
{t("financial.ownerOnly")}
</div>
</div>
);
}
return (
<div className="p-4 sm:p-6 space-y-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<h1 className="text-2xl font-semibold text-white">{t("financial.title")}</h1>
<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">
<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}
target="_blank"
rel="noreferrer"
>
{t("financial.export.html")}
</a>
<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={csvHref}
target="_blank"
rel="noreferrer"
>
{t("financial.export.csv")}
</a>
</div>
</div>
<div className="rounded-2xl border border-white/10 bg-black/40 p-4 text-sm text-zinc-300">
{t("financial.costsMoved")}{" "}
<Link className="text-emerald-200 hover:text-emerald-100" href="/settings">
{t("financial.costsMovedLink")}
</Link>
.
</div>
<div className="grid gap-4 lg:grid-cols-4">
{(impact?.currencySummaries ?? []).slice(0, 4).map((summary) => (
<div key={summary.currency} className="rounded-2xl border border-white/10 bg-black/40 p-4">
<div className="text-xs uppercase tracking-wide text-zinc-500">{t("financial.totalLoss")}</div>
<div className="mt-2 text-2xl font-semibold text-white">
{formatMoney(summary.totals.total, summary.currency, locale)}
</div>
<div className="mt-3 text-xs text-zinc-400">
{t("financial.currencyLabel", { currency: summary.currency })}
</div>
</div>
))}
{!impact?.currencySummaries?.length && (
<div className="rounded-2xl border border-white/10 bg-black/40 p-4 text-sm text-zinc-400">
{t("financial.noImpact")}
</div>
)}
</div>
<div className="grid gap-4 lg:grid-cols-[2fr_1fr]">
<div className="rounded-2xl border border-white/10 bg-black/40 p-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-white">{t("financial.chart.title")}</h2>
<p className="text-xs text-zinc-500">{t("financial.chart.subtitle")}</p>
</div>
<div className="flex gap-2">
{["24h", "7d", "30d"].map((value) => (
<button
key={value}
type="button"
onClick={() => setRange(value)}
className={
value === range
? "rounded-full bg-emerald-500/20 px-3 py-1 text-xs text-emerald-200"
: "rounded-full border border-white/10 px-3 py-1 text-xs text-zinc-300"
}
>
{value === "24h"
? t("financial.range.day")
: value === "7d"
? t("financial.range.week")
: t("financial.range.month")}
</button>
))}
</div>
</div>
<div className="mt-4 h-64">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={chartData}>
<defs>
<linearGradient id="slowFill" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#facc15" stopOpacity={0.5} />
<stop offset="95%" stopColor="#facc15" stopOpacity={0.05} />
</linearGradient>
<linearGradient id="microFill" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#fb7185" stopOpacity={0.5} />
<stop offset="95%" stopColor="#fb7185" stopOpacity={0.05} />
</linearGradient>
<linearGradient id="macroFill" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#f97316" stopOpacity={0.5} />
<stop offset="95%" stopColor="#f97316" stopOpacity={0.05} />
</linearGradient>
<linearGradient id="scrapFill" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#38bdf8" stopOpacity={0.5} />
<stop offset="95%" stopColor="#38bdf8" stopOpacity={0.05} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="var(--app-chart-grid)" />
<XAxis dataKey="day" tick={{ fill: "var(--app-chart-tick)", fontSize: 10 }} />
<YAxis tick={{ fill: "var(--app-chart-tick)", fontSize: 10 }} />
<Tooltip
contentStyle={{
background: "var(--app-chart-tooltip-bg)",
border: "1px solid var(--app-chart-tooltip-border)",
}}
labelStyle={{ color: "var(--app-chart-label)" }}
/>
<Area type="monotone" dataKey="slowCycle" stackId="1" stroke="#facc15" fill="url(#slowFill)" />
<Area type="monotone" dataKey="microstop" stackId="1" stroke="#fb7185" fill="url(#microFill)" />
<Area type="monotone" dataKey="macrostop" stackId="1" stroke="#f97316" fill="url(#macroFill)" />
<Area type="monotone" dataKey="scrap" stackId="1" stroke="#38bdf8" fill="url(#scrapFill)" />
</AreaChart>
</ResponsiveContainer>
</div>
</div>
<div className="rounded-2xl border border-white/10 bg-black/40 p-4 space-y-4">
<h2 className="text-lg font-semibold text-white">{t("financial.filters.title")}</h2>
<div className="space-y-3 text-sm text-zinc-300">
<div>
<label className="text-xs uppercase text-zinc-500">{t("financial.filters.machine")}</label>
<select
className="mt-2 w-full rounded-lg border border-white/10 bg-black/30 px-3 py-2"
value={machineFilter}
onChange={(event) => setMachineFilter(event.target.value)}
>
<option value="">{t("financial.filters.allMachines")}</option>
{machines.map((machine) => (
<option key={machine.id} value={machine.id}>
{machine.name}
</option>
))}
</select>
</div>
<div>
<label className="text-xs uppercase text-zinc-500">{t("financial.filters.location")}</label>
<select
className="mt-2 w-full rounded-lg border border-white/10 bg-black/30 px-3 py-2"
value={locationFilter}
onChange={(event) => setLocationFilter(event.target.value)}
>
<option value="">{t("financial.filters.allLocations")}</option>
{locations.map((loc) => (
<option key={loc} value={loc}>
{loc}
</option>
))}
</select>
</div>
<div>
<label className="text-xs uppercase text-zinc-500">{t("financial.filters.sku")}</label>
<input
className="mt-2 w-full rounded-lg border border-white/10 bg-black/30 px-3 py-2"
value={skuFilter}
onChange={(event) => setSkuFilter(event.target.value)}
placeholder={t("financial.filters.skuPlaceholder")}
/>
</div>
<div>
<label className="text-xs uppercase text-zinc-500">{t("financial.filters.currency")}</label>
<input
className="mt-2 w-full rounded-lg border border-white/10 bg-black/30 px-3 py-2"
value={currencyFilter}
onChange={(event) => setCurrencyFilter(event.target.value.toUpperCase())}
placeholder={t("financial.filters.currencyPlaceholder")}
/>
</div>
</div>
</div>
</div>
{loading && <div className="text-xs text-zinc-500">{t("financial.loadingMachines")}</div>}
</div>
);
}