FPSMS-frontend
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

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