| @@ -0,0 +1,26 @@ | |||||
| /** Prefer stock-take / API reason over a generic "Server error: 409 …" wrapper. */ | |||||
| export function readableServerMessage(error: unknown, fallback: string): 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]; | |||||
| // Spring CONFLICT often puts the reason in "error" (not "message"). | |||||
| const fieldPattern = /"(?:message|detail|error)"\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; | |||||
| } | |||||
| @@ -35,31 +35,7 @@ import CheckIcon from "@mui/icons-material/Check"; | |||||
| import { submitStockAdjustment, StockAdjustmentLineRequest, fetchLatestAdjustmentRemarks } from "@/app/api/stockAdjustment/actions"; | import { submitStockAdjustment, StockAdjustmentLineRequest, fetchLatestAdjustmentRemarks } from "@/app/api/stockAdjustment/actions"; | ||||
| 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; | |||||
| } | |||||
| import { readableServerMessage } from "@/app/utils/readableServerMessage"; | |||||
| type AdjustmentEntry = InventoryLotLineResult & { | type AdjustmentEntry = InventoryLotLineResult & { | ||||
| adjustedQty: number; | adjustedQty: number; | ||||
| @@ -331,8 +307,7 @@ const prevAdjustmentModalOpenRef = useRef(false); | |||||
| setStockAdjustmentModalOpen(false); | setStockAdjustmentModalOpen(false); | ||||
| await onStockAdjustmentSuccess?.(); | await onStockAdjustmentSuccess?.(); | ||||
| } catch (e: unknown) { | } catch (e: unknown) { | ||||
| const message = e instanceof Error ? e.message : String(e); | |||||
| msgError(message || t("Save failed")); | |||||
| msgError(readableServerMessage(e, t("Save failed"))); | |||||
| } finally { | } finally { | ||||
| setIsUploading(false); | setIsUploading(false); | ||||
| setIsAdjustSaving(false); | setIsAdjustSaving(false); | ||||
| @@ -29,6 +29,7 @@ import { Check } from "@mui/icons-material"; | |||||
| import { useTranslation } from "react-i18next"; | import { useTranslation } from "react-i18next"; | ||||
| import { useSearchParams } from "next/navigation"; | import { useSearchParams } from "next/navigation"; | ||||
| import { useQrCodeScannerContext } from "../QrCodeScannerProvider/QrCodeScannerProvider"; | import { useQrCodeScannerContext } from "../QrCodeScannerProvider/QrCodeScannerProvider"; | ||||
| import { readableServerMessage } from "@/app/utils/readableServerMessage"; | |||||
| interface Props extends Omit<ModalProps, "children"> { | interface Props extends Omit<ModalProps, "children"> { | ||||
| warehouse: WarehouseResult[]; | warehouse: WarehouseResult[]; | ||||
| @@ -173,6 +174,12 @@ const QrModal: React.FC<Props> = ({ open, onClose, warehouse }) => { | |||||
| } | } | ||||
| // return; | // return; | ||||
| const res = await updateStockInLine(args); | const res = await updateStockInLine(args); | ||||
| if (res?.type === "putaway_held" || res?.code === "PUTAWAY_HELD") { | |||||
| setServerError( | |||||
| res.message || "盤點進行中,自動上架將於盤點結束後入庫", | |||||
| ); | |||||
| return; | |||||
| } | |||||
| if (Boolean(res.id)) { | if (Boolean(res.id)) { | ||||
| // update entries | // update entries | ||||
| console.log(res); | console.log(res); | ||||
| @@ -182,8 +189,12 @@ const QrModal: React.FC<Props> = ({ open, onClose, warehouse }) => { | |||||
| console.log(res); | console.log(res); | ||||
| // if (res) | // if (res) | ||||
| } catch (e) { | } catch (e) { | ||||
| // server error | |||||
| setServerError(t("An error has occurred. Please try again later.")); | |||||
| setServerError( | |||||
| readableServerMessage( | |||||
| e, | |||||
| t("An error has occurred. Please try again later."), | |||||
| ), | |||||
| ); | |||||
| console.log(e); | console.log(e); | ||||
| } | } | ||||
| }, | }, | ||||
| @@ -39,6 +39,7 @@ import { QrCodeScanner } from "../QrCodeScannerProvider/QrCodeScannerProvider"; | |||||
| import { msg, msgError } from "../Swal/CustomAlerts"; | import { msg, msgError } from "../Swal/CustomAlerts"; | ||||
| import { PutAwayRecord } from "."; | import { PutAwayRecord } from "."; | ||||
| import FgStockInForm from "../StockIn/FgStockInForm"; | import FgStockInForm from "../StockIn/FgStockInForm"; | ||||
| import { readableServerMessage } from "@/app/utils/readableServerMessage"; | |||||
| import Swal from "sweetalert2"; | import Swal from "sweetalert2"; | ||||
| @@ -449,6 +450,13 @@ const PutAwayModal: React.FC<Props> = ({ open, onClose, warehouse, stockInLineId | |||||
| } | } | ||||
| console.log("%c Submitting Data:", "color:blue", args); | console.log("%c Submitting Data:", "color:blue", args); | ||||
| const res = await updateStockInLine(args); | const res = await updateStockInLine(args); | ||||
| if (res?.type === "putaway_held" || res?.code === "PUTAWAY_HELD") { | |||||
| const heldMsg = | |||||
| res.message || "盤點進行中,自動上架將於盤點結束後入庫"; | |||||
| setServerError(heldMsg); | |||||
| msgError(heldMsg); | |||||
| return; | |||||
| } | |||||
| if (Boolean(res.id)) { | if (Boolean(res.id)) { | ||||
| // update entries | // update entries | ||||
| console.log("%c Update Success:", "color:green", res); | console.log("%c Update Success:", "color:green", res); | ||||
| @@ -475,8 +483,12 @@ const PutAwayModal: React.FC<Props> = ({ open, onClose, warehouse, stockInLineId | |||||
| // console.log(res); | // console.log(res); | ||||
| // if (res) | // if (res) | ||||
| } catch (e) { | } catch (e) { | ||||
| // server error | |||||
| setServerError(t("An error has occurred. Please try again later.")); | |||||
| const message = readableServerMessage( | |||||
| e, | |||||
| t("An error has occurred. Please try again later."), | |||||
| ); | |||||
| setServerError(message); | |||||
| msgError(message); | |||||
| console.log(e); | console.log(e); | ||||
| } | } | ||||
| }, | }, | ||||
| @@ -32,8 +32,12 @@ import { | |||||
| ToggleButton, | ToggleButton, | ||||
| ToggleButtonGroup, | ToggleButtonGroup, | ||||
| Checkbox, | Checkbox, | ||||
| Accordion, | |||||
| AccordionSummary, | |||||
| AccordionDetails, | |||||
| } from "@mui/material"; | } from "@mui/material"; | ||||
| import SearchIcon from "@mui/icons-material/Search"; | import SearchIcon from "@mui/icons-material/Search"; | ||||
| import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; | |||||
| import NotificationsNoneOutlinedIcon from "@mui/icons-material/NotificationsNoneOutlined"; | import NotificationsNoneOutlinedIcon from "@mui/icons-material/NotificationsNoneOutlined"; | ||||
| import { useRouter } from "next/navigation"; | import { useRouter } from "next/navigation"; | ||||
| import { SessionWithTokens } from "@/config/authConfig"; | import { SessionWithTokens } from "@/config/authConfig"; | ||||
| @@ -318,6 +322,8 @@ const PickerCardList: React.FC<PickerCardListProps> = ({ | |||||
| dayjs.extend(duration); | dayjs.extend(duration); | ||||
| const [loading, setLoading] = useState(true); | const [loading, setLoading] = useState(true); | ||||
| /** Same UX as Approver: Search Criteria accordion can collapse / expand. */ | |||||
| const [showFilters, setShowFilters] = useState(true); | |||||
| /** 這一輪全部卡片,篩選在前端做,點選不再重打 API */ | /** 這一輪全部卡片,篩選在前端做,點選不再重打 API */ | ||||
| const [roundRecords, setRoundRecords] = useState<AllPickedStockTakeListReponse[]>([]); | const [roundRecords, setRoundRecords] = useState<AllPickedStockTakeListReponse[]>([]); | ||||
| const { data: session } = useSession() as { data: SessionWithTokens | null }; | const { data: session } = useSession() as { data: SessionWithTokens | null }; | ||||
| @@ -342,7 +348,9 @@ const PickerCardList: React.FC<PickerCardListProps> = ({ | |||||
| const [createFloorTabIndex, setCreateFloorTabIndex] = useState(0); | const [createFloorTabIndex, setCreateFloorTabIndex] = useState(0); | ||||
| const [createDialogSearchQuery, setCreateDialogSearchQuery] = useState(""); | const [createDialogSearchQuery, setCreateDialogSearchQuery] = useState(""); | ||||
| const [createMode, setCreateMode] = useState<"section" | "item">("section"); | const [createMode, setCreateMode] = useState<"section" | "item">("section"); | ||||
| /** 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 [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[]>([]); | ||||
| @@ -718,15 +726,28 @@ const PickerCardList: React.FC<PickerCardListProps> = ({ | |||||
| getStockTakeRoundCards() | getStockTakeRoundCards() | ||||
| .then((res) => { | .then((res) => { | ||||
| if (cancelled) return; | if (cancelled) return; | ||||
| const blocked = (res.rounds || []).some( | |||||
| (round) => round.open && round.stockTakeRoundMode === "item", | |||||
| const openRounds = res.rounds || []; | |||||
| const blockSection = openRounds.some( | |||||
| (round) => | |||||
| round.open && | |||||
| String(round.stockTakeRoundMode ?? "").trim().toLowerCase() === "item", | |||||
| ); | ); | ||||
| setBlockSectionCreate(blocked); | |||||
| if (blocked) setCreateMode("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) => { | .catch((e) => { | ||||
| console.error("Failed to check open item stock take rounds:", e); | |||||
| if (!cancelled) setBlockSectionCreate(false); | |||||
| console.error("Failed to check open stock take create modes:", e); | |||||
| if (!cancelled) { | |||||
| setBlockSectionCreate(false); | |||||
| setBlockItemCreate(false); | |||||
| } | |||||
| }); | }); | ||||
| return () => { | return () => { | ||||
| cancelled = true; | cancelled = true; | ||||
| @@ -1070,7 +1091,7 @@ const PickerCardList: React.FC<PickerCardListProps> = ({ | |||||
| setCreateDialogRoundName(""); | setCreateDialogRoundName(""); | ||||
| setCreateDialogPlanStart(dayjs()); | setCreateDialogPlanStart(dayjs()); | ||||
| setCreateDialogSelectedItems([]); | setCreateDialogSelectedItems([]); | ||||
| setCreateMode("section"); | |||||
| setCreateMode(blockSectionCreate && !blockItemCreate ? "item" : "section"); | |||||
| } catch (e) { | } catch (e) { | ||||
| console.error(e); | console.error(e); | ||||
| } finally { | } finally { | ||||
| @@ -1078,6 +1099,8 @@ const PickerCardList: React.FC<PickerCardListProps> = ({ | |||||
| createStockTakeInFlightRef.current = false; | createStockTakeInFlightRef.current = false; | ||||
| } | } | ||||
| }, [ | }, [ | ||||
| blockItemCreate, | |||||
| blockSectionCreate, | |||||
| createDialogPlanStart, | createDialogPlanStart, | ||||
| createDialogRoundName, | createDialogRoundName, | ||||
| createDialogSelectedItems, | createDialogSelectedItems, | ||||
| @@ -1235,7 +1258,7 @@ const PickerCardList: React.FC<PickerCardListProps> = ({ | |||||
| setOpenConfirmDialog(false); | setOpenConfirmDialog(false); | ||||
| setCreateDialogRoundName(""); | setCreateDialogRoundName(""); | ||||
| setCreateDialogPlanStart(dayjs()); | setCreateDialogPlanStart(dayjs()); | ||||
| setCreateMode(blockSectionCreate ? "item" : "section"); | |||||
| setCreateMode(blockSectionCreate && !blockItemCreate ? "item" : "section"); | |||||
| setCreateDialogItemCode(""); | setCreateDialogItemCode(""); | ||||
| setCreateDialogItemName(""); | setCreateDialogItemName(""); | ||||
| setCreateDialogItemHits([]); | setCreateDialogItemHits([]); | ||||
| @@ -1246,11 +1269,28 @@ const PickerCardList: React.FC<PickerCardListProps> = ({ | |||||
| <Box> | <Box> | ||||
| {!hideSectionList && ( | {!hideSectionList && ( | ||||
| <> | <> | ||||
| <Card elevation={0} sx={{ mb: 2 }}> | |||||
| <CardContent> | |||||
| <Typography variant="overline" sx={{ display: "block", mb: 1 }}> | |||||
| {t("Search Criteria")} | |||||
| <Accordion | |||||
| expanded={showFilters} | |||||
| onChange={(_, expanded) => setShowFilters(expanded)} | |||||
| elevation={0} | |||||
| sx={{ mb: 2, border: "1px solid", borderColor: "divider", borderRadius: 1 }} | |||||
| > | |||||
| <AccordionSummary | |||||
| expandIcon={<ExpandMoreIcon />} | |||||
| sx={{ | |||||
| minHeight: 36, | |||||
| px: 2, | |||||
| "&.Mui-expanded": { minHeight: 36 }, | |||||
| "& .MuiAccordionSummary-content, & .MuiAccordionSummary-content.Mui-expanded": { my: 0.75 }, | |||||
| }} | |||||
| > | |||||
| <Typography variant="body2"> | |||||
| <strong>{t("Search Criteria")}</strong> | |||||
| </Typography> | </Typography> | ||||
| </AccordionSummary> | |||||
| <AccordionDetails sx={{ pt: 0, pb: 1.5, px: 2 }}> | |||||
| <Card elevation={0} sx={{ mb: 0, boxShadow: "none" }}> | |||||
| <CardContent sx={{ px: 0, py: 0, "&:last-child": { pb: 0 } }}> | |||||
| <Grid container spacing={2} alignItems="flex-start"> | <Grid container spacing={2} alignItems="flex-start"> | ||||
| <Grid item xs={12}> | <Grid item xs={12}> | ||||
| <Tabs | <Tabs | ||||
| @@ -1546,6 +1586,8 @@ const PickerCardList: React.FC<PickerCardListProps> = ({ | |||||
| </CardActions> | </CardActions> | ||||
| </CardContent> | </CardContent> | ||||
| </Card> | </Card> | ||||
| </AccordionDetails> | |||||
| </Accordion> | |||||
| <Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", mb: 2 }}> | <Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", mb: 2 }}> | ||||
| @@ -1728,18 +1770,34 @@ const PickerCardList: React.FC<PickerCardListProps> = ({ | |||||
| </Tooltip> | </Tooltip> | ||||
| </DialogTitle> | </DialogTitle> | ||||
| <Box sx={{ px: 2, flexShrink: 0 }}> | <Box sx={{ px: 2, flexShrink: 0 }}> | ||||
| {blockSectionCreate && blockItemCreate ? ( | |||||
| <Typography variant="body2" color="text.secondary" sx={{ py: 1.5 }}> | |||||
| {t("Cannot create while both section and item rounds are open")} | |||||
| </Typography> | |||||
| ) : ( | |||||
| <Tabs | <Tabs | ||||
| value={createMode} | |||||
| value={ | |||||
| createMode === "item" && blockItemCreate | |||||
| ? "section" | |||||
| : createMode === "section" && blockSectionCreate | |||||
| ? "item" | |||||
| : createMode | |||||
| } | |||||
| onChange={(_, v: "section" | "item") => { | onChange={(_, v: "section" | "item") => { | ||||
| if (!v) return; | if (!v) return; | ||||
| if (v === "section" && blockSectionCreate) return; | |||||
| if (v === "item" && blockItemCreate) return; | |||||
| setCreateMode(v); | setCreateMode(v); | ||||
| }} | }} | ||||
| > | > | ||||
| {blockSectionCreate ? null : ( | {blockSectionCreate ? null : ( | ||||
| <Tab value="section" label={t("Create by section")} /> | <Tab value="section" label={t("Create by section")} /> | ||||
| )} | )} | ||||
| <Tab value="item" label={t("Create by item")} /> | |||||
| {blockItemCreate ? null : ( | |||||
| <Tab value="item" label={t("Create by item")} /> | |||||
| )} | |||||
| </Tabs> | </Tabs> | ||||
| )} | |||||
| </Box> | </Box> | ||||
| <DialogContent | <DialogContent | ||||
| dividers | dividers | ||||
| @@ -229,7 +229,7 @@ const StockTakeTab: React.FC = () => { | |||||
| ) | ) | ||||
| ) : null} | ) : null} | ||||
| <Box sx={{ display: showingPickerDetail ? "none" : "block" }}> | <Box sx={{ display: showingPickerDetail ? "none" : "block" }}> | ||||
| {isAdmin && selectedRound && (tabValue === 1 || tabValue === 2) && ( | |||||
| {isAdmin && selectedRound && tabValue === 1 && ( | |||||
| <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" | ||||
| @@ -49,6 +49,7 @@ | |||||
| "Control Time": "Control Time", | "Control Time": "Control Time", | ||||
| "Create by item": "By item", | "Create by item": "By item", | ||||
| "Create by section": "By section", | "Create by section": "By section", | ||||
| "Cannot create while both section and item rounds are open": "Cannot create a new round while both section and item rounds are open", | |||||
| "Create by item hint": "Search and select items. This round includes available lots of these items in every floor / warehouse.", | "Create by item hint": "Search and select items. This round includes available lots of these items in every floor / warehouse.", | ||||
| "Create by item search hint": "Enter an item code, an item name, or both. Both must match when filled.", | "Create by item search hint": "Enter an item code, an item name, or both. Both must match when filled.", | ||||
| "Create by item selected count": "{{count}} item(s) selected", | "Create by item selected count": "{{count}} item(s) selected", | ||||
| @@ -130,6 +130,7 @@ | |||||
| "Start Stock Take Date": "盤點日期", | "Start Stock Take Date": "盤點日期", | ||||
| "Create by item": "依貨品", | "Create by item": "依貨品", | ||||
| "Create by section": "依區域", | "Create by section": "依區域", | ||||
| "Cannot create while both section and item rounds are open": "目前同時有開著的依區域與依貨品盤點輪,無法再建立新輪", | |||||
| "Create by item hint": "搜尋並勾選貨品。此輪會納入這些貨在所有樓層/倉的可用庫存。", | "Create by item hint": "搜尋並勾選貨品。此輪會納入這些貨在所有樓層/倉的可用庫存。", | ||||
| "Create by item search hint": "可分別輸入貨品編號、貨品名稱;兩個都填時要同時符合", | "Create by item search hint": "可分別輸入貨品編號、貨品名稱;兩個都填時要同時符合", | ||||
| "Create by item selected count": "已選 {{count}} 項貨品", | "Create by item selected count": "已選 {{count}} 項貨品", | ||||