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.
 
 

425 lines
14 KiB

  1. "use client";
  2. import {
  3. Box,
  4. Button,
  5. Card,
  6. CardContent,
  7. CardActions,
  8. Stack,
  9. Typography,
  10. Chip,
  11. CircularProgress,
  12. TablePagination,
  13. Grid,
  14. LinearProgress,
  15. Dialog,
  16. DialogTitle,
  17. DialogContent,
  18. DialogContentText,
  19. DialogActions,
  20. } from "@mui/material";
  21. import SearchBox, { Criterion } from "@/components/SearchBox/SearchBox";
  22. import { useState, useCallback, useEffect } from "react";
  23. import { useTranslation } from "react-i18next";
  24. import duration from "dayjs/plugin/duration";
  25. import {
  26. getStockTakeRecords,
  27. AllPickedStockTakeListReponse,
  28. createStockTakeForSections,
  29. getStockTakeRecordsPaged,
  30. } from "@/app/api/stockTake/actions";
  31. import { fetchStockTakeSections } from "@/app/api/warehouse/actions";
  32. import dayjs from "dayjs";
  33. import { OUTPUT_DATE_FORMAT } from "@/app/utils/formatUtil";
  34. const PER_PAGE = 6;
  35. interface PickerCardListProps {
  36. /** 由父層保存,從明細返回時仍回到同一頁 */
  37. page: number;
  38. pageSize: number;
  39. onListPageChange: (page: number) => void;
  40. onCardClick: (session: AllPickedStockTakeListReponse) => void;
  41. onReStockTakeClick: (session: AllPickedStockTakeListReponse) => void;
  42. }
  43. const PickerCardList: React.FC<PickerCardListProps> = ({
  44. page,
  45. pageSize,
  46. onListPageChange,
  47. onCardClick,
  48. onReStockTakeClick,
  49. }) => {
  50. const { t } = useTranslation(["inventory", "common"]);
  51. dayjs.extend(duration);
  52. const PER_PAGE = 6;
  53. const [loading, setLoading] = useState(false);
  54. const [stockTakeSessions, setStockTakeSessions] = useState<AllPickedStockTakeListReponse[]>([]);
  55. const [total, setTotal] = useState(0);
  56. /** 建立盤點後若仍在 page 0,仍強制重新載入 */
  57. const [listRefreshNonce, setListRefreshNonce] = useState(0);
  58. const [creating, setCreating] = useState(false);
  59. const [openConfirmDialog, setOpenConfirmDialog] = useState(false);
  60. const [filterSectionDescription, setFilterSectionDescription] = useState<string>("All");
  61. const [filterStockTakeSession, setFilterStockTakeSession] = useState<string>("");
  62. const [sectionDescriptionAutocompleteOptions, setSectionDescriptionAutocompleteOptions] = useState<{ value: string; label: string }[]>([]);
  63. type PickerSearchKey = "sectionDescription" | "stockTakeSession";
  64. const sectionDescriptionOptions = Array.from(
  65. new Set(
  66. stockTakeSessions
  67. .map((s) => s.stockTakeSectionDescription)
  68. .filter((v): v is string => !!v)
  69. )
  70. );
  71. /*
  72. // 按 description + section 双条件过滤
  73. const filteredSessions = stockTakeSessions.filter((s) => {
  74. const matchDesc =
  75. filterSectionDescription === "All" ||
  76. s.stockTakeSectionDescription === filterSectionDescription;
  77. const sessionParts = (filterStockTakeSession ?? "")
  78. .split(",")
  79. .map((p) => p.trim().toLowerCase())
  80. .filter(Boolean);
  81. const matchSession =
  82. sessionParts.length === 0 ||
  83. sessionParts.some((part) =>
  84. (s.stockTakeSession ?? "").toString().toLowerCase().includes(part)
  85. );
  86. return matchDesc && matchSession;
  87. });
  88. */
  89. // SearchBox 的条件配置
  90. const criteria: Criterion<PickerSearchKey>[] = [
  91. {
  92. type: "autocomplete",
  93. label: t("Stock Take Section Description"),
  94. paramName: "sectionDescription",
  95. options: sectionDescriptionAutocompleteOptions,
  96. needAll: true,
  97. },
  98. {
  99. type: "text",
  100. label: t("Stock Take Section (can use , to search multiple sections)"),
  101. paramName: "stockTakeSession",
  102. placeholder: "",
  103. },
  104. ];
  105. const handleSearch = (inputs: Record<PickerSearchKey | `${PickerSearchKey}To`, string>) => {
  106. setFilterSectionDescription(inputs.sectionDescription || "All");
  107. setFilterStockTakeSession(inputs.stockTakeSession || "");
  108. onListPageChange(0);
  109. };
  110. const handleResetSearch = () => {
  111. setFilterSectionDescription("All");
  112. setFilterStockTakeSession("");
  113. onListPageChange(0);
  114. };
  115. useEffect(() => {
  116. let cancelled = false;
  117. setLoading(true);
  118. getStockTakeRecordsPaged(page, pageSize, {
  119. sectionDescription: filterSectionDescription,
  120. stockTakeSections: filterStockTakeSession,
  121. })
  122. .then((res) => {
  123. if (cancelled) return;
  124. setStockTakeSessions(Array.isArray(res.records) ? res.records : []);
  125. setTotal(res.total || 0);
  126. })
  127. .catch((e) => {
  128. console.error(e);
  129. if (!cancelled) {
  130. setStockTakeSessions([]);
  131. setTotal(0);
  132. }
  133. })
  134. .finally(() => {
  135. if (!cancelled) setLoading(false);
  136. });
  137. return () => {
  138. cancelled = true;
  139. };
  140. }, [page, pageSize, filterSectionDescription, filterStockTakeSession, listRefreshNonce]);
  141. //const startIdx = page * PER_PAGE;
  142. //const paged = stockTakeSessions.slice(startIdx, startIdx + PER_PAGE);
  143. const handleCreateStockTake = useCallback(async () => {
  144. setOpenConfirmDialog(false);
  145. setCreating(true);
  146. try {
  147. const result = await createStockTakeForSections();
  148. const createdCount = Object.values(result).filter(msg => msg.startsWith("Created:")).length;
  149. const skippedCount = Object.values(result).filter(msg => msg.startsWith("Skipped:")).length;
  150. const errorCount = Object.values(result).filter(msg => msg.startsWith("Error:")).length;
  151. let message = `${t("Created")}: ${createdCount}, ${t("Skipped")}: ${skippedCount}`;
  152. if (errorCount > 0) {
  153. message += `, ${t("Errors")}: ${errorCount}`;
  154. }
  155. console.log(message);
  156. onListPageChange(0);
  157. setListRefreshNonce((n) => n + 1);
  158. } catch (e) {
  159. console.error(e);
  160. } finally {
  161. setCreating(false);
  162. }
  163. }, [onListPageChange, t]);
  164. useEffect(() => {
  165. fetchStockTakeSections()
  166. .then((sections) => {
  167. const descSet = new Set<string>();
  168. sections.forEach((s) => {
  169. const desc = s.stockTakeSectionDescription?.trim();
  170. if (desc) descSet.add(desc);
  171. });
  172. setSectionDescriptionAutocompleteOptions(
  173. Array.from(descSet).map((desc) => ({ value: desc, label: desc }))
  174. );
  175. })
  176. .catch((e) => {
  177. console.error("Failed to load section descriptions for filter:", e);
  178. });
  179. }, []);
  180. const getStatusColor = (status: string) => {
  181. const statusLower = status.toLowerCase();
  182. if (statusLower === "completed") return "success";
  183. if (statusLower === "in_progress" || statusLower === "processing") return "primary";
  184. if (statusLower === "approving") return "info";
  185. if (statusLower === "stockTaking") return "primary";
  186. if (statusLower === "no_cycle") return "default";
  187. return "warning";
  188. };
  189. const TimeDisplay: React.FC<{ startTime: string | null; endTime: string | null }> = ({ startTime, endTime }) => {
  190. const [currentTime, setCurrentTime] = useState(dayjs());
  191. useEffect(() => {
  192. if (!endTime && startTime) {
  193. const interval = setInterval(() => {
  194. setCurrentTime(dayjs());
  195. }, 1000); // 每秒更新一次
  196. return () => clearInterval(interval);
  197. }
  198. }, [startTime, endTime]);
  199. if (endTime && startTime) {
  200. // 当有结束时间时,计算从开始到结束的持续时间
  201. const start = dayjs(startTime);
  202. const end = dayjs(endTime);
  203. const duration = dayjs.duration(end.diff(start));
  204. const hours = Math.floor(duration.asHours());
  205. const minutes = duration.minutes();
  206. const seconds = duration.seconds();
  207. return (
  208. <>
  209. {hours.toString().padStart(2, '0')}:{minutes.toString().padStart(2, '0')}:{seconds.toString().padStart(2, '0')}
  210. </>
  211. );
  212. } else if (startTime) {
  213. // 当没有结束时间时,显示实时计时器
  214. const start = dayjs(startTime);
  215. const duration = dayjs.duration(currentTime.diff(start));
  216. const hours = Math.floor(duration.asHours());
  217. const minutes = duration.minutes();
  218. const seconds = duration.seconds();
  219. return (
  220. <>
  221. {hours.toString().padStart(2, '0')}:{minutes.toString().padStart(2, '0')}:{seconds.toString().padStart(2, '0')}
  222. </>
  223. );
  224. } else {
  225. return <>-</>;
  226. }
  227. };
  228. const startTimeDisplay = (startTime: string | null) => {
  229. if (startTime) {
  230. const start = dayjs(startTime);
  231. return start.format("HH:mm");
  232. } else {
  233. return "-";
  234. }
  235. };
  236. const endTimeDisplay = (endTime: string | null) => {
  237. if (endTime) {
  238. const end = dayjs(endTime);
  239. return end.format("HH:mm");
  240. } else {
  241. return "-";
  242. }
  243. };
  244. const getCompletionRate = (session: AllPickedStockTakeListReponse): number => {
  245. if (session.totalInventoryLotNumber === 0) return 0;
  246. return Math.round((session.currentStockTakeItemNumber / session.totalInventoryLotNumber) * 100);
  247. };
  248. const planStartDate = (() => {
  249. const first = stockTakeSessions.find(s => s.planStartDate);
  250. if (!first?.planStartDate) return null;
  251. return dayjs(first.planStartDate).format(OUTPUT_DATE_FORMAT);
  252. })();
  253. if (loading) {
  254. return (
  255. <Box sx={{ display: "flex", justifyContent: "center", p: 3 }}>
  256. <CircularProgress />
  257. </Box>
  258. );
  259. }
  260. return (
  261. <Box>
  262. <Box sx={{ width: "100%", mb: 2 }}>
  263. <SearchBox<PickerSearchKey>
  264. criteria={criteria}
  265. onSearch={handleSearch}
  266. onReset={handleResetSearch}
  267. />
  268. </Box>
  269. <Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", mb: 2 }}>
  270. <Typography variant="body2" color="text.secondary">
  271. {t("Total Sections")}: {total}
  272. </Typography>
  273. <Typography variant="body2" color="text.secondary">
  274. {t("Start Stock Take Date")}: {planStartDate || "-"}
  275. </Typography>
  276. <Button
  277. variant="contained"
  278. color="primary"
  279. onClick={() => setOpenConfirmDialog(true)}
  280. disabled={creating}
  281. >
  282. {creating ? <CircularProgress size={20} /> : t("Create Stock Take for All Sections")}
  283. </Button>
  284. </Box>
  285. <Grid container spacing={2}>
  286. {stockTakeSessions.map((session: AllPickedStockTakeListReponse) => {
  287. const statusColor = getStatusColor(session.status || "");
  288. const lastStockTakeDate = session.lastStockTakeDate
  289. ? dayjs(session.lastStockTakeDate).format(OUTPUT_DATE_FORMAT)
  290. : "-";
  291. const completionRate = getCompletionRate(session);
  292. return (
  293. <Grid key={session.id} item xs={12} sm={6} md={4}>
  294. <Card
  295. sx={{
  296. minHeight: 200,
  297. display: "flex",
  298. flexDirection: "column",
  299. border: "1px solid",
  300. borderColor: statusColor === "success" ? "success.main" : "primary.main",
  301. }}
  302. >
  303. <CardContent sx={{ pb: 1, flexGrow: 1 }}>
  304. <Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 1 }}>
  305. <Typography variant="subtitle1" fontWeight={600}>
  306. {t("Section")}: {session.stockTakeSession}
  307. {session.stockTakeSectionDescription ? ` (${session.stockTakeSectionDescription})` : null}
  308. </Typography>
  309. </Stack>
  310. <Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
  311. {t("Last Stock Take Date")}: {lastStockTakeDate || "-"}
  312. </Typography>
  313. <Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>{t("Stock Taker")}: {session.stockTakerName}</Typography>
  314. <Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 1 }}>
  315. <Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>{t("start time")}: {startTimeDisplay(session.startTime) || "-"}</Typography>
  316. <Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>{t("end time")}: {endTimeDisplay(session.endTime) || "-"}</Typography>
  317. </Stack>
  318. <Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
  319. {t("Control Time")}: <TimeDisplay startTime={session.startTime} endTime={session.endTime} />
  320. </Typography>
  321. <Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>{t("Total Item Number")}: {session.totalItemNumber}</Typography>
  322. </CardContent>
  323. <CardActions sx={{ pt: 0.5 ,justifyContent: "space-between"}}>
  324. <Stack direction="row" spacing={1}>
  325. <Button
  326. variant="contained"
  327. size="small"
  328. onClick={() => onCardClick(session)}
  329. >
  330. {t("View Details")}
  331. </Button>
  332. <Button
  333. variant="contained"
  334. size="small"
  335. onClick={() => onReStockTakeClick(session)}
  336. disabled={!session.reStockTakeTrueFalse}
  337. >
  338. {t("View ReStockTake")}
  339. </Button>
  340. </Stack>
  341. <Chip size="small" label={t(session.status || "")} color={statusColor as any} />
  342. </CardActions>
  343. </Card>
  344. </Grid>
  345. );
  346. })}
  347. </Grid>
  348. {total > 0 && (
  349. <TablePagination
  350. component="div"
  351. count={total}
  352. page={page}
  353. rowsPerPage={pageSize}
  354. onPageChange={(e, newPage) => {
  355. onListPageChange(newPage);
  356. }}
  357. rowsPerPageOptions={[pageSize]} // 如果暂时不让用户改 pageSize,就写死
  358. />
  359. )}
  360. {/* Create Stock Take 確認 Dialog */}
  361. <Dialog
  362. open={openConfirmDialog}
  363. onClose={() => setOpenConfirmDialog(false)}
  364. maxWidth="xs"
  365. fullWidth
  366. >
  367. <DialogTitle>{t("Create Stock Take for All Sections")}</DialogTitle>
  368. <DialogContent>
  369. <DialogContentText>
  370. {t("Confirm create stock take for all sections?")}
  371. </DialogContentText>
  372. </DialogContent>
  373. <DialogActions>
  374. <Button onClick={() => setOpenConfirmDialog(false)}>
  375. {t("Cancel")}
  376. </Button>
  377. <Button
  378. variant="contained"
  379. color="primary"
  380. onClick={handleCreateStockTake}
  381. disabled={creating}
  382. >
  383. {creating ? <CircularProgress size={20} /> : t("Confirm")}
  384. </Button>
  385. </DialogActions>
  386. </Dialog>
  387. </Box>
  388. );
  389. };
  390. export default PickerCardList;