| @@ -131,7 +131,9 @@ export default function ReportPage() { | |||||
| }; | }; | ||||
| const [loading, setLoading] = useState(false); | const [loading, setLoading] = useState(false); | ||||
| const excelInFlightRef = useRef(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 [showConfirmDialog, setShowConfirmDialog] = useState(false); | ||||
| const [showNoDataDialog, setShowNoDataDialog] = useState(false); | const [showNoDataDialog, setShowNoDataDialog] = useState(false); | ||||
| const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({}); | const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({}); | ||||
| @@ -259,6 +261,9 @@ export default function ReportPage() { | |||||
| : data.map((item: any) => ({ | : data.map((item: any) => ({ | ||||
| label: item.label || item.name || item.code || String(item), | label: item.label || item.name || item.code || String(item), | ||||
| value: item.value || 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 | const rawOptions = field.dynamicOptions | ||||
| ? (dynamicOptions[field.name] || field.options || []) | ? (dynamicOptions[field.name] || field.options || []) | ||||
| : (field.options || []); | : (field.options || []); | ||||
| const options = rawOptions.map((opt) => ({ | |||||
| const mappedOptions = rawOptions.map((opt) => ({ | |||||
| ...opt, | ...opt, | ||||
| label: optionLabel(currentReport.id, field.name, opt), | label: optionLabel(currentReport.id, field.name, opt), | ||||
| })); | })); | ||||
| @@ -645,6 +650,26 @@ export default function ReportPage() { | |||||
| const valueForSelect = field.multiple | const valueForSelect = field.multiple | ||||
| ? (currentValue ? currentValue.split(',').map(v => v.trim()).filter(v => v) : []) | ? (currentValue ? currentValue.split(',').map(v => v.trim()).filter(v => v) : []) | ||||
| : currentValue; | : 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 成品/半成品生產分析報告 | // Use larger grid size for 成品/半成品生產分析報告 | ||||
| const gridSize = field.multiline | const gridSize = field.multiline | ||||
| @@ -921,6 +946,17 @@ export default function ReportPage() { | |||||
| select={field.type === 'select'} | select={field.type === 'select'} | ||||
| SelectProps={field.multiple ? { | SelectProps={field.multiple ? { | ||||
| multiple: true, | multiple: true, | ||||
| // 盤點輪次等長列表:可見約 5 項後捲動(預設會到 ~10+ 才捲) | |||||
| MenuProps: { | |||||
| PaperProps: { | |||||
| sx: { | |||||
| maxHeight: | |||||
| field.name === "stockTakeRoundId" | |||||
| ? 5 * 48 | |||||
| : undefined, | |||||
| }, | |||||
| }, | |||||
| }, | |||||
| renderValue: (selected: any) => { | renderValue: (selected: any) => { | ||||
| if (Array.isArray(selected)) { | if (Array.isArray(selected)) { | ||||
| return selected | return selected | ||||
| @@ -351,6 +351,8 @@ const PickerCardList: React.FC<PickerCardListProps> = ({ | |||||
| /** Open item round → hide 「依區域」; open section round → hide 「依貨品」 (symmetric). */ | /** Open item round → hide 「依區域」; open section round → hide 「依貨品」 (symmetric). */ | ||||
| const [blockSectionCreate, setBlockSectionCreate] = useState(false); | const [blockSectionCreate, setBlockSectionCreate] = useState(false); | ||||
| const [blockItemCreate, setBlockItemCreate] = useState(false); | const [blockItemCreate, setBlockItemCreate] = useState(false); | ||||
| const [openingCreateDialog, setOpeningCreateDialog] = useState(false); | |||||
| const openCreateDialogLockRef = useRef(false); | |||||
| const [createDialogItemCode, setCreateDialogItemCode] = useState(""); | const [createDialogItemCode, setCreateDialogItemCode] = useState(""); | ||||
| const [createDialogItemName, setCreateDialogItemName] = useState(""); | const [createDialogItemName, setCreateDialogItemName] = useState(""); | ||||
| const [createDialogItemHits, setCreateDialogItemHits] = useState<CreateDialogItemHit[]>([]); | 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(() => { | useEffect(() => { | ||||
| if (autoOpenCreate) setOpenConfirmDialog(true); | |||||
| }, [autoOpenCreate]); | |||||
| if (autoOpenCreate) void openCreateDialog(); | |||||
| }, [autoOpenCreate, openCreateDialog]); | |||||
| useEffect(() => { | useEffect(() => { | ||||
| if (hideSectionList) { | if (hideSectionList) { | ||||
| @@ -720,40 +762,6 @@ const PickerCardList: React.FC<PickerCardListProps> = ({ | |||||
| }; | }; | ||||
| }, [openConfirmDialog]); | }, [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(() => { | const handleGoWarehouseSettings = useCallback(() => { | ||||
| setMissingSectionWarnDrawerOpen(false); | setMissingSectionWarnDrawerOpen(false); | ||||
| setOpenConfirmDialog(false); | setOpenConfirmDialog(false); | ||||
| @@ -1602,10 +1610,14 @@ const PickerCardList: React.FC<PickerCardListProps> = ({ | |||||
| <Button | <Button | ||||
| variant="contained" | variant="contained" | ||||
| color="primary" | 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> | </Button> | ||||
| )} | )} | ||||
| </Box> | </Box> | ||||
| @@ -11,6 +11,10 @@ import { | |||||
| CardContent, | CardContent, | ||||
| Chip, | Chip, | ||||
| CircularProgress, | CircularProgress, | ||||
| Dialog, | |||||
| DialogActions, | |||||
| DialogContent, | |||||
| DialogTitle, | |||||
| Grid, | Grid, | ||||
| LinearProgress, | LinearProgress, | ||||
| Stack, | Stack, | ||||
| @@ -81,6 +85,7 @@ const StockTakeRoundCardList: React.FC<StockTakeRoundCardListProps> = ({ | |||||
| const [rounds, setRounds] = useState<StockTakeRoundCard[]>([]); | const [rounds, setRounds] = useState<StockTakeRoundCard[]>([]); | ||||
| const [completedYearMonths, setCompletedYearMonths] = useState<string[]>([]); | const [completedYearMonths, setCompletedYearMonths] = useState<string[]>([]); | ||||
| const [closingId, setClosingId] = useState<number | null>(null); | const [closingId, setClosingId] = useState<number | null>(null); | ||||
| const [pendingCloseCard, setPendingCloseCard] = useState<StockTakeRoundCard | null>(null); | |||||
| const closeLockRef = useRef(false); | const closeLockRef = useRef(false); | ||||
| const thisMonth = dayjs().format("YYYY-MM"); | const thisMonth = dayjs().format("YYYY-MM"); | ||||
| @@ -149,6 +154,7 @@ const StockTakeRoundCardList: React.FC<StockTakeRoundCardListProps> = ({ | |||||
| setClosingId(card.stockTakeRoundId); | setClosingId(card.stockTakeRoundId); | ||||
| try { | try { | ||||
| await closeStockTakeRound(card.stockTakeRoundId); | await closeStockTakeRound(card.stockTakeRoundId); | ||||
| setPendingCloseCard(null); | |||||
| await load(yearMonth); | await load(yearMonth); | ||||
| } catch (e) { | } catch (e) { | ||||
| console.error("Failed to close stock take round:", e); | console.error("Failed to close stock take round:", e); | ||||
| @@ -311,7 +317,7 @@ const StockTakeRoundCardList: React.FC<StockTakeRoundCardListProps> = ({ | |||||
| variant="outlined" | variant="outlined" | ||||
| color="warning" | color="warning" | ||||
| disabled={closingId === card.stockTakeRoundId} | disabled={closingId === card.stockTakeRoundId} | ||||
| onClick={() => handleCloseRound(card)} | |||||
| onClick={() => setPendingCloseCard(card)} | |||||
| > | > | ||||
| {closingId === card.stockTakeRoundId ? <CircularProgress size={16} /> : t("Close round")} | {closingId === card.stockTakeRoundId ? <CircularProgress size={16} /> : t("Close round")} | ||||
| </Button> | </Button> | ||||
| @@ -325,6 +331,37 @@ const StockTakeRoundCardList: React.FC<StockTakeRoundCardListProps> = ({ | |||||
| })} | })} | ||||
| </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> | </Box> | ||||
| ); | ); | ||||
| }; | }; | ||||
| @@ -5,6 +5,10 @@ import { | |||||
| Box, | Box, | ||||
| Button, | Button, | ||||
| CircularProgress, | CircularProgress, | ||||
| Dialog, | |||||
| DialogActions, | |||||
| DialogContent, | |||||
| DialogTitle, | |||||
| FormControl, | FormControl, | ||||
| InputLabel, | InputLabel, | ||||
| MenuItem, | MenuItem, | ||||
| @@ -80,6 +84,7 @@ const StockTakeTab: React.FC = () => { | |||||
| const [batchButtonHost, setBatchButtonHost] = useState<HTMLDivElement | null>(null); | const [batchButtonHost, setBatchButtonHost] = useState<HTMLDivElement | null>(null); | ||||
| const [showCreateOnRoundPage, setShowCreateOnRoundPage] = useState(false); | const [showCreateOnRoundPage, setShowCreateOnRoundPage] = useState(false); | ||||
| const [closingRound, setClosingRound] = useState(false); | const [closingRound, setClosingRound] = useState(false); | ||||
| const [openCloseRoundConfirm, setOpenCloseRoundConfirm] = useState(false); | |||||
| const closeRoundLock = useRef(false); | const closeRoundLock = useRef(false); | ||||
| const [snackbar, setSnackbar] = useState<{ | const [snackbar, setSnackbar] = useState<{ | ||||
| open: boolean; | open: boolean; | ||||
| @@ -191,6 +196,7 @@ const StockTakeTab: React.FC = () => { | |||||
| setClosingRound(true); | setClosingRound(true); | ||||
| try { | try { | ||||
| await closeStockTakeRound(selectedRound.stockTakeRoundId); | await closeStockTakeRound(selectedRound.stockTakeRoundId); | ||||
| setOpenCloseRoundConfirm(false); | |||||
| handleSnackbar(t("Round closed"), "success"); | handleSnackbar(t("Round closed"), "success"); | ||||
| handleBackToRounds(); | handleBackToRounds(); | ||||
| } catch (e) { | } catch (e) { | ||||
| @@ -358,7 +364,7 @@ const StockTakeTab: React.FC = () => { | |||||
| variant="outlined" | variant="outlined" | ||||
| color="warning" | color="warning" | ||||
| disabled={closingRound} | disabled={closingRound} | ||||
| onClick={handleCloseSelectedRound} | |||||
| onClick={() => setOpenCloseRoundConfirm(true)} | |||||
| > | > | ||||
| {closingRound ? <CircularProgress size={18} /> : t("Close round")} | {closingRound ? <CircularProgress size={18} /> : t("Close round")} | ||||
| </Button> | </Button> | ||||
| @@ -429,6 +435,36 @@ const StockTakeTab: React.FC = () => { | |||||
| )} | )} | ||||
| </Box> | </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 | <Snackbar | ||||
| open={snackbar.open} | open={snackbar.open} | ||||
| autoHideDuration={6000} | autoHideDuration={6000} | ||||
| @@ -20,6 +20,8 @@ | |||||
| "Back to List": "Back to List", | "Back to List": "Back to List", | ||||
| "Back to rounds": "Back to rounds", | "Back to rounds": "Back to rounds", | ||||
| "Close round": "Close round", | "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}})", | "Completed rounds for month": "Completed rounds ({{month}})", | ||||
| "Failed to close round": "Failed to close round", | "Failed to close round": "Failed to close round", | ||||
| "In progress": "In progress", | "In progress": "In progress", | ||||
| @@ -3,6 +3,8 @@ | |||||
| "Back to List": "返回列表", | "Back to List": "返回列表", | ||||
| "Back to rounds": "返回輪次列表", | "Back to rounds": "返回輪次列表", | ||||
| "Close round": "關閉此輪", | "Close round": "關閉此輪", | ||||
| "Confirm close round": "確認關閉此輪?", | |||||
| "Close round confirm message": "此按鈕僅供緊急用途。關閉後此輪會標記為已完成,並釋放因盤點暫緩的自動上架。尚未審核的項目不會因此被改成已審核或自動帶入審核數量;已審核並儲存的項目維持現狀。確定要關閉?", | |||||
| "Completed rounds for month": "已完成輪次({{month}})", | "Completed rounds for month": "已完成輪次({{month}})", | ||||
| "Failed to close round": "關閉輪次失敗", | "Failed to close round": "關閉輪次失敗", | ||||
| "In progress": "進行中", | "In progress": "進行中", | ||||