|
- "use client";
-
- /**
- * 權限說明(與全站一致):
- * - 登入後 JWT / session 帶有 `abilities: string[]`(見 config/authConfig、authorities.ts)。
- * - 導航「Finished Good Order」等使用 `requiredAbility: [AUTH.STOCK_FG, AUTH.ADMIN]`。
- * - 本表「撤銷領取 / 強制完成」僅允許具 **ADMIN** 能力者操作(對應 DB `authority.authority = 'ADMIN'`,例如 `user_authority` 對應 id=8 之權限列)。
- * - 一般使用者可進入本頁與檢視列表;按鈕會 disabled 並以 Tooltip 提示。
- */
-
- import React, { useState, useEffect, useCallback, useMemo } from "react";
- import {
- Box,
- Typography,
- FormControl,
- InputLabel,
- Select,
- MenuItem,
- Card,
- CardContent,
- Stack,
- Table,
- TableBody,
- TableCell,
- TableContainer,
- TableHead,
- TableRow,
- Paper,
- CircularProgress,
- TablePagination,
- Chip,
- Button,
- Tooltip,
- } from "@mui/material";
- import { useTranslation } from "react-i18next";
- import { useSession } from "next-auth/react";
- import dayjs, { Dayjs } from "dayjs";
- import { arrayToDayjs } from "@/app/utils/formatUtil";
- import { fetchTicketReleaseTable, getTicketReleaseTable } from "@/app/api/do/actions";
- import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
- import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
- import { DatePicker } from "@mui/x-date-pickers/DatePicker";
- import {
- forceCompleteDoPickOrder,
- revertDoPickOrderAssignment,
- } from "@/app/api/pickOrder/actions";
- import Swal from "sweetalert2";
- import { AUTH } from "@/authorities";
- import { SessionWithTokens } from "@/config/authConfig";
-
- function isCompletedStatus(status: string | null | undefined): boolean {
- return (status ?? "").toLowerCase() === "completed";
- }
-
- /** 已領取(有負責人)的進行中單據才可撤銷或強制完成;未領取不可強制完成 */
- function showDoPickOpsButtons(row: getTicketReleaseTable): boolean {
- return (
- row.isActiveDoPickOrder === true &&
- !isCompletedStatus(row.ticketStatus) &&
- row.handledBy != null
- );
- }
-
- const FGPickOrderTicketReleaseTable: React.FC = () => {
- const { t } = useTranslation("ticketReleaseTable");
- const { data: session } = useSession() as { data: SessionWithTokens | null };
- const abilities = session?.abilities ?? session?.user?.abilities ?? [];
- // 依照 DB `authority.authority = 'ADMIN'` 的逻辑:仅 abilities 明確包含 ADMIN 才允許操作
- // (避免 abilities 裡出現前後空白導致 includes 判斷失效)
- const canManageDoPickOps = abilities.some((a) => a.trim() === AUTH.ADMIN);
-
- const [queryDate, setQueryDate] = useState<Dayjs>(() => dayjs());
- const [selectedFloor, setSelectedFloor] = useState<string>("");
- const [selectedStatus, setSelectedStatus] = useState<string>("released");
-
- const [data, setData] = useState<getTicketReleaseTable[]>([]);
- const [loading, setLoading] = useState<boolean>(true);
- const [paginationController, setPaginationController] = useState({
- pageNum: 0,
- pageSize: 5,
- });
-
- const [now, setNow] = useState(dayjs());
- const [lastDataRefreshTime, setLastDataRefreshTime] = useState<dayjs.Dayjs | null>(null);
-
- const formatTime = (timeData: unknown): string => {
- if (!timeData) return "";
-
- let hour: number;
- let minute: number;
-
- if (typeof timeData === "string") {
- const parts = timeData.split(":");
- hour = parseInt(parts[0], 10);
- minute = parseInt(parts[1] || "0", 10);
- } else if (Array.isArray(timeData)) {
- hour = timeData[0] || 0;
- minute = timeData[1] || 0;
- } else {
- return "";
- }
-
- const formattedHour = hour.toString().padStart(2, "0");
- const formattedMinute = minute.toString().padStart(2, "0");
- return `${formattedHour}:${formattedMinute}`;
- };
-
- const loadData = useCallback(async () => {
- setLoading(true);
- try {
- const dayStr = queryDate.format("YYYY-MM-DD");
- const result = await fetchTicketReleaseTable(dayStr, dayStr);
- setData(result);
- setLastDataRefreshTime(dayjs());
- } catch (error) {
- console.error("Error fetching ticket release table:", error);
- } finally {
- setLoading(false);
- }
- }, [queryDate]);
-
- useEffect(() => {
- loadData();
- const id = setInterval(loadData, 5 * 60 * 1000);
- return () => clearInterval(id);
- }, [loadData]);
-
- useEffect(() => {
- const tick = setInterval(() => setNow(dayjs()), 30 * 1000);
- return () => clearInterval(tick);
- }, []);
-
- const dayStr = queryDate.format("YYYY-MM-DD");
-
- const filteredData = useMemo(() => {
- return data.filter((item) => {
- if (selectedFloor && item.storeId !== selectedFloor) {
- return false;
- }
- if (item.requiredDeliveryDate) {
- const itemDate = dayjs(item.requiredDeliveryDate).format("YYYY-MM-DD");
- if (itemDate !== dayStr) {
- return false;
- }
- }
- if (selectedStatus && item.ticketStatus?.toLowerCase() !== selectedStatus.toLowerCase()) {
- return false;
- }
- return true;
- });
- }, [data, dayStr, selectedFloor, selectedStatus]);
-
- const handlePageChange = useCallback((event: unknown, newPage: number) => {
- setPaginationController((prev) => ({
- ...prev,
- pageNum: newPage,
- }));
- }, []);
-
- const handlePageSizeChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
- const newPageSize = parseInt(event.target.value, 10);
- setPaginationController({
- pageNum: 0,
- pageSize: newPageSize,
- });
- }, []);
-
- const paginatedData = useMemo(() => {
- const startIndex = paginationController.pageNum * paginationController.pageSize;
- const endIndex = startIndex + paginationController.pageSize;
- return filteredData.slice(startIndex, endIndex);
- }, [filteredData, paginationController]);
-
- useEffect(() => {
- setPaginationController((prev) => ({ ...prev, pageNum: 0 }));
- }, [queryDate, selectedFloor, selectedStatus]);
-
- const handleRevert = async (row: getTicketReleaseTable) => {
- if (!canManageDoPickOps) return;
- const r = await Swal.fire({
- title: t("Confirm revert assignment"),
- text: t("Revert assignment hint"),
- icon: "warning",
- showCancelButton: true,
- confirmButtonText: t("Confirm"),
- cancelButtonText: t("Cancel"),
- });
- if (!r.isConfirmed) return;
- try {
- const res = await revertDoPickOrderAssignment(row.id);
- if (res.code === "SUCCESS") {
- await Swal.fire({ icon: "success", text: t("Operation succeeded"), timer: 1500, showConfirmButton: false });
- await loadData();
- } else {
- await Swal.fire({ icon: "error", title: res.code ?? "", text: res.message ?? "" });
- }
- } catch (e) {
- console.error(e);
- await Swal.fire({ icon: "error", text: String(e) });
- }
- };
-
- const handleForceComplete = async (row: getTicketReleaseTable) => {
- if (!canManageDoPickOps) return;
- const r = await Swal.fire({
- title: t("Confirm force complete"),
- text: t("Force complete hint"),
- icon: "warning",
- showCancelButton: true,
- confirmButtonText: t("Confirm"),
- cancelButtonText: t("Cancel"),
- });
- if (!r.isConfirmed) return;
- try {
- const res = await forceCompleteDoPickOrder(row.id);
- if (res.code === "SUCCESS") {
- await Swal.fire({ icon: "success", text: t("Operation succeeded"), timer: 1500, showConfirmButton: false });
- await loadData();
- } else {
- await Swal.fire({ icon: "error", title: res.code ?? "", text: res.message ?? "" });
- }
- } catch (e) {
- console.error(e);
- await Swal.fire({ icon: "error", text: String(e) });
- }
- };
-
- const opsTooltip = !canManageDoPickOps ? t("Manager only hint") : "";
-
- return (
- <LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="zh-hk">
- <Card sx={{ mb: 2 }}>
- <CardContent>
- <Typography variant="h5" sx={{ fontWeight: 600, mb: 2 }}>
- {t("Ticket Release Table")}
- </Typography>
-
- <Stack direction="row" spacing={2} sx={{ mb: 3, flexWrap: "wrap", alignItems: "center" }}>
- <DatePicker
- label={t("Target Date")}
- value={queryDate}
- onChange={(v) => v && setQueryDate(v)}
- slotProps={{ textField: { size: "small", sx: { minWidth: 180 } } }}
- />
- <Button variant="outlined" size="small" onClick={() => void loadData()}>
- {t("Reload data")}
- </Button>
-
- <FormControl sx={{ minWidth: 150 }} size="small">
- <InputLabel id="floor-select-label" shrink>
- {t("Floor")}
- </InputLabel>
- <Select
- labelId="floor-select-label"
- value={selectedFloor}
- label={t("Floor")}
- onChange={(e) => setSelectedFloor(e.target.value)}
- displayEmpty
- >
- <MenuItem value="">{t("All Floors")}</MenuItem>
- <MenuItem value="2/F">2/F</MenuItem>
- <MenuItem value="4/F">4/F</MenuItem>
- </Select>
- </FormControl>
-
- <FormControl sx={{ minWidth: 150 }} size="small">
- <InputLabel id="status-select-label" shrink>
- {t("Status")}
- </InputLabel>
- <Select
- labelId="status-select-label"
- value={selectedStatus}
- label={t("Status")}
- onChange={(e) => setSelectedStatus(e.target.value)}
- displayEmpty
- >
- <MenuItem value="">{t("All Statuses")}</MenuItem>
- <MenuItem value="pending">{t("pending")}</MenuItem>
- <MenuItem value="released">{t("released")}</MenuItem>
- <MenuItem value="completed">{t("completed")}</MenuItem>
- </Select>
- </FormControl>
-
- <Box sx={{ flexGrow: 1 }} />
- <Stack direction="row" spacing={2} sx={{ flexShrink: 0, alignSelf: "center" }}>
- <Typography variant="body2" sx={{ color: "text.secondary" }}>
- {t("Now")}: {now.format("HH:mm")}
- </Typography>
- <Typography variant="body2" sx={{ color: "text.secondary" }}>
- {t("Auto-refresh every 5 minutes")} | {t("Last updated")}:{" "}
- {lastDataRefreshTime ? lastDataRefreshTime.format("HH:mm:ss") : "--:--:--"}
- </Typography>
- </Stack>
- </Stack>
-
- <Box sx={{ mt: 2 }}>
- {loading ? (
- <Box sx={{ display: "flex", justifyContent: "center", p: 3 }}>
- <CircularProgress />
- </Box>
- ) : (
- <>
- <TableContainer component={Paper} sx={{ maxHeight: 440, overflow: "auto" }}>
- <Table size="small" sx={{ minWidth: 650 }}>
- <TableHead>
- <TableRow
- sx={{
- position: "sticky",
- top: 0,
- zIndex: 1,
- backgroundColor: "grey.100",
- }}
- >
- <TableCell>{t("Store ID")}</TableCell>
- <TableCell>{t("Required Delivery Date")}</TableCell>
- <TableCell>
- <Box sx={{ display: "flex", flexDirection: "column", gap: 0.5 }}>
- <Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
- {t("Truck Information")}
- </Typography>
- <Typography variant="caption" sx={{ color: "text.secondary" }}>
- {t("Truck Lane Code")} - {t("Departure Time")}
- </Typography>
- </Box>
- </TableCell>
- <TableCell sx={{ minWidth: 200, width: "20%" }}>{t("Shop Name")}</TableCell>
- <TableCell align="right">{t("Loading Sequence")}</TableCell>
- <TableCell>
- <Box sx={{ display: "flex", flexDirection: "column", gap: 0.5 }}>
- <Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
- {t("Ticket Information")}
- </Typography>
- <Typography variant="caption" sx={{ color: "text.secondary" }}>
- {t("Ticket No.")} ({t("Status")})
- </Typography>
- <Typography variant="caption" sx={{ color: "text.secondary" }}>
- {t("Released Time")} - {t("Completed Time")}
- </Typography>
- </Box>
- </TableCell>
- <TableCell>{t("Handler Name")}</TableCell>
- <TableCell align="right" sx={{ minWidth: 100, width: "8%", whiteSpace: "nowrap" }}>
- {t("Number of FG Items (Order Item(s) Count)")}
- </TableCell>
- <TableCell align="center" sx={{ minWidth: 200 }}>
- {t("Actions")}
- </TableCell>
- </TableRow>
- </TableHead>
- <TableBody>
- {paginatedData.length === 0 ? (
- <TableRow>
- <TableCell colSpan={9} align="center">
- {t("No data available")}
- </TableCell>
- </TableRow>
- ) : (
- paginatedData.map((row) => (
- <TableRow key={`${row.id}-${row.ticketNo}-${row.requiredDeliveryDate}`}>
- <TableCell>{row.storeId || "-"}</TableCell>
- <TableCell>
- {row.requiredDeliveryDate
- ? dayjs(row.requiredDeliveryDate).format("YYYY-MM-DD")
- : "-"}
- </TableCell>
-
- <TableCell>
- <Box sx={{ display: "flex", gap: 0.5, flexWrap: "wrap", alignItems: "center" }}>
- {row.truckLanceCode && (
- <Chip label={row.truckLanceCode} size="small" color="primary" />
- )}
- {row.truckDepartureTime && (
- <Chip label={formatTime(row.truckDepartureTime)} size="small" color="secondary" />
- )}
- {!row.truckLanceCode && !row.truckDepartureTime && (
- <Typography variant="body2" sx={{ color: "text.secondary" }}>
- -
- </Typography>
- )}
- </Box>
- </TableCell>
-
- <TableCell sx={{ minWidth: 200, width: "20%" }}>{row.shopName || "-"}</TableCell>
- <TableCell align="right">{row.loadingSequence ?? "-"}</TableCell>
-
- <TableCell>
- <Box sx={{ display: "flex", flexDirection: "column", gap: 0.5 }}>
- <Typography variant="body2">
- {row.ticketNo || "-"} ({row.ticketStatus ? t(row.ticketStatus.toLowerCase()) : "-"})
- </Typography>
- <Typography variant="body2">
- {t("Released Time")}:{" "}
- {row.ticketReleaseTime
- ? (() => {
- if (Array.isArray(row.ticketReleaseTime)) {
- return arrayToDayjs(row.ticketReleaseTime, true).format("HH:mm");
- }
- const parsedDate = dayjs(row.ticketReleaseTime, "YYYYMMDDHHmmss");
- if (!parsedDate.isValid()) {
- return dayjs(row.ticketReleaseTime).format("HH:mm");
- }
- return parsedDate.format("HH:mm");
- })()
- : "-"}
- </Typography>
- <Typography variant="body2">
- {t("Completed Time")}:{" "}
- {row.ticketCompleteDateTime
- ? (() => {
- if (Array.isArray(row.ticketCompleteDateTime)) {
- return arrayToDayjs(row.ticketCompleteDateTime, true).format("HH:mm");
- }
- const parsedDate = dayjs(row.ticketCompleteDateTime, "YYYYMMDDHHmmss");
- if (!parsedDate.isValid()) {
- return dayjs(row.ticketCompleteDateTime).format("HH:mm");
- }
- return parsedDate.format("HH:mm");
- })()
- : "-"}
- </Typography>
- </Box>
- </TableCell>
- <TableCell>{row.handlerName ?? "-"}</TableCell>
- <TableCell align="right" sx={{ minWidth: 100, width: "8%" }}>
- {row.numberOfFGItems ?? 0}
- </TableCell>
- <TableCell align="center">
- {showDoPickOpsButtons(row) ? (
- <Stack direction="row" spacing={1} justifyContent="center" flexWrap="wrap" useFlexGap>
- <Tooltip title={opsTooltip}>
- <span>
- <Button
- size="small"
- variant="outlined"
- color="warning"
- disabled={!canManageDoPickOps}
- onClick={() => void handleRevert(row)}
- >
- {t("Revert assignment")}
- </Button>
- </span>
- </Tooltip>
- <Tooltip title={opsTooltip}>
- <span>
- <Button
- size="small"
- variant="outlined"
- color="primary"
- disabled={!canManageDoPickOps}
- onClick={() => void handleForceComplete(row)}
- >
- {t("Force complete DO")}
- </Button>
- </span>
- </Tooltip>
- </Stack>
- ) : (
- <Typography variant="caption" color="text.secondary">
- —
- </Typography>
- )}
- </TableCell>
- </TableRow>
- ))
- )}
- </TableBody>
- </Table>
- </TableContainer>
- {filteredData.length > 0 && (
- <TablePagination
- component="div"
- count={filteredData.length}
- page={paginationController.pageNum}
- rowsPerPage={paginationController.pageSize}
- onPageChange={handlePageChange}
- onRowsPerPageChange={handlePageSizeChange}
- rowsPerPageOptions={[5, 10, 15]}
- labelRowsPerPage={t("Rows per page")}
- />
- )}
- </>
- )}
- </Box>
- </CardContent>
- </Card>
- </LocalizationProvider>
- );
- };
-
- export default FGPickOrderTicketReleaseTable;
|