| @@ -36,6 +36,31 @@ import { submitStockAdjustment, StockAdjustmentLineRequest, fetchLatestAdjustmen | |||
| import { useSession } from "next-auth/react"; | |||
| 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 & { | |||
| adjustedQty: number; | |||
| originalQty?: number; | |||
| @@ -571,9 +596,12 @@ const prevAdjustmentModalOpenRef = useRef(false); | |||
| } else { | |||
| throw new Error(response?.message || t("Failed to transfer stock")); | |||
| } | |||
| } catch (error: any) { | |||
| } catch (error: unknown) { | |||
| 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 { | |||
| setIsUploading(false); | |||
| } | |||
| @@ -573,7 +573,9 @@ const QcStockInModal: React.FC<Props> = ({ | |||
| try { | |||
| // Use updateStockInLine directly like PutAwayModal does | |||
| 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"); | |||
| } | |||
| } catch (error) { | |||
| @@ -664,9 +666,18 @@ const QcStockInModal: React.FC<Props> = ({ | |||
| return; | |||
| } | |||
| // 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 | |||
| closeHandler({}, "backdropClick"); | |||
| }, | |||
| @@ -1638,6 +1638,49 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||
| </Button> | |||
| ) : null; | |||
| 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 ( | |||
| <Box> | |||
| @@ -1984,72 +2027,53 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||
| <MenuItem value="pass">{t("Pass")}</MenuItem> | |||
| </Select> | |||
| </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> | |||
| </Grid> | |||
| )} | |||
| <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> | |||
| {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> | |||
| </CardContent> | |||
| </Card> | |||
| @@ -745,6 +745,7 @@ const PickerCardList: React.FC<PickerCardListProps> = ({ | |||
| const handleOpenCreateStockTakeSummaryConfirm = useCallback(() => { | |||
| if (createDialogPlanStart == null || !createDialogPlanStart.isValid()) return; | |||
| if (createDialogPlanStart.startOf("day").isBefore(dayjs().startOf("day"))) return; | |||
| if (createMode === "item") { | |||
| if (createDialogSelectedItems.length === 0) return; | |||
| } else if (createDialogSelectedSections.length === 0) { | |||
| @@ -1803,6 +1804,7 @@ const PickerCardList: React.FC<PickerCardListProps> = ({ | |||
| value={createDialogPlanStart} | |||
| onChange={(newValue) => setCreateDialogPlanStart(newValue)} | |||
| format={OUTPUT_DATE_FORMAT} | |||
| minDate={dayjs().startOf("day")} | |||
| slotProps={{ | |||
| textField: { | |||
| fullWidth: true, | |||
| @@ -2239,7 +2241,8 @@ const PickerCardList: React.FC<PickerCardListProps> = ({ | |||
| ? createDialogSelectedItems.length === 0 | |||
| : createDialogSelectedSections.length === 0) || | |||
| createDialogPlanStart == null || | |||
| !createDialogPlanStart.isValid() | |||
| !createDialogPlanStart.isValid() || | |||
| createDialogPlanStart.startOf("day").isBefore(dayjs().startOf("day")) | |||
| } | |||
| > | |||
| {t("Confirm")} | |||
| @@ -4,6 +4,9 @@ import { | |||
| Box, | |||
| Button, | |||
| Card, | |||
| FormControl, | |||
| MenuItem, | |||
| Select, | |||
| CardActions, | |||
| CardContent, | |||
| Chip, | |||
| @@ -72,7 +75,7 @@ const StockTakeRoundCardList: React.FC<StockTakeRoundCardListProps> = ({ | |||
| yearMonth, | |||
| onYearMonthChange, | |||
| }) => { | |||
| const { t } = useTranslation(["stockTake", "common"]); | |||
| const { t, i18n } = useTranslation(["stockTake", "common"]); | |||
| const [loading, setLoading] = useState(true); | |||
| const [hasOpenRounds, setHasOpenRounds] = useState(false); | |||
| const [rounds, setRounds] = useState<StockTakeRoundCard[]>([]); | |||
| @@ -103,11 +106,19 @@ const StockTakeRoundCardList: React.FC<StockTakeRoundCardListProps> = ({ | |||
| void load(yearMonth); | |||
| }, [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 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]); | |||
| const monthIndex = useMemo(() => { | |||
| @@ -115,6 +126,16 @@ const StockTakeRoundCardList: React.FC<StockTakeRoundCardListProps> = ({ | |||
| return i >= 0 ? i : 0; | |||
| }, [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) => { | |||
| if (hasOpenRounds || monthOptions.length === 0) return; | |||
| const next = monthIndex + delta; | |||
| @@ -157,7 +178,7 @@ const StockTakeRoundCardList: React.FC<StockTakeRoundCardListProps> = ({ | |||
| <Typography variant="body2" color="text.secondary"> | |||
| {hasOpenRounds | |||
| ? t("Open rounds this month") | |||
| : t("Completed rounds for month", { month: yearMonth })} | |||
| : t("Completed rounds for month", { month: formatYearMonth(yearMonth) })} | |||
| </Typography> | |||
| <Stack direction="row" spacing={1} alignItems="center"> | |||
| {!hasOpenRounds && monthOptions.length > 0 ? ( | |||
| @@ -165,16 +186,34 @@ const StockTakeRoundCardList: React.FC<StockTakeRoundCardListProps> = ({ | |||
| <Button | |||
| size="small" | |||
| variant="outlined" | |||
| disabled={monthIndex >= monthOptions.length - 1} | |||
| disabled={!olderMonth} | |||
| onClick={() => goMonth(1)} | |||
| > | |||
| {t("Previous month")} | |||
| {olderMonth ? formatYearMonth(olderMonth) : t("Previous month")} | |||
| </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> | |||
| </> | |||
| ) : null} | |||
| @@ -229,7 +229,7 @@ const StockTakeTab: React.FC = () => { | |||
| ) | |||
| ) : null} | |||
| <Box sx={{ display: showingPickerDetail ? "none" : "block" }}> | |||
| {isAdmin && ( | |||
| {isAdmin && selectedRound && (tabValue === 1 || tabValue === 2) && ( | |||
| <Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 1 }}> | |||
| <Typography | |||
| component="label" | |||
| @@ -22,6 +22,7 @@ type ToastPosition = | |||
| type MsgOptions = { | |||
| position?: ToastPosition; | |||
| timer?: number; | |||
| wrap?: boolean; | |||
| }; | |||
| export const msg = (title: SweetAlertTitle, options?: MsgOptions) => { | |||
| @@ -57,6 +58,7 @@ export const msgError = (title: SweetAlertTitle, options?: MsgOptions) => { | |||
| }, | |||
| customClass: { | |||
| container: 'swal2-custom-zindex', | |||
| popup: options?.wrap ? "swal2-toast-wrap" : undefined, | |||
| }, | |||
| }).fire({ | |||
| icon: "error", | |||
| @@ -5,3 +5,21 @@ | |||
| .swal-popup-class { | |||
| 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": "上一個月", | |||
| "Round closed": "已關閉此輪", | |||
| "Main floor": "主要樓層", | |||
| "Stock taken": "已盤點", | |||
| "Approved progress": "已審核", | |||
| "Stock taken": "已盤點區域", | |||
| "Approved progress": "已審核區域", | |||
| "Stock taken lots": "已盤點批次", | |||
| "Approved lots": "已審核批次", | |||
| "Stock Take Section": "盤點區域類型", | |||