FPSMS-frontend
No puede seleccionar más de 25 temas Los temas deben comenzar con una letra o número, pueden incluir guiones ('-') y pueden tener hasta 35 caracteres de largo.
 
 

491 líneas
20 KiB

  1. "use client";
  2. /**
  3. * 權限說明(與全站一致):
  4. * - 登入後 JWT / session 帶有 `abilities: string[]`(見 config/authConfig、authorities.ts)。
  5. * - 導航「Finished Good Order」等使用 `requiredAbility: [AUTH.STOCK_FG, AUTH.ADMIN]`。
  6. * - 本表「撤銷領取 / 強制完成」僅允許具 **ADMIN** 能力者操作(對應 DB `authority.authority = 'ADMIN'`,例如 `user_authority` 對應 id=8 之權限列)。
  7. * - 一般使用者可進入本頁與檢視列表;按鈕會 disabled 並以 Tooltip 提示。
  8. */
  9. import React, { useState, useEffect, useCallback, useMemo } from "react";
  10. import {
  11. Box,
  12. Typography,
  13. FormControl,
  14. InputLabel,
  15. Select,
  16. MenuItem,
  17. Card,
  18. CardContent,
  19. Stack,
  20. Table,
  21. TableBody,
  22. TableCell,
  23. TableContainer,
  24. TableHead,
  25. TableRow,
  26. Paper,
  27. CircularProgress,
  28. TablePagination,
  29. Chip,
  30. Button,
  31. Tooltip,
  32. } from "@mui/material";
  33. import { useTranslation } from "react-i18next";
  34. import { useSession } from "next-auth/react";
  35. import dayjs, { Dayjs } from "dayjs";
  36. import { arrayToDayjs } from "@/app/utils/formatUtil";
  37. import { fetchTicketReleaseTable, getTicketReleaseTable } from "@/app/api/do/actions";
  38. import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
  39. import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
  40. import { DatePicker } from "@mui/x-date-pickers/DatePicker";
  41. import {
  42. forceCompleteDoPickOrder,
  43. revertDoPickOrderAssignment,
  44. } from "@/app/api/pickOrder/actions";
  45. import Swal from "sweetalert2";
  46. import { AUTH } from "@/authorities";
  47. import { SessionWithTokens } from "@/config/authConfig";
  48. function isCompletedStatus(status: string | null | undefined): boolean {
  49. return (status ?? "").toLowerCase() === "completed";
  50. }
  51. /** 已領取(有負責人)的進行中單據才可撤銷或強制完成;未領取不可強制完成 */
  52. function showDoPickOpsButtons(row: getTicketReleaseTable): boolean {
  53. return (
  54. row.isActiveDoPickOrder === true &&
  55. !isCompletedStatus(row.ticketStatus) &&
  56. row.handledBy != null
  57. );
  58. }
  59. const FGPickOrderTicketReleaseTable: React.FC = () => {
  60. const { t } = useTranslation("ticketReleaseTable");
  61. const { data: session } = useSession() as { data: SessionWithTokens | null };
  62. const abilities = session?.abilities ?? session?.user?.abilities ?? [];
  63. // 依照 DB `authority.authority = 'ADMIN'` 的逻辑:仅 abilities 明確包含 ADMIN 才允許操作
  64. // (避免 abilities 裡出現前後空白導致 includes 判斷失效)
  65. const canManageDoPickOps = abilities.some((a) => a.trim() === AUTH.ADMIN);
  66. const [queryDate, setQueryDate] = useState<Dayjs>(() => dayjs());
  67. const [selectedFloor, setSelectedFloor] = useState<string>("");
  68. const [selectedStatus, setSelectedStatus] = useState<string>("released");
  69. const [data, setData] = useState<getTicketReleaseTable[]>([]);
  70. const [loading, setLoading] = useState<boolean>(true);
  71. const [paginationController, setPaginationController] = useState({
  72. pageNum: 0,
  73. pageSize: 5,
  74. });
  75. const [now, setNow] = useState(dayjs());
  76. const [lastDataRefreshTime, setLastDataRefreshTime] = useState<dayjs.Dayjs | null>(null);
  77. const formatTime = (timeData: unknown): string => {
  78. if (!timeData) return "";
  79. let hour: number;
  80. let minute: number;
  81. if (typeof timeData === "string") {
  82. const parts = timeData.split(":");
  83. hour = parseInt(parts[0], 10);
  84. minute = parseInt(parts[1] || "0", 10);
  85. } else if (Array.isArray(timeData)) {
  86. hour = timeData[0] || 0;
  87. minute = timeData[1] || 0;
  88. } else {
  89. return "";
  90. }
  91. const formattedHour = hour.toString().padStart(2, "0");
  92. const formattedMinute = minute.toString().padStart(2, "0");
  93. return `${formattedHour}:${formattedMinute}`;
  94. };
  95. const loadData = useCallback(async () => {
  96. setLoading(true);
  97. try {
  98. const dayStr = queryDate.format("YYYY-MM-DD");
  99. const result = await fetchTicketReleaseTable(dayStr, dayStr);
  100. setData(result);
  101. setLastDataRefreshTime(dayjs());
  102. } catch (error) {
  103. console.error("Error fetching ticket release table:", error);
  104. } finally {
  105. setLoading(false);
  106. }
  107. }, [queryDate]);
  108. useEffect(() => {
  109. loadData();
  110. const id = setInterval(loadData, 5 * 60 * 1000);
  111. return () => clearInterval(id);
  112. }, [loadData]);
  113. useEffect(() => {
  114. const tick = setInterval(() => setNow(dayjs()), 30 * 1000);
  115. return () => clearInterval(tick);
  116. }, []);
  117. const dayStr = queryDate.format("YYYY-MM-DD");
  118. const filteredData = useMemo(() => {
  119. return data.filter((item) => {
  120. if (selectedFloor && item.storeId !== selectedFloor) {
  121. return false;
  122. }
  123. if (item.requiredDeliveryDate) {
  124. const itemDate = dayjs(item.requiredDeliveryDate).format("YYYY-MM-DD");
  125. if (itemDate !== dayStr) {
  126. return false;
  127. }
  128. }
  129. if (selectedStatus && item.ticketStatus?.toLowerCase() !== selectedStatus.toLowerCase()) {
  130. return false;
  131. }
  132. return true;
  133. });
  134. }, [data, dayStr, selectedFloor, selectedStatus]);
  135. const handlePageChange = useCallback((event: unknown, newPage: number) => {
  136. setPaginationController((prev) => ({
  137. ...prev,
  138. pageNum: newPage,
  139. }));
  140. }, []);
  141. const handlePageSizeChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
  142. const newPageSize = parseInt(event.target.value, 10);
  143. setPaginationController({
  144. pageNum: 0,
  145. pageSize: newPageSize,
  146. });
  147. }, []);
  148. const paginatedData = useMemo(() => {
  149. const startIndex = paginationController.pageNum * paginationController.pageSize;
  150. const endIndex = startIndex + paginationController.pageSize;
  151. return filteredData.slice(startIndex, endIndex);
  152. }, [filteredData, paginationController]);
  153. useEffect(() => {
  154. setPaginationController((prev) => ({ ...prev, pageNum: 0 }));
  155. }, [queryDate, selectedFloor, selectedStatus]);
  156. const handleRevert = async (row: getTicketReleaseTable) => {
  157. if (!canManageDoPickOps) return;
  158. const r = await Swal.fire({
  159. title: t("Confirm revert assignment"),
  160. text: t("Revert assignment hint"),
  161. icon: "warning",
  162. showCancelButton: true,
  163. confirmButtonText: t("Confirm"),
  164. cancelButtonText: t("Cancel"),
  165. });
  166. if (!r.isConfirmed) return;
  167. try {
  168. const res = await revertDoPickOrderAssignment(row.id);
  169. if (res.code === "SUCCESS") {
  170. await Swal.fire({ icon: "success", text: t("Operation succeeded"), timer: 1500, showConfirmButton: false });
  171. await loadData();
  172. } else {
  173. await Swal.fire({ icon: "error", title: res.code ?? "", text: res.message ?? "" });
  174. }
  175. } catch (e) {
  176. console.error(e);
  177. await Swal.fire({ icon: "error", text: String(e) });
  178. }
  179. };
  180. const handleForceComplete = async (row: getTicketReleaseTable) => {
  181. if (!canManageDoPickOps) return;
  182. const r = await Swal.fire({
  183. title: t("Confirm force complete"),
  184. text: t("Force complete hint"),
  185. icon: "warning",
  186. showCancelButton: true,
  187. confirmButtonText: t("Confirm"),
  188. cancelButtonText: t("Cancel"),
  189. });
  190. if (!r.isConfirmed) return;
  191. try {
  192. const res = await forceCompleteDoPickOrder(row.id);
  193. if (res.code === "SUCCESS") {
  194. await Swal.fire({ icon: "success", text: t("Operation succeeded"), timer: 1500, showConfirmButton: false });
  195. await loadData();
  196. } else {
  197. await Swal.fire({ icon: "error", title: res.code ?? "", text: res.message ?? "" });
  198. }
  199. } catch (e) {
  200. console.error(e);
  201. await Swal.fire({ icon: "error", text: String(e) });
  202. }
  203. };
  204. const opsTooltip = !canManageDoPickOps ? t("Manager only hint") : "";
  205. return (
  206. <LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="zh-hk">
  207. <Card sx={{ mb: 2 }}>
  208. <CardContent>
  209. <Typography variant="h5" sx={{ fontWeight: 600, mb: 2 }}>
  210. {t("Ticket Release Table")}
  211. </Typography>
  212. <Stack direction="row" spacing={2} sx={{ mb: 3, flexWrap: "wrap", alignItems: "center" }}>
  213. <DatePicker
  214. label={t("Target Date")}
  215. value={queryDate}
  216. onChange={(v) => v && setQueryDate(v)}
  217. slotProps={{ textField: { size: "small", sx: { minWidth: 180 } } }}
  218. />
  219. <Button variant="outlined" size="small" onClick={() => void loadData()}>
  220. {t("Reload data")}
  221. </Button>
  222. <FormControl sx={{ minWidth: 150 }} size="small">
  223. <InputLabel id="floor-select-label" shrink>
  224. {t("Floor")}
  225. </InputLabel>
  226. <Select
  227. labelId="floor-select-label"
  228. value={selectedFloor}
  229. label={t("Floor")}
  230. onChange={(e) => setSelectedFloor(e.target.value)}
  231. displayEmpty
  232. >
  233. <MenuItem value="">{t("All Floors")}</MenuItem>
  234. <MenuItem value="2/F">2/F</MenuItem>
  235. <MenuItem value="4/F">4/F</MenuItem>
  236. </Select>
  237. </FormControl>
  238. <FormControl sx={{ minWidth: 150 }} size="small">
  239. <InputLabel id="status-select-label" shrink>
  240. {t("Status")}
  241. </InputLabel>
  242. <Select
  243. labelId="status-select-label"
  244. value={selectedStatus}
  245. label={t("Status")}
  246. onChange={(e) => setSelectedStatus(e.target.value)}
  247. displayEmpty
  248. >
  249. <MenuItem value="">{t("All Statuses")}</MenuItem>
  250. <MenuItem value="pending">{t("pending")}</MenuItem>
  251. <MenuItem value="released">{t("released")}</MenuItem>
  252. <MenuItem value="completed">{t("completed")}</MenuItem>
  253. </Select>
  254. </FormControl>
  255. <Box sx={{ flexGrow: 1 }} />
  256. <Stack direction="row" spacing={2} sx={{ flexShrink: 0, alignSelf: "center" }}>
  257. <Typography variant="body2" sx={{ color: "text.secondary" }} suppressHydrationWarning>
  258. {t("Now")}: {now.format("HH:mm")}
  259. </Typography>
  260. <Typography variant="body2" sx={{ color: "text.secondary" }}>
  261. {t("Auto-refresh every 5 minutes")} | {t("Last updated")}:{" "}
  262. {lastDataRefreshTime ? lastDataRefreshTime.format("HH:mm:ss") : "--:--:--"}
  263. </Typography>
  264. </Stack>
  265. </Stack>
  266. <Box sx={{ mt: 2 }}>
  267. {loading ? (
  268. <Box sx={{ display: "flex", justifyContent: "center", p: 3 }}>
  269. <CircularProgress />
  270. </Box>
  271. ) : (
  272. <>
  273. <TableContainer component={Paper} sx={{ maxHeight: 440, overflow: "auto" }}>
  274. <Table size="small" sx={{ minWidth: 650 }}>
  275. <TableHead>
  276. <TableRow
  277. sx={{
  278. position: "sticky",
  279. top: 0,
  280. zIndex: 1,
  281. backgroundColor: "grey.100",
  282. }}
  283. >
  284. <TableCell>{t("Store ID")}</TableCell>
  285. <TableCell>{t("Required Delivery Date")}</TableCell>
  286. <TableCell>
  287. <Box sx={{ display: "flex", flexDirection: "column", gap: 0.5 }}>
  288. <Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
  289. {t("Truck Information")}
  290. </Typography>
  291. <Typography variant="caption" sx={{ color: "text.secondary" }}>
  292. {t("Truck Lane Code")} - {t("Departure Time")}
  293. </Typography>
  294. </Box>
  295. </TableCell>
  296. <TableCell sx={{ minWidth: 200, width: "20%" }}>{t("Shop Name")}</TableCell>
  297. <TableCell align="right">{t("Loading Sequence")}</TableCell>
  298. <TableCell>
  299. <Box sx={{ display: "flex", flexDirection: "column", gap: 0.5 }}>
  300. <Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
  301. {t("Ticket Information")}
  302. </Typography>
  303. <Typography variant="caption" sx={{ color: "text.secondary" }}>
  304. {t("Ticket No.")} ({t("Status")})
  305. </Typography>
  306. <Typography variant="caption" sx={{ color: "text.secondary" }}>
  307. {t("Released Time")} - {t("Completed Time")}
  308. </Typography>
  309. </Box>
  310. </TableCell>
  311. <TableCell>{t("Handler Name")}</TableCell>
  312. <TableCell align="right" sx={{ minWidth: 100, width: "8%", whiteSpace: "nowrap" }}>
  313. {t("Number of FG Items (Order Item(s) Count)")}
  314. </TableCell>
  315. <TableCell align="center" sx={{ minWidth: 200 }}>
  316. {t("Actions")}
  317. </TableCell>
  318. </TableRow>
  319. </TableHead>
  320. <TableBody>
  321. {paginatedData.length === 0 ? (
  322. <TableRow>
  323. <TableCell colSpan={9} align="center">
  324. {t("No data available")}
  325. </TableCell>
  326. </TableRow>
  327. ) : (
  328. paginatedData.map((row) => (
  329. <TableRow key={`${row.id}-${row.ticketNo}-${row.requiredDeliveryDate}`}>
  330. <TableCell>{row.storeId || "-"}</TableCell>
  331. <TableCell>
  332. {row.requiredDeliveryDate
  333. ? dayjs(row.requiredDeliveryDate).format("YYYY-MM-DD")
  334. : "-"}
  335. </TableCell>
  336. <TableCell>
  337. <Box sx={{ display: "flex", gap: 0.5, flexWrap: "wrap", alignItems: "center" }}>
  338. {row.truckLanceCode && (
  339. <Chip label={row.truckLanceCode} size="small" color="primary" />
  340. )}
  341. {row.truckDepartureTime && (
  342. <Chip label={formatTime(row.truckDepartureTime)} size="small" color="secondary" />
  343. )}
  344. {!row.truckLanceCode && !row.truckDepartureTime && (
  345. <Typography variant="body2" sx={{ color: "text.secondary" }}>
  346. -
  347. </Typography>
  348. )}
  349. </Box>
  350. </TableCell>
  351. <TableCell sx={{ minWidth: 200, width: "20%" }}>{row.shopName || "-"}</TableCell>
  352. <TableCell align="right">{row.loadingSequence ?? "-"}</TableCell>
  353. <TableCell>
  354. <Box sx={{ display: "flex", flexDirection: "column", gap: 0.5 }}>
  355. <Typography variant="body2">
  356. {row.ticketNo || "-"} ({row.ticketStatus ? t(row.ticketStatus.toLowerCase()) : "-"})
  357. </Typography>
  358. <Typography variant="body2">
  359. {t("Released Time")}:{" "}
  360. {row.ticketReleaseTime
  361. ? (() => {
  362. if (Array.isArray(row.ticketReleaseTime)) {
  363. return arrayToDayjs(row.ticketReleaseTime, true).format("HH:mm");
  364. }
  365. const parsedDate = dayjs(row.ticketReleaseTime, "YYYYMMDDHHmmss");
  366. if (!parsedDate.isValid()) {
  367. return dayjs(row.ticketReleaseTime).format("HH:mm");
  368. }
  369. return parsedDate.format("HH:mm");
  370. })()
  371. : "-"}
  372. </Typography>
  373. <Typography variant="body2">
  374. {t("Completed Time")}:{" "}
  375. {row.ticketCompleteDateTime
  376. ? (() => {
  377. if (Array.isArray(row.ticketCompleteDateTime)) {
  378. return arrayToDayjs(row.ticketCompleteDateTime, true).format("HH:mm");
  379. }
  380. const parsedDate = dayjs(row.ticketCompleteDateTime, "YYYYMMDDHHmmss");
  381. if (!parsedDate.isValid()) {
  382. return dayjs(row.ticketCompleteDateTime).format("HH:mm");
  383. }
  384. return parsedDate.format("HH:mm");
  385. })()
  386. : "-"}
  387. </Typography>
  388. </Box>
  389. </TableCell>
  390. <TableCell>{row.handlerName ?? "-"}</TableCell>
  391. <TableCell align="right" sx={{ minWidth: 100, width: "8%" }}>
  392. {row.numberOfFGItems ?? 0}
  393. </TableCell>
  394. <TableCell align="center">
  395. {showDoPickOpsButtons(row) ? (
  396. <Stack direction="row" spacing={1} justifyContent="center" flexWrap="wrap" useFlexGap>
  397. <Tooltip title={opsTooltip}>
  398. <span>
  399. <Button
  400. size="small"
  401. variant="outlined"
  402. color="warning"
  403. disabled={!canManageDoPickOps}
  404. onClick={() => void handleRevert(row)}
  405. >
  406. {t("Revert assignment")}
  407. </Button>
  408. </span>
  409. </Tooltip>
  410. <Tooltip title={opsTooltip}>
  411. <span>
  412. <Button
  413. size="small"
  414. variant="outlined"
  415. color="primary"
  416. disabled={!canManageDoPickOps}
  417. onClick={() => void handleForceComplete(row)}
  418. >
  419. {t("Force complete DO")}
  420. </Button>
  421. </span>
  422. </Tooltip>
  423. </Stack>
  424. ) : (
  425. <Typography variant="caption" color="text.secondary">
  426. </Typography>
  427. )}
  428. </TableCell>
  429. </TableRow>
  430. ))
  431. )}
  432. </TableBody>
  433. </Table>
  434. </TableContainer>
  435. {filteredData.length > 0 && (
  436. <TablePagination
  437. component="div"
  438. count={filteredData.length}
  439. page={paginationController.pageNum}
  440. rowsPerPage={paginationController.pageSize}
  441. onPageChange={handlePageChange}
  442. onRowsPerPageChange={handlePageSizeChange}
  443. rowsPerPageOptions={[5, 10, 15]}
  444. labelRowsPerPage={t("Rows per page")}
  445. />
  446. )}
  447. </>
  448. )}
  449. </Box>
  450. </CardContent>
  451. </Card>
  452. </LocalizationProvider>
  453. );
  454. };
  455. export default FGPickOrderTicketReleaseTable;