FPSMS-frontend
Вы не можете выбрать более 25 тем Темы должны начинаться с буквы или цифры, могут содержать дефисы(-) и должны содержать не более 35 символов.

454 строки
16 KiB

  1. "use client";
  2. import {
  3. Box,
  4. Button,
  5. Card,
  6. FormControl,
  7. MenuItem,
  8. Select,
  9. CardActions,
  10. CardContent,
  11. Chip,
  12. CircularProgress,
  13. LinearProgress,
  14. Dialog,
  15. DialogActions,
  16. DialogContent,
  17. DialogTitle,
  18. Grid,
  19. Stack,
  20. Tab,
  21. Tabs,
  22. Typography,
  23. } from "@mui/material";
  24. import { useCallback, useEffect, useMemo, useRef, useState } from "react";
  25. import { useTranslation } from "react-i18next";
  26. import dayjs from "dayjs";
  27. import { OUTPUT_DATE_FORMAT } from "@/app/utils/formatUtil";
  28. import {
  29. closeStockTakeRound,
  30. getStockTakeRoundCards,
  31. type AllPickedStockTakeListReponse,
  32. type StockTakeRoundCard,
  33. } from "@/app/api/stockTake/actions";
  34. export function roundCardToSession(card: StockTakeRoundCard): AllPickedStockTakeListReponse {
  35. return {
  36. id: card.stockTakeRoundId,
  37. stockTakeSession: card.stockTakeRoundName || "",
  38. lastStockTakeDate: null,
  39. status: card.status,
  40. approverName: null,
  41. currentStockTakeItemNumber: 0,
  42. totalInventoryLotNumber: card.totalInventoryLotNumber || 0,
  43. stockTakeId: card.stockTakeId,
  44. stockTakeRoundId: card.stockTakeRoundId,
  45. stockTakerName: null,
  46. totalItemNumber: card.totalItemNumber || 0,
  47. startTime: null,
  48. endTime: null,
  49. planStartDate: card.planStartDate,
  50. stockTakeSectionDescription: null,
  51. warehouseArea: null,
  52. storeId: card.mainFloor,
  53. reStockTakeTrueFalse: card.reStockTakeTrueFalse,
  54. listMode: card.stockTakeRoundMode === "item" ? "item" : "section",
  55. };
  56. }
  57. type RoundListView = "open" | "history";
  58. interface StockTakeRoundCardListProps {
  59. isAdmin: boolean;
  60. onEnterRound: (card: StockTakeRoundCard, rounds: StockTakeRoundCard[]) => void;
  61. onCreateClick: () => void;
  62. creating?: boolean;
  63. refreshNonce?: number;
  64. yearMonth: string;
  65. onYearMonthChange: (yearMonth: string) => void;
  66. }
  67. const StockTakeRoundCardList: React.FC<StockTakeRoundCardListProps> = ({
  68. isAdmin,
  69. onEnterRound,
  70. onCreateClick,
  71. creating = false,
  72. refreshNonce = 0,
  73. yearMonth,
  74. onYearMonthChange,
  75. }) => {
  76. const { t, i18n } = useTranslation(["stockTake", "common"]);
  77. const [listView, setListView] = useState<RoundListView>("open");
  78. const [loading, setLoading] = useState(true);
  79. const [rounds, setRounds] = useState<StockTakeRoundCard[]>([]);
  80. const [completedYearMonths, setCompletedYearMonths] = useState<string[]>([]);
  81. const [closingId, setClosingId] = useState<number | null>(null);
  82. const [pendingCloseCard, setPendingCloseCard] = useState<StockTakeRoundCard | null>(null);
  83. const closeLockRef = useRef(false);
  84. const thisMonth = dayjs().format("YYYY-MM");
  85. const load = useCallback(async (ym: string, view: RoundListView) => {
  86. setLoading(true);
  87. try {
  88. const res = await getStockTakeRoundCards(view === "history" ? ym : undefined, view);
  89. setRounds(Array.isArray(res.rounds) ? res.rounds : []);
  90. const months = Array.isArray(res.completedYearMonths) ? res.completedYearMonths : [];
  91. setCompletedYearMonths(months);
  92. } catch (e) {
  93. console.error("Failed to load stock take rounds:", e);
  94. setRounds([]);
  95. setCompletedYearMonths([]);
  96. } finally {
  97. setLoading(false);
  98. }
  99. }, []);
  100. useEffect(() => {
  101. void load(yearMonth, listView);
  102. }, [load, yearMonth, listView, refreshNonce]);
  103. // Every month from the earliest round through this month, including months with no round.
  104. const monthOptions = useMemo(() => {
  105. const known = completedYearMonths.filter((m) => m && m <= thisMonth);
  106. const earliest = known.reduce((a, b) => (a < b ? a : b), thisMonth);
  107. const months: string[] = [];
  108. let cursor = dayjs(`${earliest}-01`);
  109. const end = dayjs(`${thisMonth}-01`);
  110. if (!cursor.isValid() || !end.isValid()) return [thisMonth];
  111. while (cursor.isBefore(end) || cursor.isSame(end, "month")) {
  112. months.push(cursor.format("YYYY-MM"));
  113. cursor = cursor.add(1, "month");
  114. }
  115. return months.sort((a, b) => b.localeCompare(a));
  116. }, [completedYearMonths, thisMonth]);
  117. const monthIndex = useMemo(() => {
  118. const i = monthOptions.indexOf(yearMonth);
  119. return i >= 0 ? i : 0;
  120. }, [monthOptions, yearMonth]);
  121. const selectedYm = monthOptions.includes(yearMonth) ? yearMonth : (monthOptions[0] || thisMonth);
  122. const selectedYear = selectedYm.slice(0, 4);
  123. const selectedMonth = selectedYm.slice(5, 7);
  124. const yearOptions = useMemo(() => {
  125. const years = Array.from(new Set(monthOptions.map((ym) => ym.slice(0, 4))));
  126. return years.sort((a, b) => b.localeCompare(a));
  127. }, [monthOptions]);
  128. const monthOptionsForYear = useMemo(() => {
  129. return monthOptions
  130. .filter((ym) => ym.startsWith(`${selectedYear}-`))
  131. .map((ym) => ym.slice(5, 7))
  132. .sort((a, b) => Number(b) - Number(a));
  133. }, [monthOptions, selectedYear]);
  134. const formatYearMonth = (ym: string) => {
  135. const parsed = dayjs(`${ym}-01`);
  136. if (!parsed.isValid()) return ym;
  137. return i18n.language?.toLowerCase().startsWith("zh")
  138. ? parsed.format("YYYY年M月")
  139. : parsed.format("MMM YYYY");
  140. };
  141. const formatYearLabel = (year: string) =>
  142. i18n.language?.toLowerCase().startsWith("zh") ? `${year}${t("Year suffix")}` : year;
  143. const formatMonthLabel = (month: string) => {
  144. const n = Number(month);
  145. if (!Number.isFinite(n)) return month;
  146. return i18n.language?.toLowerCase().startsWith("zh")
  147. ? `${n}${t("Month suffix")}`
  148. : dayjs().month(n - 1).format("MMM");
  149. };
  150. const olderMonth = monthOptions[monthIndex + 1];
  151. const newerMonth = monthIndex > 0 ? monthOptions[monthIndex - 1] : undefined;
  152. const goMonth = (delta: number) => {
  153. if (listView !== "history" || monthOptions.length === 0) return;
  154. const next = monthIndex + delta;
  155. if (next < 0 || next >= monthOptions.length) return;
  156. onYearMonthChange(monthOptions[next]);
  157. };
  158. const onYearChange = (year: string) => {
  159. const monthsInYear = monthOptions
  160. .filter((ym) => ym.startsWith(`${year}-`))
  161. .map((ym) => ym.slice(5, 7));
  162. const keepMonth = monthsInYear.includes(selectedMonth) ? selectedMonth : monthsInYear[0];
  163. if (!keepMonth) return;
  164. onYearMonthChange(`${year}-${keepMonth}`);
  165. };
  166. const onMonthChange = (month: string) => {
  167. onYearMonthChange(`${selectedYear}-${month}`);
  168. };
  169. const handleCloseRound = async (card: StockTakeRoundCard) => {
  170. if (!isAdmin || closeLockRef.current) return;
  171. closeLockRef.current = true;
  172. setClosingId(card.stockTakeRoundId);
  173. try {
  174. await closeStockTakeRound(card.stockTakeRoundId);
  175. setPendingCloseCard(null);
  176. await load(yearMonth, listView);
  177. } catch (e) {
  178. console.error("Failed to close stock take round:", e);
  179. } finally {
  180. setClosingId(null);
  181. closeLockRef.current = false;
  182. }
  183. };
  184. const roundLabel = (card: StockTakeRoundCard) => {
  185. const name = card.stockTakeRoundName?.trim();
  186. if (name) return name;
  187. return `${t("Stock Take Round")} #${card.stockTakeRoundId}`;
  188. };
  189. const progressRow = (label: string, done: number, total: number, barColor: "primary.main" | "success.main") => {
  190. const pct = total > 0 ? Math.min(100, (done / total) * 100) : 0;
  191. return (
  192. <Box>
  193. <Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "baseline", gap: 1 }}>
  194. <Typography variant="caption" color="text.secondary">
  195. {label}
  196. </Typography>
  197. <Typography variant="body2" fontWeight={600} sx={{ fontVariantNumeric: "tabular-nums" }}>
  198. {t("Count of total", { done, total })}
  199. </Typography>
  200. </Box>
  201. <LinearProgress
  202. variant="determinate"
  203. value={pct}
  204. aria-label={`${label} ${t("Count of total", { done, total })}`}
  205. sx={{
  206. mt: 0.4,
  207. height: 4,
  208. borderRadius: 99,
  209. bgcolor: "action.hover",
  210. "& .MuiLinearProgress-bar": { borderRadius: 99, bgcolor: barColor },
  211. }}
  212. />
  213. </Box>
  214. );
  215. };
  216. return (
  217. <Box>
  218. <Tabs
  219. value={listView}
  220. onChange={(_, next: RoundListView) => setListView(next)}
  221. sx={{ mb: 1.5, minHeight: 36 }}
  222. >
  223. <Tab
  224. value="open"
  225. label={t("In progress")}
  226. sx={{ minHeight: 36, py: 0.5, textTransform: "none" }}
  227. />
  228. <Tab
  229. value="history"
  230. label={t("History rounds")}
  231. sx={{ minHeight: 36, py: 0.5, textTransform: "none" }}
  232. />
  233. </Tabs>
  234. <Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", mb: 2, gap: 1, flexWrap: "wrap" }}>
  235. <Typography variant="body2" color="text.secondary">
  236. {listView === "open"
  237. ? t("Open rounds this month")
  238. : t("Completed rounds for month", { month: formatYearMonth(yearMonth) })}
  239. </Typography>
  240. <Stack direction="row" spacing={1} alignItems="center">
  241. {listView === "history" && monthOptions.length > 0 ? (
  242. <>
  243. <Button
  244. size="small"
  245. variant="outlined"
  246. disabled={!olderMonth}
  247. onClick={() => goMonth(1)}
  248. >
  249. {olderMonth ? formatYearMonth(olderMonth) : t("Previous month")}
  250. </Button>
  251. <FormControl size="small" sx={{ minWidth: 96 }}>
  252. <Select
  253. value={yearOptions.includes(selectedYear) ? selectedYear : yearOptions[0]}
  254. onChange={(e) => onYearChange(String(e.target.value))}
  255. inputProps={{ "aria-label": t("Year") }}
  256. MenuProps={{ PaperProps: { sx: { maxHeight: 48 * 6 } } }}
  257. >
  258. {yearOptions.map((year) => (
  259. <MenuItem key={year} value={year}>
  260. {formatYearLabel(year)}
  261. </MenuItem>
  262. ))}
  263. </Select>
  264. </FormControl>
  265. <FormControl size="small" sx={{ minWidth: 88 }}>
  266. <Select
  267. value={monthOptionsForYear.includes(selectedMonth) ? selectedMonth : monthOptionsForYear[0]}
  268. onChange={(e) => onMonthChange(String(e.target.value))}
  269. inputProps={{ "aria-label": t("Month") }}
  270. MenuProps={{ PaperProps: { sx: { maxHeight: 48 * 6 } } }}
  271. >
  272. {monthOptionsForYear.map((month) => (
  273. <MenuItem key={month} value={month}>
  274. {formatMonthLabel(month)}
  275. </MenuItem>
  276. ))}
  277. </Select>
  278. </FormControl>
  279. <Button
  280. size="small"
  281. variant="outlined"
  282. disabled={!newerMonth}
  283. onClick={() => goMonth(-1)}
  284. >
  285. {newerMonth ? formatYearMonth(newerMonth) : t("Next month")}
  286. </Button>
  287. </>
  288. ) : null}
  289. <Button variant="contained" onClick={onCreateClick} disabled={creating || !isAdmin}>
  290. {creating ? <CircularProgress size={20} /> : t("Create Stock Take (Select Sections)")}
  291. </Button>
  292. </Stack>
  293. </Box>
  294. {loading ? (
  295. <Box sx={{ display: "flex", justifyContent: "center", p: 3 }}>
  296. <CircularProgress />
  297. </Box>
  298. ) : rounds.length === 0 ? (
  299. <Typography variant="body2" color="text.secondary" sx={{ py: 4, textAlign: "center" }}>
  300. {t("No data")}
  301. </Typography>
  302. ) : (
  303. <Grid container spacing={2}>
  304. {rounds.map((card) => {
  305. const isItemRound = card.stockTakeRoundMode === "item";
  306. const total = isItemRound ? (card.totalInventoryLotNumber || 0) : (card.sectionCount || 0);
  307. const taken = card.stockTakenCount || 0;
  308. const approved = card.approvedCount || 0;
  309. const floor = card.mainFloor?.trim();
  310. const modeLabel = isItemRound ? t("Create by item") : t("Create by section");
  311. return (
  312. <Grid key={card.stockTakeRoundId} item xs={12} sm={6} md={4}>
  313. <Card
  314. sx={{
  315. minHeight: 180,
  316. display: "flex",
  317. flexDirection: "column",
  318. border: "1px solid",
  319. borderColor: "divider",
  320. }}
  321. >
  322. <CardContent sx={{ pb: 1, flexGrow: 1 }}>
  323. <Stack direction="row" spacing={1} alignItems="flex-start" justifyContent="space-between">
  324. <Box sx={{ minWidth: 0 }}>
  325. <Typography variant="subtitle1" fontWeight={600} noWrap>
  326. {roundLabel(card)}
  327. </Typography>
  328. <Stack direction="row" spacing={0.75} alignItems="center" sx={{ mt: 0.25, flexWrap: "wrap" }}>
  329. <Chip
  330. size="small"
  331. label={modeLabel}
  332. color={isItemRound ? "info" : "primary"}
  333. />
  334. {floor ? (
  335. <Typography variant="body2" color="text.secondary">
  336. {floor}
  337. </Typography>
  338. ) : null}
  339. </Stack>
  340. </Box>
  341. <Chip
  342. size="small"
  343. variant="outlined"
  344. color={statusChipColor(card.status)}
  345. label={t(card.status || " ")}
  346. sx={{ flexShrink: 0 }}
  347. />
  348. </Stack>
  349. <Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
  350. {t("Plan Start Date")}:{" "}
  351. {card.planStartDate ? dayjs(card.planStartDate).format(OUTPUT_DATE_FORMAT) : "-"}
  352. </Typography>
  353. <Typography variant="caption" color="text.secondary" sx={{ display: "block", mt: 1.5 }}>
  354. {isItemRound ? t("Round unit lots") : t("Round unit sections")}
  355. </Typography>
  356. <Stack spacing={1} sx={{ mt: 0.5 }}>
  357. {progressRow(t("progressCounted"), taken, total, "primary.main")}
  358. {progressRow(t("progressApproved"), approved, total, "success.main")}
  359. </Stack>
  360. </CardContent>
  361. <CardActions sx={{ pt: 0.5, gap: 1 }}>
  362. <Button size="small" variant="contained" onClick={() => onEnterRound(card, rounds)}>
  363. {t("View Details")}
  364. </Button>
  365. {isAdmin && card.open ? (
  366. <Button
  367. size="small"
  368. variant="outlined"
  369. color="warning"
  370. disabled={closingId === card.stockTakeRoundId}
  371. onClick={() => setPendingCloseCard(card)}
  372. >
  373. {closingId === card.stockTakeRoundId ? <CircularProgress size={16} /> : t("Close round")}
  374. </Button>
  375. ) : null}
  376. </CardActions>
  377. </Card>
  378. </Grid>
  379. );
  380. })}
  381. </Grid>
  382. )}
  383. <Dialog
  384. open={Boolean(pendingCloseCard)}
  385. onClose={() => {
  386. if (closingId == null) setPendingCloseCard(null);
  387. }}
  388. maxWidth="sm"
  389. fullWidth
  390. >
  391. <DialogTitle>{t("Confirm close round")}</DialogTitle>
  392. <DialogContent dividers>
  393. <Typography variant="body2">{t("Close round confirm message")}</Typography>
  394. </DialogContent>
  395. <DialogActions sx={{ px: 3, py: 2 }}>
  396. <Button
  397. onClick={() => setPendingCloseCard(null)}
  398. disabled={closingId != null}
  399. >
  400. {t("Cancel")}
  401. </Button>
  402. <Button
  403. variant="contained"
  404. color="warning"
  405. disabled={!pendingCloseCard || closingId != null}
  406. onClick={() => {
  407. if (pendingCloseCard) void handleCloseRound(pendingCloseCard);
  408. }}
  409. >
  410. {closingId != null ? <CircularProgress size={18} /> : t("Close round")}
  411. </Button>
  412. </DialogActions>
  413. </Dialog>
  414. </Box>
  415. );
  416. };
  417. type StatusChipColor = "default" | "primary" | "secondary" | "error" | "info" | "success" | "warning";
  418. export function statusChipColor(status: string): StatusChipColor {
  419. const s = (status || "").toLowerCase().replace(/_/g, "");
  420. if (s === "completed") return "success";
  421. if (s === "pending") return "warning";
  422. if (s === "approving") return "info";
  423. if (s === "stocktaking" || s === "processing" || s === "inprogress") return "primary";
  424. if (s === "notmatch") return "warning";
  425. return "default";
  426. }
  427. export default StockTakeRoundCardList;