|
|
|
@@ -17,6 +17,8 @@ import { |
|
|
|
DialogTitle, |
|
|
|
Grid, |
|
|
|
Stack, |
|
|
|
Tab, |
|
|
|
Tabs, |
|
|
|
Typography, |
|
|
|
} from "@mui/material"; |
|
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
|
|
|
@@ -54,6 +56,8 @@ export function roundCardToSession(card: StockTakeRoundCard): AllPickedStockTake |
|
|
|
}; |
|
|
|
} |
|
|
|
|
|
|
|
type RoundListView = "open" | "history"; |
|
|
|
|
|
|
|
interface StockTakeRoundCardListProps { |
|
|
|
isAdmin: boolean; |
|
|
|
onEnterRound: (card: StockTakeRoundCard, rounds: StockTakeRoundCard[]) => void; |
|
|
|
@@ -74,8 +78,8 @@ const StockTakeRoundCardList: React.FC<StockTakeRoundCardListProps> = ({ |
|
|
|
onYearMonthChange, |
|
|
|
}) => { |
|
|
|
const { t, i18n } = useTranslation(["stockTake", "common"]); |
|
|
|
const [listView, setListView] = useState<RoundListView>("open"); |
|
|
|
const [loading, setLoading] = useState(true); |
|
|
|
const [hasOpenRounds, setHasOpenRounds] = useState(false); |
|
|
|
const [rounds, setRounds] = useState<StockTakeRoundCard[]>([]); |
|
|
|
const [completedYearMonths, setCompletedYearMonths] = useState<string[]>([]); |
|
|
|
const [closingId, setClosingId] = useState<number | null>(null); |
|
|
|
@@ -83,17 +87,15 @@ const StockTakeRoundCardList: React.FC<StockTakeRoundCardListProps> = ({ |
|
|
|
const closeLockRef = useRef(false); |
|
|
|
const thisMonth = dayjs().format("YYYY-MM"); |
|
|
|
|
|
|
|
const load = useCallback(async (ym: string) => { |
|
|
|
const load = useCallback(async (ym: string, view: RoundListView) => { |
|
|
|
setLoading(true); |
|
|
|
try { |
|
|
|
const res = await getStockTakeRoundCards(ym); |
|
|
|
setHasOpenRounds(Boolean(res.hasOpenRounds)); |
|
|
|
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); |
|
|
|
setHasOpenRounds(false); |
|
|
|
setRounds([]); |
|
|
|
setCompletedYearMonths([]); |
|
|
|
} finally { |
|
|
|
@@ -102,8 +104,8 @@ const StockTakeRoundCardList: React.FC<StockTakeRoundCardListProps> = ({ |
|
|
|
}, []); |
|
|
|
|
|
|
|
useEffect(() => { |
|
|
|
void load(yearMonth); |
|
|
|
}, [load, yearMonth, refreshNonce]); |
|
|
|
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(() => { |
|
|
|
@@ -125,6 +127,22 @@ const StockTakeRoundCardList: React.FC<StockTakeRoundCardListProps> = ({ |
|
|
|
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; |
|
|
|
@@ -132,16 +150,39 @@ const StockTakeRoundCardList: React.FC<StockTakeRoundCardListProps> = ({ |
|
|
|
? 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 (hasOpenRounds || monthOptions.length === 0) return; |
|
|
|
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; |
|
|
|
@@ -149,7 +190,7 @@ const StockTakeRoundCardList: React.FC<StockTakeRoundCardListProps> = ({ |
|
|
|
try { |
|
|
|
await closeStockTakeRound(card.stockTakeRoundId); |
|
|
|
setPendingCloseCard(null); |
|
|
|
await load(yearMonth); |
|
|
|
await load(yearMonth, listView); |
|
|
|
} catch (e) { |
|
|
|
console.error("Failed to close stock take round:", e); |
|
|
|
} finally { |
|
|
|
@@ -168,21 +209,38 @@ const StockTakeRoundCardList: React.FC<StockTakeRoundCardListProps> = ({ |
|
|
|
const s = (status || "").toLowerCase().replace(/_/g, ""); |
|
|
|
if (s === "completed") return "success"; |
|
|
|
if (s === "approving") return "info"; |
|
|
|
if (s === "stocktaking" || s === "processing") return "primary"; // 盤點中 |
|
|
|
if (s === "pending") return "warning"; // 待處理 |
|
|
|
if (s === "stocktaking" || s === "processing") return "primary"; |
|
|
|
if (s === "pending") return "warning"; |
|
|
|
return "default"; |
|
|
|
}; |
|
|
|
|
|
|
|
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"> |
|
|
|
{hasOpenRounds |
|
|
|
{listView === "open" |
|
|
|
? t("Open rounds this month") |
|
|
|
: t("Completed rounds for month", { month: formatYearMonth(yearMonth) })} |
|
|
|
</Typography> |
|
|
|
<Stack direction="row" spacing={1} alignItems="center"> |
|
|
|
{!hasOpenRounds && monthOptions.length > 0 ? ( |
|
|
|
{listView === "history" && monthOptions.length > 0 ? ( |
|
|
|
<> |
|
|
|
<Button |
|
|
|
size="small" |
|
|
|
@@ -192,18 +250,30 @@ const StockTakeRoundCardList: React.FC<StockTakeRoundCardListProps> = ({ |
|
|
|
> |
|
|
|
{olderMonth ? formatYearMonth(olderMonth) : t("Previous month")} |
|
|
|
</Button> |
|
|
|
<FormControl size="small" sx={{ minWidth: 140 }}> |
|
|
|
<FormControl size="small" sx={{ minWidth: 96 }}> |
|
|
|
<Select |
|
|
|
value={monthOptions.includes(yearMonth) ? yearMonth : monthOptions[0]} |
|
|
|
onChange={(e) => onYearMonthChange(String(e.target.value))} |
|
|
|
inputProps={{ "aria-label": t("Completed rounds for month", { month: formatYearMonth(yearMonth) }) }} |
|
|
|
MenuProps={{ |
|
|
|
PaperProps: { sx: { maxHeight: 48 * 6 } }, |
|
|
|
}} |
|
|
|
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 } } }} |
|
|
|
> |
|
|
|
{monthOptions.map((ym) => ( |
|
|
|
<MenuItem key={ym} value={ym}> |
|
|
|
{formatYearMonth(ym)} |
|
|
|
{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> |
|
|
|
@@ -239,6 +309,9 @@ const StockTakeRoundCardList: React.FC<StockTakeRoundCardListProps> = ({ |
|
|
|
const total = isItemRound ? (card.totalInventoryLotNumber || 0) : (card.sectionCount || 0); |
|
|
|
const taken = card.stockTakenCount || 0; |
|
|
|
const approved = card.approvedCount || 0; |
|
|
|
const progressLegend = isItemRound |
|
|
|
? t("Round progress legend lots") |
|
|
|
: t("Round progress legend"); |
|
|
|
return ( |
|
|
|
<Grid key={card.stockTakeRoundId} item xs={12} sm={6} md={4}> |
|
|
|
<Card |
|
|
|
@@ -263,45 +336,28 @@ const StockTakeRoundCardList: React.FC<StockTakeRoundCardListProps> = ({ |
|
|
|
{t("Plan Start Date")}:{" "} |
|
|
|
{card.planStartDate ? dayjs(card.planStartDate).format(OUTPUT_DATE_FORMAT) : "-"} |
|
|
|
</Typography> |
|
|
|
<Typography variant="body2" color="text.secondary"> |
|
|
|
{card.stockTakeRoundMode === "item" |
|
|
|
? `${t("Total Items")}: ${card.totalItemNumber || 0}` |
|
|
|
: `${t("Total Sections")}: ${card.sectionCount}`} |
|
|
|
</Typography> |
|
|
|
<Typography variant="body2" color="text.secondary"> |
|
|
|
{t("Main floor")}: {card.mainFloor?.trim() || "-"} |
|
|
|
</Typography> |
|
|
|
<Stack |
|
|
|
direction="row" |
|
|
|
spacing={2} |
|
|
|
sx={{ mt: 1.5 }} |
|
|
|
alignItems="flex-start" |
|
|
|
justifyContent="space-between" |
|
|
|
> |
|
|
|
<Stack spacing={0.25} sx={{ flex: 1, minWidth: 0 }}> |
|
|
|
<Typography variant="caption" color="text.secondary" noWrap> |
|
|
|
{isItemRound ? t("Stock taken lots") : t("Stock taken")} |
|
|
|
</Typography> |
|
|
|
<Typography |
|
|
|
fontWeight={700} |
|
|
|
color="primary.main" |
|
|
|
sx={{ fontSize: "1.5rem", lineHeight: 1.2, fontVariantNumeric: "tabular-nums" }} |
|
|
|
> |
|
|
|
{taken} / {total} |
|
|
|
</Typography> |
|
|
|
</Stack> |
|
|
|
<Stack spacing={0.25} sx={{ flex: 1, minWidth: 0 }}> |
|
|
|
<Typography variant="caption" color="text.secondary" noWrap> |
|
|
|
{isItemRound ? t("Approved lots") : t("Approved progress")} |
|
|
|
</Typography> |
|
|
|
<Typography |
|
|
|
fontWeight={700} |
|
|
|
color="success.main" |
|
|
|
sx={{ fontSize: "1.5rem", lineHeight: 1.2, fontVariantNumeric: "tabular-nums" }} |
|
|
|
> |
|
|
|
{approved} / {total} |
|
|
|
</Typography> |
|
|
|
</Stack> |
|
|
|
<Stack spacing={0.25}> |
|
|
|
<Typography variant="body2" color="text.secondary" noWrap> |
|
|
|
{progressLegend} |
|
|
|
</Typography> |
|
|
|
<Typography |
|
|
|
fontWeight={700} |
|
|
|
sx={{ |
|
|
|
fontSize: "1.5rem", |
|
|
|
lineHeight: 1.2, |
|
|
|
fontVariantNumeric: "tabular-nums", |
|
|
|
letterSpacing: 0.5, |
|
|
|
}} |
|
|
|
> |
|
|
|
<Box component="span" sx={{ color: "primary.main" }}>{taken}</Box> |
|
|
|
<Box component="span" sx={{ color: "text.disabled", mx: 0.75 }}>/</Box> |
|
|
|
<Box component="span" sx={{ color: "success.main" }}>{approved}</Box> |
|
|
|
<Box component="span" sx={{ color: "text.disabled", mx: 0.75 }}>/</Box> |
|
|
|
<Box component="span" sx={{ color: "text.primary" }}>{total}</Box> |
|
|
|
</Typography> |
|
|
|
</Stack> |
|
|
|
</CardContent> |
|
|
|
<CardActions sx={{ pt: 0.5, justifyContent: "space-between", flexWrap: "wrap", gap: 1 }}> |
|
|
|
|