FPSMS-frontend
選択できるのは25トピックまでです。 トピックは、先頭が英数字で、英数字とダッシュ('-')を使用した35文字以内のものにしてください。

PickerCardList.tsx 14 KiB

2ヶ月前
1ヶ月前
2ヶ月前
2ヶ月前
2ヶ月前
2ヶ月前
4週間前
2ヶ月前
2ヶ月前
1週間前
2ヶ月前
1週間前
2ヶ月前
2ヶ月前
2ヶ月前
2ヶ月前
2ヶ月前
1週間前
2ヶ月前
1ヶ月前
2週間前
2週間前
1週間前
1週間前
1週間前
4週間前
1週間前
4週間前
1週間前
2ヶ月前
4週間前
2ヶ月前
1ヶ月前
2ヶ月前
1週間前
2ヶ月前
1週間前
2ヶ月前
2ヶ月前
2ヶ月前
2ヶ月前
2ヶ月前
4週間前
2ヶ月前
2ヶ月前
4週間前
2ヶ月前
2ヶ月前
4週間前
2ヶ月前
1ヶ月前
2ヶ月前
4週間前
2ヶ月前
2ヶ月前
4週間前
2ヶ月前
2ヶ月前
2ヶ月前
2ヶ月前
2ヶ月前
2ヶ月前
2ヶ月前
2ヶ月前
2ヶ月前
2ヶ月前
2ヶ月前
2ヶ月前
4週間前
1週間前
4週間前
2ヶ月前
1ヶ月前
2ヶ月前
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425
  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;