| @@ -36,6 +36,31 @@ import { submitStockAdjustment, StockAdjustmentLineRequest, fetchLatestAdjustmen | |||||
| import { useSession } from "next-auth/react"; | import { useSession } from "next-auth/react"; | ||||
| import { AUTH, hasAbility } from "@/authorities"; | import { AUTH, hasAbility } from "@/authorities"; | ||||
| function readableServerMessage(error: unknown, fallback: string) { | |||||
| const raw = | |||||
| error instanceof Error ? error.message : typeof error === "string" ? error : ""; | |||||
| const sentence = raw.match(/盤點進行中,(?:區域\s+\S+|貨品\s+.+?)\s*暫停出入庫/); | |||||
| if (sentence) return sentence[0]; | |||||
| const fieldPattern = /"(?:message|detail)"\s*:\s*"((?:\\.|[^"\\])*)"/g; | |||||
| const fields: string[] = []; | |||||
| let match: RegExpExecArray | null = fieldPattern.exec(raw); | |||||
| while (match) { | |||||
| if (match[1]) fields.push(match[1]); | |||||
| match = fieldPattern.exec(raw); | |||||
| } | |||||
| for (let i = fields.length - 1; i >= 0; i -= 1) { | |||||
| const text = fields[i] | |||||
| .replace(/\\"/g, '"') | |||||
| .replace(/\\n/g, " ") | |||||
| .replace(/\\u([0-9a-fA-F]{4})/g, (_full: string, hex: string) => | |||||
| String.fromCharCode(parseInt(hex, 16)), | |||||
| ) | |||||
| .trim(); | |||||
| if (text && !text.startsWith("Server error") && !text.startsWith("{")) return text; | |||||
| } | |||||
| return fallback; | |||||
| } | |||||
| type AdjustmentEntry = InventoryLotLineResult & { | type AdjustmentEntry = InventoryLotLineResult & { | ||||
| adjustedQty: number; | adjustedQty: number; | ||||
| originalQty?: number; | originalQty?: number; | ||||
| @@ -571,9 +596,12 @@ const prevAdjustmentModalOpenRef = useRef(false); | |||||
| } else { | } else { | ||||
| throw new Error(response?.message || t("Failed to transfer stock")); | throw new Error(response?.message || t("Failed to transfer stock")); | ||||
| } | } | ||||
| } catch (error: any) { | |||||
| } catch (error: unknown) { | |||||
| console.error("Error transferring stock:", error); | console.error("Error transferring stock:", error); | ||||
| msgError(error?.message || t("Failed to transfer stock. Please try again.")); | |||||
| msgError(readableServerMessage(error, t("Failed to transfer stock. Please try again.")), { | |||||
| wrap: true, | |||||
| timer: 6000, | |||||
| }); | |||||
| } finally { | } finally { | ||||
| setIsUploading(false); | setIsUploading(false); | ||||
| } | } | ||||
| @@ -573,7 +573,9 @@ const QcStockInModal: React.FC<Props> = ({ | |||||
| try { | try { | ||||
| // Use updateStockInLine directly like PutAwayModal does | // Use updateStockInLine directly like PutAwayModal does | ||||
| const res = await updateStockInLine(putawayData); | const res = await updateStockInLine(putawayData); | ||||
| if (Boolean(res.id)) { | |||||
| if (res?.type === "putaway_held") { | |||||
| alert(res.message || "盤點進行中,自動上架將於盤點結束後入庫"); | |||||
| } else if (Boolean(res.id)) { | |||||
| console.log("Auto putaway completed for job order bom"); | console.log("Auto putaway completed for job order bom"); | ||||
| } | } | ||||
| } catch (error) { | } catch (error) { | ||||
| @@ -664,9 +666,18 @@ const QcStockInModal: React.FC<Props> = ({ | |||||
| return; | return; | ||||
| } | } | ||||
| // Handle putaway submission logic here | // Handle putaway submission logic here | ||||
| const res = await postStockInLine(putawayData); | |||||
| console.log("result ", res); | |||||
| try { | |||||
| const res = await postStockInLine(putawayData); | |||||
| console.log("result ", res); | |||||
| if (res?.type === "putaway_held") { | |||||
| alert(res.message || "盤點進行中,自動上架將於盤點結束後入庫"); | |||||
| } | |||||
| } catch (error) { | |||||
| const message = error instanceof Error ? error.message : "上架失敗"; | |||||
| alert(message); | |||||
| return; | |||||
| } | |||||
| // Close modal after successful putaway | // Close modal after successful putaway | ||||
| closeHandler({}, "backdropClick"); | closeHandler({}, "backdropClick"); | ||||
| }, | }, | ||||
| @@ -1638,6 +1638,49 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||||
| </Button> | </Button> | ||||
| ) : null; | ) : null; | ||||
| const batchButtonInTabBar = Boolean(batchButtonHost && batchSaveButton); | const batchButtonInTabBar = Boolean(batchButtonHost && batchSaveButton); | ||||
| const searchFieldCount = | |||||
| (searchKind === "section" ? 2 : 0) + 1 + (mode === "pending" ? 1 : 0) + 1; | |||||
| const varianceColumn = ((searchFieldCount - 1) % 3) + 1; | |||||
| const rowFillers = searchFieldCount % 3 === 0 ? 0 : 3 - (searchFieldCount % 3); | |||||
| const varianceFilterControl = ( | |||||
| <> | |||||
| <FormControlLabel | |||||
| sx={{ ml: 0, mr: 0 }} | |||||
| control={ | |||||
| <Checkbox | |||||
| size="small" | |||||
| checked={searchVarianceFilterInclusive} | |||||
| onChange={(e) => { | |||||
| setSearchVarianceFilterInclusive(e.target.checked); | |||||
| handleSearch({ varianceFilterInclusive: e.target.checked }); | |||||
| }} | |||||
| /> | |||||
| } | |||||
| label={<Typography variant="body2">{t("Variance filter inclusive only")}</Typography>} | |||||
| /> | |||||
| <Typography variant="caption" color="text.secondary" sx={{ pl: 0.5, display: "block" }}> | |||||
| {searchVarianceFilterInclusive | |||||
| ? t("Variance filter inclusive range hint", { | |||||
| value: searchVariancePercentTolerance || "0", | |||||
| op: searchVarianceFilterStrict ? "<" : "≤", | |||||
| }) | |||||
| : t("Variance filter exclusive range hint", { | |||||
| value: searchVariancePercentTolerance || "0", | |||||
| op: searchVarianceFilterStrict ? ">" : "≥", | |||||
| })} | |||||
| </Typography> | |||||
| </> | |||||
| ); | |||||
| const searchActionButtons = ( | |||||
| <Stack direction="row" spacing={1} justifyContent="flex-start"> | |||||
| <Button variant="outlined" onClick={handleResetSearch}> | |||||
| {t("Reset")} | |||||
| </Button> | |||||
| <Button variant="contained" onClick={() => handleSearch()}> | |||||
| {t("Search")} | |||||
| </Button> | |||||
| </Stack> | |||||
| ); | |||||
| return ( | return ( | ||||
| <Box> | <Box> | ||||
| @@ -1984,72 +2027,53 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||||
| <MenuItem value="pass">{t("Pass")}</MenuItem> | <MenuItem value="pass">{t("Pass")}</MenuItem> | ||||
| </Select> | </Select> | ||||
| </FormControl> | </FormControl> | ||||
| <Stack direction="row" spacing={1} sx={{ pt: 0.5 }}> | |||||
| <Button variant="outlined" onClick={handleResetSearch}> | |||||
| {t("Reset")} | |||||
| </Button> | |||||
| <Button variant="contained" onClick={() => handleSearch()}> | |||||
| {t("Search")} | |||||
| </Button> | |||||
| </Stack> | |||||
| </Stack> | </Stack> | ||||
| </Grid> | </Grid> | ||||
| )} | )} | ||||
| <Grid item xs={12} md={4}> | <Grid item xs={12} md={4}> | ||||
| <Stack spacing={0.5}> | |||||
| <TextField | |||||
| fullWidth | |||||
| size="small" | |||||
| type="number" | |||||
| label={t("Variance %")} | |||||
| value={searchVariancePercentTolerance} | |||||
| onChange={(e) => { | |||||
| const next = sanitizePositiveDecimalInput(e.target.value); | |||||
| setSearchVariancePercentTolerance(next); | |||||
| handleSearch({ variancePercentTolerance: next }); | |||||
| }} | |||||
| inputProps={{ min: 0, step: 0.1 }} | |||||
| sx={approverSearchFieldSx} | |||||
| /> | |||||
| <FormControlLabel | |||||
| sx={{ ml: 0, mr: 0 }} | |||||
| control={ | |||||
| <Checkbox | |||||
| size="small" | |||||
| checked={searchVarianceFilterInclusive} | |||||
| onChange={(e) => { | |||||
| setSearchVarianceFilterInclusive(e.target.checked); | |||||
| handleSearch({ varianceFilterInclusive: e.target.checked }); | |||||
| }} | |||||
| /> | |||||
| } | |||||
| label={ | |||||
| <Typography variant="body2">{t("Variance filter inclusive only")}</Typography> | |||||
| } | |||||
| /> | |||||
| <Typography variant="caption" color="text.secondary" sx={{ pl: 0.5 }}> | |||||
| {searchVarianceFilterInclusive | |||||
| ? t("Variance filter inclusive range hint", { | |||||
| value: searchVariancePercentTolerance || "0", | |||||
| op: searchVarianceFilterStrict ? "<" : "≤", | |||||
| }) | |||||
| : t("Variance filter exclusive range hint", { | |||||
| value: searchVariancePercentTolerance || "0", | |||||
| op: searchVarianceFilterStrict ? ">" : "≥", | |||||
| })} | |||||
| </Typography> | |||||
| {mode !== "pending" && ( | |||||
| <Stack direction="row" spacing={1} sx={{ pt: 0.5 }}> | |||||
| <Button variant="outlined" onClick={handleResetSearch}> | |||||
| {t("Reset")} | |||||
| </Button> | |||||
| <Button variant="contained" onClick={() => handleSearch()}> | |||||
| {t("Search")} | |||||
| </Button> | |||||
| </Stack> | |||||
| )} | |||||
| </Stack> | |||||
| <TextField | |||||
| fullWidth | |||||
| size="small" | |||||
| type="number" | |||||
| label={t("Variance %")} | |||||
| value={searchVariancePercentTolerance} | |||||
| onChange={(e) => { | |||||
| const next = sanitizePositiveDecimalInput(e.target.value); | |||||
| setSearchVariancePercentTolerance(next); | |||||
| handleSearch({ variancePercentTolerance: next }); | |||||
| }} | |||||
| inputProps={{ min: 0, step: 0.1 }} | |||||
| sx={approverSearchFieldSx} | |||||
| /> | |||||
| </Grid> | </Grid> | ||||
| {Array.from({ length: rowFillers }).map((_, index) => ( | |||||
| <Grid | |||||
| key={`approver-search-fill-${index}`} | |||||
| item | |||||
| md={4} | |||||
| sx={{ display: { xs: "none", md: "block" } }} | |||||
| /> | |||||
| ))} | |||||
| {varianceColumn === 1 ? ( | |||||
| <> | |||||
| <Grid item xs={12} md={4}> | |||||
| {varianceFilterControl} | |||||
| </Grid> | |||||
| <Grid item xs={12}>{searchActionButtons}</Grid> | |||||
| </> | |||||
| ) : ( | |||||
| <> | |||||
| <Grid item xs={12} md={4}> | |||||
| {searchActionButtons} | |||||
| </Grid> | |||||
| {varianceColumn === 3 ? ( | |||||
| <Grid item md={4} sx={{ display: { xs: "none", md: "block" } }} /> | |||||
| ) : null} | |||||
| <Grid item xs={12} md={4}> | |||||
| {varianceFilterControl} | |||||
| </Grid> | |||||
| </> | |||||
| )} | |||||
| </Grid> | </Grid> | ||||
| </CardContent> | </CardContent> | ||||
| </Card> | </Card> | ||||
| @@ -745,6 +745,7 @@ const PickerCardList: React.FC<PickerCardListProps> = ({ | |||||
| const handleOpenCreateStockTakeSummaryConfirm = useCallback(() => { | const handleOpenCreateStockTakeSummaryConfirm = useCallback(() => { | ||||
| if (createDialogPlanStart == null || !createDialogPlanStart.isValid()) return; | if (createDialogPlanStart == null || !createDialogPlanStart.isValid()) return; | ||||
| if (createDialogPlanStart.startOf("day").isBefore(dayjs().startOf("day"))) return; | |||||
| if (createMode === "item") { | if (createMode === "item") { | ||||
| if (createDialogSelectedItems.length === 0) return; | if (createDialogSelectedItems.length === 0) return; | ||||
| } else if (createDialogSelectedSections.length === 0) { | } else if (createDialogSelectedSections.length === 0) { | ||||
| @@ -1803,6 +1804,7 @@ const PickerCardList: React.FC<PickerCardListProps> = ({ | |||||
| value={createDialogPlanStart} | value={createDialogPlanStart} | ||||
| onChange={(newValue) => setCreateDialogPlanStart(newValue)} | onChange={(newValue) => setCreateDialogPlanStart(newValue)} | ||||
| format={OUTPUT_DATE_FORMAT} | format={OUTPUT_DATE_FORMAT} | ||||
| minDate={dayjs().startOf("day")} | |||||
| slotProps={{ | slotProps={{ | ||||
| textField: { | textField: { | ||||
| fullWidth: true, | fullWidth: true, | ||||
| @@ -2239,7 +2241,8 @@ const PickerCardList: React.FC<PickerCardListProps> = ({ | |||||
| ? createDialogSelectedItems.length === 0 | ? createDialogSelectedItems.length === 0 | ||||
| : createDialogSelectedSections.length === 0) || | : createDialogSelectedSections.length === 0) || | ||||
| createDialogPlanStart == null || | createDialogPlanStart == null || | ||||
| !createDialogPlanStart.isValid() | |||||
| !createDialogPlanStart.isValid() || | |||||
| createDialogPlanStart.startOf("day").isBefore(dayjs().startOf("day")) | |||||
| } | } | ||||
| > | > | ||||
| {t("Confirm")} | {t("Confirm")} | ||||
| @@ -4,6 +4,9 @@ import { | |||||
| Box, | Box, | ||||
| Button, | Button, | ||||
| Card, | Card, | ||||
| FormControl, | |||||
| MenuItem, | |||||
| Select, | |||||
| CardActions, | CardActions, | ||||
| CardContent, | CardContent, | ||||
| Chip, | Chip, | ||||
| @@ -72,7 +75,7 @@ const StockTakeRoundCardList: React.FC<StockTakeRoundCardListProps> = ({ | |||||
| yearMonth, | yearMonth, | ||||
| onYearMonthChange, | onYearMonthChange, | ||||
| }) => { | }) => { | ||||
| const { t } = useTranslation(["stockTake", "common"]); | |||||
| const { t, i18n } = useTranslation(["stockTake", "common"]); | |||||
| const [loading, setLoading] = useState(true); | const [loading, setLoading] = useState(true); | ||||
| const [hasOpenRounds, setHasOpenRounds] = useState(false); | const [hasOpenRounds, setHasOpenRounds] = useState(false); | ||||
| const [rounds, setRounds] = useState<StockTakeRoundCard[]>([]); | const [rounds, setRounds] = useState<StockTakeRoundCard[]>([]); | ||||
| @@ -103,11 +106,19 @@ const StockTakeRoundCardList: React.FC<StockTakeRoundCardListProps> = ({ | |||||
| void load(yearMonth); | void load(yearMonth); | ||||
| }, [load, yearMonth, refreshNonce]); | }, [load, yearMonth, refreshNonce]); | ||||
| // Completed months plus current month (even if no round created yet). No future months. | |||||
| // Every month from the earliest round through this month, including months with no round. | |||||
| const monthOptions = useMemo(() => { | const monthOptions = useMemo(() => { | ||||
| const set = new Set(completedYearMonths.filter((m) => m && m <= thisMonth)); | |||||
| set.add(thisMonth); | |||||
| return Array.from(set).sort((a, b) => b.localeCompare(a)); | |||||
| 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]); | }, [completedYearMonths, thisMonth]); | ||||
| const monthIndex = useMemo(() => { | const monthIndex = useMemo(() => { | ||||
| @@ -115,6 +126,16 @@ const StockTakeRoundCardList: React.FC<StockTakeRoundCardListProps> = ({ | |||||
| return i >= 0 ? i : 0; | return i >= 0 ? i : 0; | ||||
| }, [monthOptions, yearMonth]); | }, [monthOptions, yearMonth]); | ||||
| 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 olderMonth = monthOptions[monthIndex + 1]; | |||||
| const newerMonth = monthIndex > 0 ? monthOptions[monthIndex - 1] : undefined; | |||||
| const goMonth = (delta: number) => { | const goMonth = (delta: number) => { | ||||
| if (hasOpenRounds || monthOptions.length === 0) return; | if (hasOpenRounds || monthOptions.length === 0) return; | ||||
| const next = monthIndex + delta; | const next = monthIndex + delta; | ||||
| @@ -157,7 +178,7 @@ const StockTakeRoundCardList: React.FC<StockTakeRoundCardListProps> = ({ | |||||
| <Typography variant="body2" color="text.secondary"> | <Typography variant="body2" color="text.secondary"> | ||||
| {hasOpenRounds | {hasOpenRounds | ||||
| ? t("Open rounds this month") | ? t("Open rounds this month") | ||||
| : t("Completed rounds for month", { month: yearMonth })} | |||||
| : t("Completed rounds for month", { month: formatYearMonth(yearMonth) })} | |||||
| </Typography> | </Typography> | ||||
| <Stack direction="row" spacing={1} alignItems="center"> | <Stack direction="row" spacing={1} alignItems="center"> | ||||
| {!hasOpenRounds && monthOptions.length > 0 ? ( | {!hasOpenRounds && monthOptions.length > 0 ? ( | ||||
| @@ -165,16 +186,34 @@ const StockTakeRoundCardList: React.FC<StockTakeRoundCardListProps> = ({ | |||||
| <Button | <Button | ||||
| size="small" | size="small" | ||||
| variant="outlined" | variant="outlined" | ||||
| disabled={monthIndex >= monthOptions.length - 1} | |||||
| disabled={!olderMonth} | |||||
| onClick={() => goMonth(1)} | onClick={() => goMonth(1)} | ||||
| > | > | ||||
| {t("Previous month")} | |||||
| {olderMonth ? formatYearMonth(olderMonth) : t("Previous month")} | |||||
| </Button> | </Button> | ||||
| <Typography variant="body2" sx={{ minWidth: 80, textAlign: "center" }}> | |||||
| {yearMonth} | |||||
| </Typography> | |||||
| <Button size="small" variant="outlined" disabled={monthIndex <= 0} onClick={() => goMonth(-1)}> | |||||
| {t("Next month")} | |||||
| <FormControl size="small" sx={{ minWidth: 140 }}> | |||||
| <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 } }, | |||||
| }} | |||||
| > | |||||
| {monthOptions.map((ym) => ( | |||||
| <MenuItem key={ym} value={ym}> | |||||
| {formatYearMonth(ym)} | |||||
| </MenuItem> | |||||
| ))} | |||||
| </Select> | |||||
| </FormControl> | |||||
| <Button | |||||
| size="small" | |||||
| variant="outlined" | |||||
| disabled={!newerMonth} | |||||
| onClick={() => goMonth(-1)} | |||||
| > | |||||
| {newerMonth ? formatYearMonth(newerMonth) : t("Next month")} | |||||
| </Button> | </Button> | ||||
| </> | </> | ||||
| ) : null} | ) : null} | ||||
| @@ -229,7 +229,7 @@ const StockTakeTab: React.FC = () => { | |||||
| ) | ) | ||||
| ) : null} | ) : null} | ||||
| <Box sx={{ display: showingPickerDetail ? "none" : "block" }}> | <Box sx={{ display: showingPickerDetail ? "none" : "block" }}> | ||||
| {isAdmin && ( | |||||
| {isAdmin && selectedRound && (tabValue === 1 || tabValue === 2) && ( | |||||
| <Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 1 }}> | <Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 1 }}> | ||||
| <Typography | <Typography | ||||
| component="label" | component="label" | ||||
| @@ -22,6 +22,7 @@ type ToastPosition = | |||||
| type MsgOptions = { | type MsgOptions = { | ||||
| position?: ToastPosition; | position?: ToastPosition; | ||||
| timer?: number; | timer?: number; | ||||
| wrap?: boolean; | |||||
| }; | }; | ||||
| export const msg = (title: SweetAlertTitle, options?: MsgOptions) => { | export const msg = (title: SweetAlertTitle, options?: MsgOptions) => { | ||||
| @@ -57,6 +58,7 @@ export const msgError = (title: SweetAlertTitle, options?: MsgOptions) => { | |||||
| }, | }, | ||||
| customClass: { | customClass: { | ||||
| container: 'swal2-custom-zindex', | container: 'swal2-custom-zindex', | ||||
| popup: options?.wrap ? "swal2-toast-wrap" : undefined, | |||||
| }, | }, | ||||
| }).fire({ | }).fire({ | ||||
| icon: "error", | icon: "error", | ||||
| @@ -5,3 +5,21 @@ | |||||
| .swal-popup-class { | .swal-popup-class { | ||||
| z-index: 10000; /* Adjust the z-index value as needed */ | z-index: 10000; /* Adjust the z-index value as needed */ | ||||
| } | } | ||||
| .swal2-popup.swal2-toast.swal2-toast-wrap { | |||||
| box-sizing: border-box !important; | |||||
| width: 100% !important; | |||||
| max-width: 100% !important; | |||||
| height: auto !important; | |||||
| overflow: hidden !important; | |||||
| grid-template-columns: auto minmax(0, 1fr) !important; | |||||
| } | |||||
| .swal2-popup.swal2-toast.swal2-toast-wrap .swal2-title { | |||||
| min-width: 0 !important; | |||||
| white-space: normal !important; | |||||
| overflow: visible !important; | |||||
| text-overflow: unset !important; | |||||
| max-height: none !important; | |||||
| line-height: 1.4 !important; | |||||
| word-break: break-word !important; | |||||
| } | |||||
| @@ -11,8 +11,8 @@ | |||||
| "Previous month": "上一個月", | "Previous month": "上一個月", | ||||
| "Round closed": "已關閉此輪", | "Round closed": "已關閉此輪", | ||||
| "Main floor": "主要樓層", | "Main floor": "主要樓層", | ||||
| "Stock taken": "已盤點", | |||||
| "Approved progress": "已審核", | |||||
| "Stock taken": "已盤點區域", | |||||
| "Approved progress": "已審核區域", | |||||
| "Stock taken lots": "已盤點批次", | "Stock taken lots": "已盤點批次", | ||||
| "Approved lots": "已審核批次", | "Approved lots": "已審核批次", | ||||
| "Stock Take Section": "盤點區域類型", | "Stock Take Section": "盤點區域類型", | ||||