| @@ -131,7 +131,9 @@ export default function ReportPage() { | |||
| }; | |||
| const [loading, setLoading] = useState(false); | |||
| const excelInFlightRef = useRef(false); | |||
| const [dynamicOptions, setDynamicOptions] = useState<Record<string, { label: string; value: string }[]>>({}); | |||
| const [dynamicOptions, setDynamicOptions] = useState< | |||
| Record<string, { label: string; value: string; mode?: string }[]> | |||
| >({}); | |||
| const [showConfirmDialog, setShowConfirmDialog] = useState(false); | |||
| const [showNoDataDialog, setShowNoDataDialog] = useState(false); | |||
| const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({}); | |||
| @@ -259,6 +261,9 @@ export default function ReportPage() { | |||
| : data.map((item: any) => ({ | |||
| label: item.label || item.name || item.code || String(item), | |||
| value: item.value || item.code || String(item), | |||
| ...(item.mode != null && String(item.mode).trim() | |||
| ? { mode: String(item.mode).trim().toLowerCase() } | |||
| : {}), | |||
| })) | |||
| : []; | |||
| @@ -637,7 +642,7 @@ export default function ReportPage() { | |||
| const rawOptions = field.dynamicOptions | |||
| ? (dynamicOptions[field.name] || field.options || []) | |||
| : (field.options || []); | |||
| const options = rawOptions.map((opt) => ({ | |||
| const mappedOptions = rawOptions.map((opt) => ({ | |||
| ...opt, | |||
| label: optionLabel(currentReport.id, field.name, opt), | |||
| })); | |||
| @@ -645,6 +650,26 @@ export default function ReportPage() { | |||
| const valueForSelect = field.multiple | |||
| ? (currentValue ? currentValue.split(',').map(v => v.trim()).filter(v => v) : []) | |||
| : currentValue; | |||
| // rep-012:已選依貨品輪 → 只顯示依貨品;已選依區域 → 只顯示依區域 | |||
| const options = | |||
| currentReport.id === "rep-012" && | |||
| field.name === "stockTakeRoundId" && | |||
| Array.isArray(valueForSelect) && | |||
| valueForSelect.length > 0 | |||
| ? (() => { | |||
| const selectedModes = new Set( | |||
| valueForSelect | |||
| .map( | |||
| (id) => | |||
| mappedOptions.find((o) => o.value === id)?.mode, | |||
| ) | |||
| .filter((m): m is string => Boolean(m)), | |||
| ); | |||
| if (selectedModes.size !== 1) return mappedOptions; | |||
| const mode = [...selectedModes][0]; | |||
| return mappedOptions.filter((o) => o.mode === mode); | |||
| })() | |||
| : mappedOptions; | |||
| // Use larger grid size for 成品/半成品生產分析報告 | |||
| const gridSize = field.multiline | |||
| @@ -921,6 +946,17 @@ export default function ReportPage() { | |||
| select={field.type === 'select'} | |||
| SelectProps={field.multiple ? { | |||
| multiple: true, | |||
| // 盤點輪次等長列表:可見約 5 項後捲動(預設會到 ~10+ 才捲) | |||
| MenuProps: { | |||
| PaperProps: { | |||
| sx: { | |||
| maxHeight: | |||
| field.name === "stockTakeRoundId" | |||
| ? 5 * 48 | |||
| : undefined, | |||
| }, | |||
| }, | |||
| }, | |||
| renderValue: (selected: any) => { | |||
| if (Array.isArray(selected)) { | |||
| return selected | |||
| @@ -351,6 +351,8 @@ const PickerCardList: React.FC<PickerCardListProps> = ({ | |||
| /** Open item round → hide 「依區域」; open section round → hide 「依貨品」 (symmetric). */ | |||
| const [blockSectionCreate, setBlockSectionCreate] = useState(false); | |||
| const [blockItemCreate, setBlockItemCreate] = useState(false); | |||
| const [openingCreateDialog, setOpeningCreateDialog] = useState(false); | |||
| const openCreateDialogLockRef = useRef(false); | |||
| const [createDialogItemCode, setCreateDialogItemCode] = useState(""); | |||
| const [createDialogItemName, setCreateDialogItemName] = useState(""); | |||
| const [createDialogItemHits, setCreateDialogItemHits] = useState<CreateDialogItemHit[]>([]); | |||
| @@ -420,9 +422,49 @@ const PickerCardList: React.FC<PickerCardListProps> = ({ | |||
| }; | |||
| }, []); | |||
| const applyCreateModeBlocksFromRounds = useCallback( | |||
| (rounds: { open?: boolean; stockTakeRoundMode?: string | null }[]) => { | |||
| const openRounds = rounds || []; | |||
| const blockSection = openRounds.some( | |||
| (round) => | |||
| round.open && | |||
| String(round.stockTakeRoundMode ?? "").trim().toLowerCase() === "item", | |||
| ); | |||
| const blockItem = openRounds.some((round) => { | |||
| if (!round.open) return false; | |||
| const mode = String(round.stockTakeRoundMode ?? "section").trim().toLowerCase(); | |||
| return mode === "section" || mode === ""; | |||
| }); | |||
| setBlockSectionCreate(blockSection); | |||
| setBlockItemCreate(blockItem); | |||
| if (!blockSection) setCreateMode("section"); | |||
| else if (!blockItem) setCreateMode("item"); | |||
| }, | |||
| [], | |||
| ); | |||
| const openCreateDialog = useCallback(async () => { | |||
| if (openCreateDialogLockRef.current) return; | |||
| openCreateDialogLockRef.current = true; | |||
| setOpeningCreateDialog(true); | |||
| try { | |||
| const res = await getStockTakeRoundCards(); | |||
| applyCreateModeBlocksFromRounds(res.rounds || []); | |||
| } catch (e) { | |||
| console.error("Failed to check open stock take create modes:", e); | |||
| setBlockSectionCreate(false); | |||
| setBlockItemCreate(false); | |||
| setCreateMode("section"); | |||
| } finally { | |||
| setOpeningCreateDialog(false); | |||
| openCreateDialogLockRef.current = false; | |||
| setOpenConfirmDialog(true); | |||
| } | |||
| }, [applyCreateModeBlocksFromRounds]); | |||
| useEffect(() => { | |||
| if (autoOpenCreate) setOpenConfirmDialog(true); | |||
| }, [autoOpenCreate]); | |||
| if (autoOpenCreate) void openCreateDialog(); | |||
| }, [autoOpenCreate, openCreateDialog]); | |||
| useEffect(() => { | |||
| if (hideSectionList) { | |||
| @@ -720,40 +762,6 @@ const PickerCardList: React.FC<PickerCardListProps> = ({ | |||
| }; | |||
| }, [openConfirmDialog]); | |||
| useEffect(() => { | |||
| if (!openConfirmDialog) return; | |||
| let cancelled = false; | |||
| getStockTakeRoundCards() | |||
| .then((res) => { | |||
| if (cancelled) return; | |||
| const openRounds = res.rounds || []; | |||
| const blockSection = openRounds.some( | |||
| (round) => | |||
| round.open && | |||
| String(round.stockTakeRoundMode ?? "").trim().toLowerCase() === "item", | |||
| ); | |||
| const blockItem = openRounds.some((round) => { | |||
| if (!round.open) return false; | |||
| const mode = String(round.stockTakeRoundMode ?? "section").trim().toLowerCase(); | |||
| return mode === "section" || mode === ""; | |||
| }); | |||
| setBlockSectionCreate(blockSection); | |||
| setBlockItemCreate(blockItem); | |||
| if (!blockSection) setCreateMode("section"); | |||
| else if (!blockItem) setCreateMode("item"); | |||
| }) | |||
| .catch((e) => { | |||
| console.error("Failed to check open stock take create modes:", e); | |||
| if (!cancelled) { | |||
| setBlockSectionCreate(false); | |||
| setBlockItemCreate(false); | |||
| } | |||
| }); | |||
| return () => { | |||
| cancelled = true; | |||
| }; | |||
| }, [openConfirmDialog]); | |||
| const handleGoWarehouseSettings = useCallback(() => { | |||
| setMissingSectionWarnDrawerOpen(false); | |||
| setOpenConfirmDialog(false); | |||
| @@ -1602,10 +1610,14 @@ const PickerCardList: React.FC<PickerCardListProps> = ({ | |||
| <Button | |||
| variant="contained" | |||
| color="primary" | |||
| onClick={() => setOpenConfirmDialog(true)} | |||
| disabled={creating || !canManageStockTake} | |||
| onClick={() => void openCreateDialog()} | |||
| disabled={creating || openingCreateDialog || !canManageStockTake} | |||
| > | |||
| {creating ? <CircularProgress size={20} /> : t("Create Stock Take (Select Sections)")} | |||
| {creating || openingCreateDialog ? ( | |||
| <CircularProgress size={20} /> | |||
| ) : ( | |||
| t("Create Stock Take (Select Sections)") | |||
| )} | |||
| </Button> | |||
| )} | |||
| </Box> | |||
| @@ -11,6 +11,10 @@ import { | |||
| CardContent, | |||
| Chip, | |||
| CircularProgress, | |||
| Dialog, | |||
| DialogActions, | |||
| DialogContent, | |||
| DialogTitle, | |||
| Grid, | |||
| LinearProgress, | |||
| Stack, | |||
| @@ -81,6 +85,7 @@ const StockTakeRoundCardList: React.FC<StockTakeRoundCardListProps> = ({ | |||
| 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"); | |||
| @@ -149,6 +154,7 @@ const StockTakeRoundCardList: React.FC<StockTakeRoundCardListProps> = ({ | |||
| setClosingId(card.stockTakeRoundId); | |||
| try { | |||
| await closeStockTakeRound(card.stockTakeRoundId); | |||
| setPendingCloseCard(null); | |||
| await load(yearMonth); | |||
| } catch (e) { | |||
| console.error("Failed to close stock take round:", e); | |||
| @@ -311,7 +317,7 @@ const StockTakeRoundCardList: React.FC<StockTakeRoundCardListProps> = ({ | |||
| variant="outlined" | |||
| color="warning" | |||
| disabled={closingId === card.stockTakeRoundId} | |||
| onClick={() => handleCloseRound(card)} | |||
| onClick={() => setPendingCloseCard(card)} | |||
| > | |||
| {closingId === card.stockTakeRoundId ? <CircularProgress size={16} /> : t("Close round")} | |||
| </Button> | |||
| @@ -325,6 +331,37 @@ const StockTakeRoundCardList: React.FC<StockTakeRoundCardListProps> = ({ | |||
| })} | |||
| </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> | |||
| ); | |||
| }; | |||
| @@ -5,6 +5,10 @@ import { | |||
| Box, | |||
| Button, | |||
| CircularProgress, | |||
| Dialog, | |||
| DialogActions, | |||
| DialogContent, | |||
| DialogTitle, | |||
| FormControl, | |||
| InputLabel, | |||
| MenuItem, | |||
| @@ -80,6 +84,7 @@ const StockTakeTab: React.FC = () => { | |||
| const [batchButtonHost, setBatchButtonHost] = useState<HTMLDivElement | null>(null); | |||
| const [showCreateOnRoundPage, setShowCreateOnRoundPage] = useState(false); | |||
| const [closingRound, setClosingRound] = useState(false); | |||
| const [openCloseRoundConfirm, setOpenCloseRoundConfirm] = useState(false); | |||
| const closeRoundLock = useRef(false); | |||
| const [snackbar, setSnackbar] = useState<{ | |||
| open: boolean; | |||
| @@ -191,6 +196,7 @@ const StockTakeTab: React.FC = () => { | |||
| setClosingRound(true); | |||
| try { | |||
| await closeStockTakeRound(selectedRound.stockTakeRoundId); | |||
| setOpenCloseRoundConfirm(false); | |||
| handleSnackbar(t("Round closed"), "success"); | |||
| handleBackToRounds(); | |||
| } catch (e) { | |||
| @@ -358,7 +364,7 @@ const StockTakeTab: React.FC = () => { | |||
| variant="outlined" | |||
| color="warning" | |||
| disabled={closingRound} | |||
| onClick={handleCloseSelectedRound} | |||
| onClick={() => setOpenCloseRoundConfirm(true)} | |||
| > | |||
| {closingRound ? <CircularProgress size={18} /> : t("Close round")} | |||
| </Button> | |||
| @@ -429,6 +435,36 @@ const StockTakeTab: React.FC = () => { | |||
| )} | |||
| </Box> | |||
| <Dialog | |||
| open={openCloseRoundConfirm} | |||
| onClose={() => { | |||
| if (!closingRound) setOpenCloseRoundConfirm(false); | |||
| }} | |||
| 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={() => setOpenCloseRoundConfirm(false)} | |||
| disabled={closingRound} | |||
| > | |||
| {t("Cancel")} | |||
| </Button> | |||
| <Button | |||
| variant="contained" | |||
| color="warning" | |||
| disabled={closingRound} | |||
| onClick={() => void handleCloseSelectedRound()} | |||
| > | |||
| {closingRound ? <CircularProgress size={18} /> : t("Close round")} | |||
| </Button> | |||
| </DialogActions> | |||
| </Dialog> | |||
| <Snackbar | |||
| open={snackbar.open} | |||
| autoHideDuration={6000} | |||
| @@ -20,6 +20,8 @@ | |||
| "Back to List": "Back to List", | |||
| "Back to rounds": "Back to rounds", | |||
| "Close round": "Close round", | |||
| "Confirm close round": "Close this round?", | |||
| "Close round confirm message": "This button is for emergency use only. Closing marks this round as completed and releases auto put-aways held by stock take. Items not yet approved will not be auto-approved or given approver quantities. Already approved (saved) items stay as they are. This does not delete stock-take data, and it is not cancel / void. Continue?", | |||
| "Completed rounds for month": "Completed rounds ({{month}})", | |||
| "Failed to close round": "Failed to close round", | |||
| "In progress": "In progress", | |||
| @@ -3,6 +3,8 @@ | |||
| "Back to List": "返回列表", | |||
| "Back to rounds": "返回輪次列表", | |||
| "Close round": "關閉此輪", | |||
| "Confirm close round": "確認關閉此輪?", | |||
| "Close round confirm message": "此按鈕僅供緊急用途。關閉後此輪會標記為已完成,並釋放因盤點暫緩的自動上架。尚未審核的項目不會因此被改成已審核或自動帶入審核數量;已審核並儲存的項目維持現狀。確定要關閉?", | |||
| "Completed rounds for month": "已完成輪次({{month}})", | |||
| "Failed to close round": "關閉輪次失敗", | |||
| "In progress": "進行中", | |||