|
- "use client";
-
- import React, { useCallback, useState } from "react";
- import { Box, Typography, Skeleton, Alert, TextField, Button, Chip, Stack } from "@mui/material";
- import dynamic from "next/dynamic";
- import dayjs from "dayjs";
- import WarehouseIcon from "@mui/icons-material/Warehouse";
- import {
- fetchStockTransactionsByDate,
- fetchStockInOutByDate,
- fetchStockBalanceTrend,
- fetchConsumptionTrendByMonth,
- } from "@/app/api/chart/client";
- import ChartCard from "../_components/ChartCard";
- import DateRangeSelect from "../_components/DateRangeSelect";
- import { toDateRange, DEFAULT_RANGE_DAYS, ITEM_CODE_DEBOUNCE_MS } from "../_components/constants";
-
- const ApexCharts = dynamic(() => import("react-apexcharts"), { ssr: false });
-
- const PAGE_TITLE = "庫存與倉儲";
-
- type Criteria = {
- stockTxn: { rangeDays: number };
- stockInOut: { rangeDays: number };
- balance: { rangeDays: number };
- consumption: { rangeDays: number };
- };
-
- const defaultCriteria: Criteria = {
- stockTxn: { rangeDays: DEFAULT_RANGE_DAYS },
- stockInOut: { rangeDays: DEFAULT_RANGE_DAYS },
- balance: { rangeDays: DEFAULT_RANGE_DAYS },
- consumption: { rangeDays: DEFAULT_RANGE_DAYS },
- };
-
- export default function WarehouseChartPage() {
- const [criteria, setCriteria] = useState<Criteria>(defaultCriteria);
- const [itemCodeBalance, setItemCodeBalance] = useState("");
- const [debouncedItemCodeBalance, setDebouncedItemCodeBalance] = useState("");
- const [consumptionItemCodes, setConsumptionItemCodes] = useState<string[]>([]);
- const [consumptionItemCodeInput, setConsumptionItemCodeInput] = useState("");
- const [error, setError] = useState<string | null>(null);
- const [chartData, setChartData] = useState<{
- stockTxn: { date: string; inQty: number; outQty: number; totalQty: number }[];
- stockInOut: { date: string; inQty: number; outQty: number }[];
- balance: { date: string; balance: number }[];
- consumption: { month: string; outQty: number }[];
- consumptionByItems?: { months: string[]; series: { name: string; data: number[] }[] };
- }>({ stockTxn: [], stockInOut: [], balance: [], consumption: [] });
- const [loadingCharts, setLoadingCharts] = useState<Record<string, boolean>>({});
-
- const updateCriteria = useCallback(
- <K extends keyof Criteria>(key: K, updater: (prev: Criteria[K]) => Criteria[K]) => {
- setCriteria((prev) => ({ ...prev, [key]: updater(prev[key]) }));
- },
- []
- );
- const setChartLoading = useCallback((key: string, value: boolean) => {
- setLoadingCharts((prev) => (prev[key] === value ? prev : { ...prev, [key]: value }));
- }, []);
-
- React.useEffect(() => {
- const t = setTimeout(() => setDebouncedItemCodeBalance(itemCodeBalance), ITEM_CODE_DEBOUNCE_MS);
- return () => clearTimeout(t);
- }, [itemCodeBalance]);
- const addConsumptionItem = useCallback(() => {
- const code = consumptionItemCodeInput.trim();
- if (!code || consumptionItemCodes.includes(code)) return;
- setConsumptionItemCodes((prev) => [...prev, code].sort());
- setConsumptionItemCodeInput("");
- }, [consumptionItemCodeInput, consumptionItemCodes]);
-
- React.useEffect(() => {
- const { startDate: s, endDate: e } = toDateRange(criteria.stockTxn.rangeDays);
- setChartLoading("stockTxn", true);
- fetchStockTransactionsByDate(s, e)
- .then((data) =>
- setChartData((prev) => ({
- ...prev,
- stockTxn: data as { date: string; inQty: number; outQty: number; totalQty: number }[],
- }))
- )
- .catch((err) => setError(err instanceof Error ? err.message : "Request failed"))
- .finally(() => setChartLoading("stockTxn", false));
- }, [criteria.stockTxn, setChartLoading]);
-
- React.useEffect(() => {
- const { startDate: s, endDate: e } = toDateRange(criteria.stockInOut.rangeDays);
- setChartLoading("stockInOut", true);
- fetchStockInOutByDate(s, e)
- .then((data) =>
- setChartData((prev) => ({
- ...prev,
- stockInOut: data as { date: string; inQty: number; outQty: number }[],
- }))
- )
- .catch((err) => setError(err instanceof Error ? err.message : "Request failed"))
- .finally(() => setChartLoading("stockInOut", false));
- }, [criteria.stockInOut, setChartLoading]);
-
- React.useEffect(() => {
- const { startDate: s, endDate: e } = toDateRange(criteria.balance.rangeDays);
- const item = debouncedItemCodeBalance.trim() || undefined;
- setChartLoading("balance", true);
- fetchStockBalanceTrend(s, e, item)
- .then((data) =>
- setChartData((prev) => ({
- ...prev,
- balance: data as { date: string; balance: number }[],
- }))
- )
- .catch((err) => setError(err instanceof Error ? err.message : "Request failed"))
- .finally(() => setChartLoading("balance", false));
- }, [criteria.balance, debouncedItemCodeBalance, setChartLoading]);
-
- React.useEffect(() => {
- const { startDate: s, endDate: e } = toDateRange(criteria.consumption.rangeDays);
- setChartLoading("consumption", true);
- if (consumptionItemCodes.length === 0) {
- fetchConsumptionTrendByMonth(dayjs().year(), s, e, undefined)
- .then((data) =>
- setChartData((prev) => ({
- ...prev,
- consumption: data as { month: string; outQty: number }[],
- consumptionByItems: undefined,
- }))
- )
- .catch((err) => setError(err instanceof Error ? err.message : "Request failed"))
- .finally(() => setChartLoading("consumption", false));
- return;
- }
- Promise.all(
- consumptionItemCodes.map((code) =>
- fetchConsumptionTrendByMonth(dayjs().year(), s, e, code)
- )
- )
- .then((results) => {
- const byItem = results.map((rows, i) => ({
- itemCode: consumptionItemCodes[i],
- rows: rows as { month: string; outQty: number }[],
- }));
- const allMonths = Array.from(
- new Set(byItem.flatMap((x) => x.rows.map((r) => r.month)))
- ).sort();
- const series = byItem.map(({ itemCode, rows }) => ({
- name: itemCode,
- data: allMonths.map((m) => {
- const r = rows.find((x) => x.month === m);
- return r ? r.outQty : 0;
- }),
- }));
- setChartData((prev) => ({
- ...prev,
- consumption: [],
- consumptionByItems: { months: allMonths, series },
- }));
- })
- .catch((err) => setError(err instanceof Error ? err.message : "Request failed"))
- .finally(() => setChartLoading("consumption", false));
- }, [criteria.consumption, consumptionItemCodes, setChartLoading]);
-
- return (
- <Box sx={{ maxWidth: 1200, mx: "auto" }}>
- <Typography variant="h5" sx={{ mb: 2, fontWeight: 600, display: "flex", alignItems: "center", gap: 1 }}>
- <WarehouseIcon /> {PAGE_TITLE}
- </Typography>
- {error && (
- <Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>
- {error}
- </Alert>
- )}
-
- <ChartCard
- title="按日期庫存流水(入/出/合計)"
- exportFilename="庫存流水_按日期"
- exportData={chartData.stockTxn.map((s) => ({ 日期: s.date, 入庫: s.inQty, 出庫: s.outQty, 合計: s.totalQty }))}
- filters={
- <DateRangeSelect
- value={criteria.stockTxn.rangeDays}
- onChange={(v) => updateCriteria("stockTxn", (c) => ({ ...c, rangeDays: v }))}
- />
- }
- >
- {loadingCharts.stockTxn ? (
- <Skeleton variant="rectangular" height={320} />
- ) : (
- <ApexCharts
- options={{
- chart: { type: "line" },
- xaxis: { categories: chartData.stockTxn.map((s) => s.date) },
- yaxis: { title: { text: "數量" } },
- stroke: { curve: "smooth" },
- dataLabels: { enabled: false },
- }}
- series={[
- { name: "入庫", data: chartData.stockTxn.map((s) => s.inQty) },
- { name: "出庫", data: chartData.stockTxn.map((s) => s.outQty) },
- { name: "合計", data: chartData.stockTxn.map((s) => s.totalQty) },
- ]}
- type="line"
- width="100%"
- height={320}
- />
- )}
- </ChartCard>
-
- <ChartCard
- title="按日期入庫與出庫"
- exportFilename="入庫與出庫_按日期"
- exportData={chartData.stockInOut.map((s) => ({ 日期: s.date, 入庫: s.inQty, 出庫: s.outQty }))}
- filters={
- <DateRangeSelect
- value={criteria.stockInOut.rangeDays}
- onChange={(v) => updateCriteria("stockInOut", (c) => ({ ...c, rangeDays: v }))}
- />
- }
- >
- {loadingCharts.stockInOut ? (
- <Skeleton variant="rectangular" height={320} />
- ) : (
- <ApexCharts
- options={{
- chart: { type: "area", stacked: false },
- xaxis: { categories: chartData.stockInOut.map((s) => s.date) },
- yaxis: { title: { text: "數量" } },
- stroke: { curve: "smooth" },
- dataLabels: { enabled: false },
- }}
- series={[
- { name: "入庫", data: chartData.stockInOut.map((s) => s.inQty) },
- { name: "出庫", data: chartData.stockInOut.map((s) => s.outQty) },
- ]}
- type="area"
- width="100%"
- height={320}
- />
- )}
- </ChartCard>
-
- <ChartCard
- title="庫存餘額趨勢"
- exportFilename="庫存餘額趨勢"
- exportData={chartData.balance.map((b) => ({ 日期: b.date, 餘額: b.balance }))}
- filters={
- <>
- <DateRangeSelect
- value={criteria.balance.rangeDays}
- onChange={(v) => updateCriteria("balance", (c) => ({ ...c, rangeDays: v }))}
- />
- <TextField
- size="small"
- label="物料編碼"
- placeholder="可選"
- value={itemCodeBalance}
- onChange={(e) => setItemCodeBalance(e.target.value)}
- sx={{ minWidth: 180 }}
- />
- </>
- }
- >
- {loadingCharts.balance ? (
- <Skeleton variant="rectangular" height={320} />
- ) : (
- <ApexCharts
- options={{
- chart: { type: "line" },
- xaxis: { categories: chartData.balance.map((b) => b.date) },
- yaxis: { title: { text: "餘額" } },
- stroke: { curve: "smooth" },
- dataLabels: { enabled: false },
- }}
- series={[{ name: "餘額", data: chartData.balance.map((b) => b.balance) }]}
- type="line"
- width="100%"
- height={320}
- />
- )}
- </ChartCard>
-
- <ChartCard
- title="按月考勤消耗趨勢(出庫量)"
- exportFilename="按月考勤消耗趨勢_出庫量"
- exportData={
- chartData.consumptionByItems
- ? chartData.consumptionByItems.series.flatMap((s) =>
- s.data.map((qty, i) => ({
- 月份: chartData.consumptionByItems!.months[i],
- 物料編碼: s.name,
- 出庫量: qty,
- }))
- )
- : chartData.consumption.map((c) => ({ 月份: c.month, 出庫量: c.outQty }))
- }
- filters={
- <>
- <DateRangeSelect
- value={criteria.consumption.rangeDays}
- onChange={(v) => updateCriteria("consumption", (c) => ({ ...c, rangeDays: v }))}
- />
- <Stack direction="row" alignItems="center" flexWrap="wrap" gap={1}>
- <TextField
- size="small"
- label="物料編碼"
- placeholder={consumptionItemCodes.length === 0 ? "不選則全部合計" : "新增物料以分項顯示"}
- value={consumptionItemCodeInput}
- onChange={(e) => setConsumptionItemCodeInput(e.target.value)}
- onKeyDown={(e) => e.key === "Enter" && (e.preventDefault(), addConsumptionItem())}
- sx={{ minWidth: 180 }}
- />
- <Button size="small" variant="outlined" onClick={addConsumptionItem}>
- 新增
- </Button>
- {consumptionItemCodes.map((code) => (
- <Chip
- key={code}
- label={code}
- size="small"
- onDelete={() =>
- setConsumptionItemCodes((prev) => prev.filter((c) => c !== code))
- }
- />
- ))}
- </Stack>
- </>
- }
- >
- {loadingCharts.consumption ? (
- <Skeleton variant="rectangular" height={320} />
- ) : chartData.consumptionByItems ? (
- <ApexCharts
- options={{
- chart: { type: "bar", stacked: false },
- xaxis: { categories: chartData.consumptionByItems.months },
- yaxis: { title: { text: "出庫量" } },
- plotOptions: { bar: { columnWidth: "60%" } },
- dataLabels: { enabled: false },
- legend: { position: "top" },
- }}
- series={chartData.consumptionByItems.series}
- type="bar"
- width="100%"
- height={320}
- />
- ) : (
- <ApexCharts
- options={{
- chart: { type: "bar" },
- xaxis: { categories: chartData.consumption.map((c) => c.month) },
- yaxis: { title: { text: "出庫量" } },
- plotOptions: { bar: { columnWidth: "60%" } },
- dataLabels: { enabled: false },
- }}
- series={[{ name: "出庫量", data: chartData.consumption.map((c) => c.outQty) }]}
- type="bar"
- width="100%"
- height={320}
- />
- )}
- </ChartCard>
- </Box>
- );
- }
|