FPSMS-frontend
Você não pode selecionar mais de 25 tópicos Os tópicos devem começar com uma letra ou um número, podem incluir traços ('-') e podem ter até 35 caracteres.
 
 

363 linhas
14 KiB

  1. "use client";
  2. import React, { useCallback, useState } from "react";
  3. import { Box, Typography, Skeleton, Alert, TextField, Button, Chip, Stack } from "@mui/material";
  4. import dynamic from "next/dynamic";
  5. import dayjs from "dayjs";
  6. import WarehouseIcon from "@mui/icons-material/Warehouse";
  7. import {
  8. fetchStockTransactionsByDate,
  9. fetchStockInOutByDate,
  10. fetchStockBalanceTrend,
  11. fetchConsumptionTrendByMonth,
  12. } from "@/app/api/chart/client";
  13. import ChartCard from "../_components/ChartCard";
  14. import DateRangeSelect from "../_components/DateRangeSelect";
  15. import { toDateRange, DEFAULT_RANGE_DAYS, ITEM_CODE_DEBOUNCE_MS } from "../_components/constants";
  16. const ApexCharts = dynamic(() => import("react-apexcharts"), { ssr: false });
  17. const PAGE_TITLE = "庫存與倉儲";
  18. type Criteria = {
  19. stockTxn: { rangeDays: number };
  20. stockInOut: { rangeDays: number };
  21. balance: { rangeDays: number };
  22. consumption: { rangeDays: number };
  23. };
  24. const defaultCriteria: Criteria = {
  25. stockTxn: { rangeDays: DEFAULT_RANGE_DAYS },
  26. stockInOut: { rangeDays: DEFAULT_RANGE_DAYS },
  27. balance: { rangeDays: DEFAULT_RANGE_DAYS },
  28. consumption: { rangeDays: DEFAULT_RANGE_DAYS },
  29. };
  30. export default function WarehouseChartPage() {
  31. const [criteria, setCriteria] = useState<Criteria>(defaultCriteria);
  32. const [itemCodeBalance, setItemCodeBalance] = useState("");
  33. const [debouncedItemCodeBalance, setDebouncedItemCodeBalance] = useState("");
  34. const [consumptionItemCodes, setConsumptionItemCodes] = useState<string[]>([]);
  35. const [consumptionItemCodeInput, setConsumptionItemCodeInput] = useState("");
  36. const [error, setError] = useState<string | null>(null);
  37. const [chartData, setChartData] = useState<{
  38. stockTxn: { date: string; inQty: number; outQty: number; totalQty: number }[];
  39. stockInOut: { date: string; inQty: number; outQty: number }[];
  40. balance: { date: string; balance: number }[];
  41. consumption: { month: string; outQty: number }[];
  42. consumptionByItems?: { months: string[]; series: { name: string; data: number[] }[] };
  43. }>({ stockTxn: [], stockInOut: [], balance: [], consumption: [] });
  44. const [loadingCharts, setLoadingCharts] = useState<Record<string, boolean>>({});
  45. const updateCriteria = useCallback(
  46. <K extends keyof Criteria>(key: K, updater: (prev: Criteria[K]) => Criteria[K]) => {
  47. setCriteria((prev) => ({ ...prev, [key]: updater(prev[key]) }));
  48. },
  49. []
  50. );
  51. const setChartLoading = useCallback((key: string, value: boolean) => {
  52. setLoadingCharts((prev) => (prev[key] === value ? prev : { ...prev, [key]: value }));
  53. }, []);
  54. React.useEffect(() => {
  55. const t = setTimeout(() => setDebouncedItemCodeBalance(itemCodeBalance), ITEM_CODE_DEBOUNCE_MS);
  56. return () => clearTimeout(t);
  57. }, [itemCodeBalance]);
  58. const addConsumptionItem = useCallback(() => {
  59. const code = consumptionItemCodeInput.trim();
  60. if (!code || consumptionItemCodes.includes(code)) return;
  61. setConsumptionItemCodes((prev) => [...prev, code].sort());
  62. setConsumptionItemCodeInput("");
  63. }, [consumptionItemCodeInput, consumptionItemCodes]);
  64. React.useEffect(() => {
  65. const { startDate: s, endDate: e } = toDateRange(criteria.stockTxn.rangeDays);
  66. setChartLoading("stockTxn", true);
  67. fetchStockTransactionsByDate(s, e)
  68. .then((data) =>
  69. setChartData((prev) => ({
  70. ...prev,
  71. stockTxn: data as { date: string; inQty: number; outQty: number; totalQty: number }[],
  72. }))
  73. )
  74. .catch((err) => setError(err instanceof Error ? err.message : "Request failed"))
  75. .finally(() => setChartLoading("stockTxn", false));
  76. }, [criteria.stockTxn, setChartLoading]);
  77. React.useEffect(() => {
  78. const { startDate: s, endDate: e } = toDateRange(criteria.stockInOut.rangeDays);
  79. setChartLoading("stockInOut", true);
  80. fetchStockInOutByDate(s, e)
  81. .then((data) =>
  82. setChartData((prev) => ({
  83. ...prev,
  84. stockInOut: data as { date: string; inQty: number; outQty: number }[],
  85. }))
  86. )
  87. .catch((err) => setError(err instanceof Error ? err.message : "Request failed"))
  88. .finally(() => setChartLoading("stockInOut", false));
  89. }, [criteria.stockInOut, setChartLoading]);
  90. React.useEffect(() => {
  91. const { startDate: s, endDate: e } = toDateRange(criteria.balance.rangeDays);
  92. const item = debouncedItemCodeBalance.trim() || undefined;
  93. setChartLoading("balance", true);
  94. fetchStockBalanceTrend(s, e, item)
  95. .then((data) =>
  96. setChartData((prev) => ({
  97. ...prev,
  98. balance: data as { date: string; balance: number }[],
  99. }))
  100. )
  101. .catch((err) => setError(err instanceof Error ? err.message : "Request failed"))
  102. .finally(() => setChartLoading("balance", false));
  103. }, [criteria.balance, debouncedItemCodeBalance, setChartLoading]);
  104. React.useEffect(() => {
  105. const { startDate: s, endDate: e } = toDateRange(criteria.consumption.rangeDays);
  106. setChartLoading("consumption", true);
  107. if (consumptionItemCodes.length === 0) {
  108. fetchConsumptionTrendByMonth(dayjs().year(), s, e, undefined)
  109. .then((data) =>
  110. setChartData((prev) => ({
  111. ...prev,
  112. consumption: data as { month: string; outQty: number }[],
  113. consumptionByItems: undefined,
  114. }))
  115. )
  116. .catch((err) => setError(err instanceof Error ? err.message : "Request failed"))
  117. .finally(() => setChartLoading("consumption", false));
  118. return;
  119. }
  120. Promise.all(
  121. consumptionItemCodes.map((code) =>
  122. fetchConsumptionTrendByMonth(dayjs().year(), s, e, code)
  123. )
  124. )
  125. .then((results) => {
  126. const byItem = results.map((rows, i) => ({
  127. itemCode: consumptionItemCodes[i],
  128. rows: rows as { month: string; outQty: number }[],
  129. }));
  130. const allMonths = Array.from(
  131. new Set(byItem.flatMap((x) => x.rows.map((r) => r.month)))
  132. ).sort();
  133. const series = byItem.map(({ itemCode, rows }) => ({
  134. name: itemCode,
  135. data: allMonths.map((m) => {
  136. const r = rows.find((x) => x.month === m);
  137. return r ? r.outQty : 0;
  138. }),
  139. }));
  140. setChartData((prev) => ({
  141. ...prev,
  142. consumption: [],
  143. consumptionByItems: { months: allMonths, series },
  144. }));
  145. })
  146. .catch((err) => setError(err instanceof Error ? err.message : "Request failed"))
  147. .finally(() => setChartLoading("consumption", false));
  148. }, [criteria.consumption, consumptionItemCodes, setChartLoading]);
  149. return (
  150. <Box sx={{ maxWidth: 1200, mx: "auto" }}>
  151. <Typography variant="h5" sx={{ mb: 2, fontWeight: 600, display: "flex", alignItems: "center", gap: 1 }}>
  152. <WarehouseIcon /> {PAGE_TITLE}
  153. </Typography>
  154. {error && (
  155. <Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>
  156. {error}
  157. </Alert>
  158. )}
  159. <ChartCard
  160. title="按日期庫存流水(入/出/合計)"
  161. exportFilename="庫存流水_按日期"
  162. exportData={chartData.stockTxn.map((s) => ({ 日期: s.date, 入庫: s.inQty, 出庫: s.outQty, 合計: s.totalQty }))}
  163. filters={
  164. <DateRangeSelect
  165. value={criteria.stockTxn.rangeDays}
  166. onChange={(v) => updateCriteria("stockTxn", (c) => ({ ...c, rangeDays: v }))}
  167. />
  168. }
  169. >
  170. {loadingCharts.stockTxn ? (
  171. <Skeleton variant="rectangular" height={320} />
  172. ) : (
  173. <ApexCharts
  174. options={{
  175. chart: { type: "line" },
  176. xaxis: { categories: chartData.stockTxn.map((s) => s.date) },
  177. yaxis: { title: { text: "數量" } },
  178. stroke: { curve: "smooth" },
  179. dataLabels: { enabled: false },
  180. }}
  181. series={[
  182. { name: "入庫", data: chartData.stockTxn.map((s) => s.inQty) },
  183. { name: "出庫", data: chartData.stockTxn.map((s) => s.outQty) },
  184. { name: "合計", data: chartData.stockTxn.map((s) => s.totalQty) },
  185. ]}
  186. type="line"
  187. width="100%"
  188. height={320}
  189. />
  190. )}
  191. </ChartCard>
  192. <ChartCard
  193. title="按日期入庫與出庫"
  194. exportFilename="入庫與出庫_按日期"
  195. exportData={chartData.stockInOut.map((s) => ({ 日期: s.date, 入庫: s.inQty, 出庫: s.outQty }))}
  196. filters={
  197. <DateRangeSelect
  198. value={criteria.stockInOut.rangeDays}
  199. onChange={(v) => updateCriteria("stockInOut", (c) => ({ ...c, rangeDays: v }))}
  200. />
  201. }
  202. >
  203. {loadingCharts.stockInOut ? (
  204. <Skeleton variant="rectangular" height={320} />
  205. ) : (
  206. <ApexCharts
  207. options={{
  208. chart: { type: "area", stacked: false },
  209. xaxis: { categories: chartData.stockInOut.map((s) => s.date) },
  210. yaxis: { title: { text: "數量" } },
  211. stroke: { curve: "smooth" },
  212. dataLabels: { enabled: false },
  213. }}
  214. series={[
  215. { name: "入庫", data: chartData.stockInOut.map((s) => s.inQty) },
  216. { name: "出庫", data: chartData.stockInOut.map((s) => s.outQty) },
  217. ]}
  218. type="area"
  219. width="100%"
  220. height={320}
  221. />
  222. )}
  223. </ChartCard>
  224. <ChartCard
  225. title="庫存餘額趨勢"
  226. exportFilename="庫存餘額趨勢"
  227. exportData={chartData.balance.map((b) => ({ 日期: b.date, 餘額: b.balance }))}
  228. filters={
  229. <>
  230. <DateRangeSelect
  231. value={criteria.balance.rangeDays}
  232. onChange={(v) => updateCriteria("balance", (c) => ({ ...c, rangeDays: v }))}
  233. />
  234. <TextField
  235. size="small"
  236. label="物料編碼"
  237. placeholder="可選"
  238. value={itemCodeBalance}
  239. onChange={(e) => setItemCodeBalance(e.target.value)}
  240. sx={{ minWidth: 180 }}
  241. />
  242. </>
  243. }
  244. >
  245. {loadingCharts.balance ? (
  246. <Skeleton variant="rectangular" height={320} />
  247. ) : (
  248. <ApexCharts
  249. options={{
  250. chart: { type: "line" },
  251. xaxis: { categories: chartData.balance.map((b) => b.date) },
  252. yaxis: { title: { text: "餘額" } },
  253. stroke: { curve: "smooth" },
  254. dataLabels: { enabled: false },
  255. }}
  256. series={[{ name: "餘額", data: chartData.balance.map((b) => b.balance) }]}
  257. type="line"
  258. width="100%"
  259. height={320}
  260. />
  261. )}
  262. </ChartCard>
  263. <ChartCard
  264. title="按月考勤消耗趨勢(出庫量)"
  265. exportFilename="按月考勤消耗趨勢_出庫量"
  266. exportData={
  267. chartData.consumptionByItems
  268. ? chartData.consumptionByItems.series.flatMap((s) =>
  269. s.data.map((qty, i) => ({
  270. 月份: chartData.consumptionByItems!.months[i],
  271. 物料編碼: s.name,
  272. 出庫量: qty,
  273. }))
  274. )
  275. : chartData.consumption.map((c) => ({ 月份: c.month, 出庫量: c.outQty }))
  276. }
  277. filters={
  278. <>
  279. <DateRangeSelect
  280. value={criteria.consumption.rangeDays}
  281. onChange={(v) => updateCriteria("consumption", (c) => ({ ...c, rangeDays: v }))}
  282. />
  283. <Stack direction="row" alignItems="center" flexWrap="wrap" gap={1}>
  284. <TextField
  285. size="small"
  286. label="物料編碼"
  287. placeholder={consumptionItemCodes.length === 0 ? "不選則全部合計" : "新增物料以分項顯示"}
  288. value={consumptionItemCodeInput}
  289. onChange={(e) => setConsumptionItemCodeInput(e.target.value)}
  290. onKeyDown={(e) => e.key === "Enter" && (e.preventDefault(), addConsumptionItem())}
  291. sx={{ minWidth: 180 }}
  292. />
  293. <Button size="small" variant="outlined" onClick={addConsumptionItem}>
  294. 新增
  295. </Button>
  296. {consumptionItemCodes.map((code) => (
  297. <Chip
  298. key={code}
  299. label={code}
  300. size="small"
  301. onDelete={() =>
  302. setConsumptionItemCodes((prev) => prev.filter((c) => c !== code))
  303. }
  304. />
  305. ))}
  306. </Stack>
  307. </>
  308. }
  309. >
  310. {loadingCharts.consumption ? (
  311. <Skeleton variant="rectangular" height={320} />
  312. ) : chartData.consumptionByItems ? (
  313. <ApexCharts
  314. options={{
  315. chart: { type: "bar", stacked: false },
  316. xaxis: { categories: chartData.consumptionByItems.months },
  317. yaxis: { title: { text: "出庫量" } },
  318. plotOptions: { bar: { columnWidth: "60%" } },
  319. dataLabels: { enabled: false },
  320. legend: { position: "top" },
  321. }}
  322. series={chartData.consumptionByItems.series}
  323. type="bar"
  324. width="100%"
  325. height={320}
  326. />
  327. ) : (
  328. <ApexCharts
  329. options={{
  330. chart: { type: "bar" },
  331. xaxis: { categories: chartData.consumption.map((c) => c.month) },
  332. yaxis: { title: { text: "出庫量" } },
  333. plotOptions: { bar: { columnWidth: "60%" } },
  334. dataLabels: { enabled: false },
  335. }}
  336. series={[{ name: "出庫量", data: chartData.consumption.map((c) => c.outQty) }]}
  337. type="bar"
  338. width="100%"
  339. height={320}
  340. />
  341. )}
  342. </ChartCard>
  343. </Box>
  344. );
  345. }