|
- "use client";
-
- import {
- Box,
- Button,
- Card,
- FormControl,
- MenuItem,
- Select,
- CardActions,
- CardContent,
- Chip,
- CircularProgress,
- LinearProgress,
- Dialog,
- DialogActions,
- DialogContent,
- DialogTitle,
- Grid,
- Stack,
- Tab,
- Tabs,
- Typography,
- } from "@mui/material";
- import { useCallback, useEffect, useMemo, useRef, useState } from "react";
- import { useTranslation } from "react-i18next";
- import dayjs from "dayjs";
- import { OUTPUT_DATE_FORMAT } from "@/app/utils/formatUtil";
- import {
- closeStockTakeRound,
- getStockTakeRoundCards,
- type AllPickedStockTakeListReponse,
- type StockTakeRoundCard,
- } from "@/app/api/stockTake/actions";
-
- export function roundCardToSession(card: StockTakeRoundCard): AllPickedStockTakeListReponse {
- return {
- id: card.stockTakeRoundId,
- stockTakeSession: card.stockTakeRoundName || "",
- lastStockTakeDate: null,
- status: card.status,
- approverName: null,
- currentStockTakeItemNumber: 0,
- totalInventoryLotNumber: card.totalInventoryLotNumber || 0,
- stockTakeId: card.stockTakeId,
- stockTakeRoundId: card.stockTakeRoundId,
- stockTakerName: null,
- totalItemNumber: card.totalItemNumber || 0,
- startTime: null,
- endTime: null,
- planStartDate: card.planStartDate,
- stockTakeSectionDescription: null,
- warehouseArea: null,
- storeId: card.mainFloor,
- reStockTakeTrueFalse: card.reStockTakeTrueFalse,
- listMode: card.stockTakeRoundMode === "item" ? "item" : "section",
- };
- }
-
- type RoundListView = "open" | "history";
-
- interface StockTakeRoundCardListProps {
- isAdmin: boolean;
- onEnterRound: (card: StockTakeRoundCard, rounds: StockTakeRoundCard[]) => void;
- onCreateClick: () => void;
- creating?: boolean;
- refreshNonce?: number;
- yearMonth: string;
- onYearMonthChange: (yearMonth: string) => void;
- }
-
- const StockTakeRoundCardList: React.FC<StockTakeRoundCardListProps> = ({
- isAdmin,
- onEnterRound,
- onCreateClick,
- creating = false,
- refreshNonce = 0,
- yearMonth,
- onYearMonthChange,
- }) => {
- const { t, i18n } = useTranslation(["stockTake", "common"]);
- const [listView, setListView] = useState<RoundListView>("open");
- const [loading, setLoading] = useState(true);
- const [rounds, setRounds] = useState<StockTakeRoundCard[]>([]);
- const [completedYearMonths, setCompletedYearMonths] = useState<string[]>([]);
- const [closingId, setClosingId] = useState<number | null>(null);
- const [pendingCloseCard, setPendingCloseCard] = useState<StockTakeRoundCard | null>(null);
- const closeLockRef = useRef(false);
- const thisMonth = dayjs().format("YYYY-MM");
-
- const load = useCallback(async (ym: string, view: RoundListView) => {
- setLoading(true);
- try {
- const res = await getStockTakeRoundCards(view === "history" ? ym : undefined, view);
- setRounds(Array.isArray(res.rounds) ? res.rounds : []);
- const months = Array.isArray(res.completedYearMonths) ? res.completedYearMonths : [];
- setCompletedYearMonths(months);
- } catch (e) {
- console.error("Failed to load stock take rounds:", e);
- setRounds([]);
- setCompletedYearMonths([]);
- } finally {
- setLoading(false);
- }
- }, []);
-
- useEffect(() => {
- void load(yearMonth, listView);
- }, [load, yearMonth, listView, refreshNonce]);
-
- // Every month from the earliest round through this month, including months with no round.
- const monthOptions = useMemo(() => {
- const known = completedYearMonths.filter((m) => m && m <= thisMonth);
- const earliest = known.reduce((a, b) => (a < b ? a : b), thisMonth);
- const months: string[] = [];
- let cursor = dayjs(`${earliest}-01`);
- const end = dayjs(`${thisMonth}-01`);
- if (!cursor.isValid() || !end.isValid()) return [thisMonth];
- while (cursor.isBefore(end) || cursor.isSame(end, "month")) {
- months.push(cursor.format("YYYY-MM"));
- cursor = cursor.add(1, "month");
- }
- return months.sort((a, b) => b.localeCompare(a));
- }, [completedYearMonths, thisMonth]);
-
- const monthIndex = useMemo(() => {
- const i = monthOptions.indexOf(yearMonth);
- return i >= 0 ? i : 0;
- }, [monthOptions, yearMonth]);
-
- const selectedYm = monthOptions.includes(yearMonth) ? yearMonth : (monthOptions[0] || thisMonth);
- const selectedYear = selectedYm.slice(0, 4);
- const selectedMonth = selectedYm.slice(5, 7);
-
- const yearOptions = useMemo(() => {
- const years = Array.from(new Set(monthOptions.map((ym) => ym.slice(0, 4))));
- return years.sort((a, b) => b.localeCompare(a));
- }, [monthOptions]);
-
- const monthOptionsForYear = useMemo(() => {
- return monthOptions
- .filter((ym) => ym.startsWith(`${selectedYear}-`))
- .map((ym) => ym.slice(5, 7))
- .sort((a, b) => Number(b) - Number(a));
- }, [monthOptions, selectedYear]);
-
- const formatYearMonth = (ym: string) => {
- const parsed = dayjs(`${ym}-01`);
- if (!parsed.isValid()) return ym;
- return i18n.language?.toLowerCase().startsWith("zh")
- ? parsed.format("YYYY年M月")
- : parsed.format("MMM YYYY");
- };
- const formatYearLabel = (year: string) =>
- i18n.language?.toLowerCase().startsWith("zh") ? `${year}${t("Year suffix")}` : year;
- const formatMonthLabel = (month: string) => {
- const n = Number(month);
- if (!Number.isFinite(n)) return month;
- return i18n.language?.toLowerCase().startsWith("zh")
- ? `${n}${t("Month suffix")}`
- : dayjs().month(n - 1).format("MMM");
- };
-
- const olderMonth = monthOptions[monthIndex + 1];
- const newerMonth = monthIndex > 0 ? monthOptions[monthIndex - 1] : undefined;
-
- const goMonth = (delta: number) => {
- if (listView !== "history" || monthOptions.length === 0) return;
- const next = monthIndex + delta;
- if (next < 0 || next >= monthOptions.length) return;
- onYearMonthChange(monthOptions[next]);
- };
-
- const onYearChange = (year: string) => {
- const monthsInYear = monthOptions
- .filter((ym) => ym.startsWith(`${year}-`))
- .map((ym) => ym.slice(5, 7));
- const keepMonth = monthsInYear.includes(selectedMonth) ? selectedMonth : monthsInYear[0];
- if (!keepMonth) return;
- onYearMonthChange(`${year}-${keepMonth}`);
- };
-
- const onMonthChange = (month: string) => {
- onYearMonthChange(`${selectedYear}-${month}`);
- };
-
- const handleCloseRound = async (card: StockTakeRoundCard) => {
- if (!isAdmin || closeLockRef.current) return;
- closeLockRef.current = true;
- setClosingId(card.stockTakeRoundId);
- try {
- await closeStockTakeRound(card.stockTakeRoundId);
- setPendingCloseCard(null);
- await load(yearMonth, listView);
- } catch (e) {
- console.error("Failed to close stock take round:", e);
- } finally {
- setClosingId(null);
- closeLockRef.current = false;
- }
- };
-
- const roundLabel = (card: StockTakeRoundCard) => {
- const name = card.stockTakeRoundName?.trim();
- if (name) return name;
- return `${t("Stock Take Round")} #${card.stockTakeRoundId}`;
- };
-
- const progressRow = (label: string, done: number, total: number, barColor: "primary.main" | "success.main") => {
- const pct = total > 0 ? Math.min(100, (done / total) * 100) : 0;
- return (
- <Box>
- <Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "baseline", gap: 1 }}>
- <Typography variant="caption" color="text.secondary">
- {label}
- </Typography>
- <Typography variant="body2" fontWeight={600} sx={{ fontVariantNumeric: "tabular-nums" }}>
- {t("Count of total", { done, total })}
- </Typography>
- </Box>
- <LinearProgress
- variant="determinate"
- value={pct}
- aria-label={`${label} ${t("Count of total", { done, total })}`}
- sx={{
- mt: 0.4,
- height: 4,
- borderRadius: 99,
- bgcolor: "action.hover",
- "& .MuiLinearProgress-bar": { borderRadius: 99, bgcolor: barColor },
- }}
- />
- </Box>
- );
- };
-
- return (
- <Box>
- <Tabs
- value={listView}
- onChange={(_, next: RoundListView) => setListView(next)}
- sx={{ mb: 1.5, minHeight: 36 }}
- >
- <Tab
- value="open"
- label={t("In progress")}
- sx={{ minHeight: 36, py: 0.5, textTransform: "none" }}
- />
- <Tab
- value="history"
- label={t("History rounds")}
- sx={{ minHeight: 36, py: 0.5, textTransform: "none" }}
- />
- </Tabs>
-
- <Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", mb: 2, gap: 1, flexWrap: "wrap" }}>
- <Typography variant="body2" color="text.secondary">
- {listView === "open"
- ? t("Open rounds this month")
- : t("Completed rounds for month", { month: formatYearMonth(yearMonth) })}
- </Typography>
- <Stack direction="row" spacing={1} alignItems="center">
- {listView === "history" && monthOptions.length > 0 ? (
- <>
- <Button
- size="small"
- variant="outlined"
- disabled={!olderMonth}
- onClick={() => goMonth(1)}
- >
- {olderMonth ? formatYearMonth(olderMonth) : t("Previous month")}
- </Button>
- <FormControl size="small" sx={{ minWidth: 96 }}>
- <Select
- value={yearOptions.includes(selectedYear) ? selectedYear : yearOptions[0]}
- onChange={(e) => onYearChange(String(e.target.value))}
- inputProps={{ "aria-label": t("Year") }}
- MenuProps={{ PaperProps: { sx: { maxHeight: 48 * 6 } } }}
- >
- {yearOptions.map((year) => (
- <MenuItem key={year} value={year}>
- {formatYearLabel(year)}
- </MenuItem>
- ))}
- </Select>
- </FormControl>
- <FormControl size="small" sx={{ minWidth: 88 }}>
- <Select
- value={monthOptionsForYear.includes(selectedMonth) ? selectedMonth : monthOptionsForYear[0]}
- onChange={(e) => onMonthChange(String(e.target.value))}
- inputProps={{ "aria-label": t("Month") }}
- MenuProps={{ PaperProps: { sx: { maxHeight: 48 * 6 } } }}
- >
- {monthOptionsForYear.map((month) => (
- <MenuItem key={month} value={month}>
- {formatMonthLabel(month)}
- </MenuItem>
- ))}
- </Select>
- </FormControl>
- <Button
- size="small"
- variant="outlined"
- disabled={!newerMonth}
- onClick={() => goMonth(-1)}
- >
- {newerMonth ? formatYearMonth(newerMonth) : t("Next month")}
- </Button>
- </>
- ) : null}
- <Button variant="contained" onClick={onCreateClick} disabled={creating || !isAdmin}>
- {creating ? <CircularProgress size={20} /> : t("Create Stock Take (Select Sections)")}
- </Button>
- </Stack>
- </Box>
-
- {loading ? (
- <Box sx={{ display: "flex", justifyContent: "center", p: 3 }}>
- <CircularProgress />
- </Box>
- ) : rounds.length === 0 ? (
- <Typography variant="body2" color="text.secondary" sx={{ py: 4, textAlign: "center" }}>
- {t("No data")}
- </Typography>
- ) : (
- <Grid container spacing={2}>
- {rounds.map((card) => {
- const isItemRound = card.stockTakeRoundMode === "item";
- const total = isItemRound ? (card.totalInventoryLotNumber || 0) : (card.sectionCount || 0);
- const taken = card.stockTakenCount || 0;
- const approved = card.approvedCount || 0;
- const floor = card.mainFloor?.trim();
- const modeLabel = isItemRound ? t("Create by item") : t("Create by section");
- return (
- <Grid key={card.stockTakeRoundId} item xs={12} sm={6} md={4}>
- <Card
- sx={{
- minHeight: 180,
- display: "flex",
- flexDirection: "column",
- border: "1px solid",
- borderColor: "divider",
- }}
- >
- <CardContent sx={{ pb: 1, flexGrow: 1 }}>
- <Stack direction="row" spacing={1} alignItems="flex-start" justifyContent="space-between">
- <Box sx={{ minWidth: 0 }}>
- <Typography variant="subtitle1" fontWeight={600} noWrap>
- {roundLabel(card)}
- </Typography>
- <Stack direction="row" spacing={0.75} alignItems="center" sx={{ mt: 0.25, flexWrap: "wrap" }}>
- <Chip
- size="small"
- label={modeLabel}
- color={isItemRound ? "info" : "primary"}
- />
- {floor ? (
- <Typography variant="body2" color="text.secondary">
- {floor}
- </Typography>
- ) : null}
- </Stack>
- </Box>
- <Chip
- size="small"
- variant="outlined"
- color={statusChipColor(card.status)}
- label={t(card.status || " ")}
- sx={{ flexShrink: 0 }}
- />
- </Stack>
- <Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
- {t("Plan Start Date")}:{" "}
- {card.planStartDate ? dayjs(card.planStartDate).format(OUTPUT_DATE_FORMAT) : "-"}
- </Typography>
- <Typography variant="caption" color="text.secondary" sx={{ display: "block", mt: 1.5 }}>
- {isItemRound ? t("Round unit lots") : t("Round unit sections")}
- </Typography>
- <Stack spacing={1} sx={{ mt: 0.5 }}>
- {progressRow(t("progressCounted"), taken, total, "primary.main")}
- {progressRow(t("progressApproved"), approved, total, "success.main")}
- </Stack>
- </CardContent>
- <CardActions sx={{ pt: 0.5, gap: 1 }}>
- <Button size="small" variant="contained" onClick={() => onEnterRound(card, rounds)}>
- {t("View Details")}
- </Button>
- {isAdmin && card.open ? (
- <Button
- size="small"
- variant="outlined"
- color="warning"
- disabled={closingId === card.stockTakeRoundId}
- onClick={() => setPendingCloseCard(card)}
- >
- {closingId === card.stockTakeRoundId ? <CircularProgress size={16} /> : t("Close round")}
- </Button>
- ) : null}
- </CardActions>
- </Card>
- </Grid>
- );
- })}
- </Grid>
- )}
- <Dialog
- open={Boolean(pendingCloseCard)}
- onClose={() => {
- if (closingId == null) setPendingCloseCard(null);
- }}
- maxWidth="sm"
- fullWidth
- >
- <DialogTitle>{t("Confirm close round")}</DialogTitle>
- <DialogContent dividers>
- <Typography variant="body2">{t("Close round confirm message")}</Typography>
- </DialogContent>
- <DialogActions sx={{ px: 3, py: 2 }}>
- <Button
- onClick={() => setPendingCloseCard(null)}
- disabled={closingId != null}
- >
- {t("Cancel")}
- </Button>
- <Button
- variant="contained"
- color="warning"
- disabled={!pendingCloseCard || closingId != null}
- onClick={() => {
- if (pendingCloseCard) void handleCloseRound(pendingCloseCard);
- }}
- >
- {closingId != null ? <CircularProgress size={18} /> : t("Close round")}
- </Button>
- </DialogActions>
- </Dialog>
- </Box>
- );
- };
-
- type StatusChipColor = "default" | "primary" | "secondary" | "error" | "info" | "success" | "warning";
-
- export function statusChipColor(status: string): StatusChipColor {
- const s = (status || "").toLowerCase().replace(/_/g, "");
- if (s === "completed") return "success";
- if (s === "pending") return "warning";
- if (s === "approving") return "info";
- if (s === "stocktaking" || s === "processing" || s === "inprogress") return "primary";
- if (s === "notmatch") return "warning";
- return "default";
- }
-
- export default StockTakeRoundCardList;
|