| @@ -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 { 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; | |||
| } | |||
| import { readableServerMessage } from "@/app/utils/readableServerMessage"; | |||
| type AdjustmentEntry = InventoryLotLineResult & { | |||
| adjustedQty: number; | |||
| @@ -331,8 +307,7 @@ const prevAdjustmentModalOpenRef = useRef(false); | |||
| setStockAdjustmentModalOpen(false); | |||
| await onStockAdjustmentSuccess?.(); | |||
| } catch (e: unknown) { | |||
| const message = e instanceof Error ? e.message : String(e); | |||
| msgError(message || t("Save failed")); | |||
| msgError(readableServerMessage(e, t("Save failed"))); | |||
| } finally { | |||
| setIsUploading(false); | |||
| setIsAdjustSaving(false); | |||
| @@ -29,6 +29,7 @@ import { Check } from "@mui/icons-material"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import { useSearchParams } from "next/navigation"; | |||
| import { useQrCodeScannerContext } from "../QrCodeScannerProvider/QrCodeScannerProvider"; | |||
| import { readableServerMessage } from "@/app/utils/readableServerMessage"; | |||
| interface Props extends Omit<ModalProps, "children"> { | |||
| warehouse: WarehouseResult[]; | |||
| @@ -173,6 +174,12 @@ const QrModal: React.FC<Props> = ({ open, onClose, warehouse }) => { | |||
| } | |||
| // return; | |||
| const res = await updateStockInLine(args); | |||
| if (res?.type === "putaway_held" || res?.code === "PUTAWAY_HELD") { | |||
| setServerError( | |||
| res.message || "盤點進行中,自動上架將於盤點結束後入庫", | |||
| ); | |||
| return; | |||
| } | |||
| if (Boolean(res.id)) { | |||
| // update entries | |||
| console.log(res); | |||
| @@ -182,8 +189,12 @@ const QrModal: React.FC<Props> = ({ open, onClose, warehouse }) => { | |||
| console.log(res); | |||
| // if (res) | |||
| } 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); | |||
| } | |||
| }, | |||
| @@ -39,6 +39,7 @@ import { QrCodeScanner } from "../QrCodeScannerProvider/QrCodeScannerProvider"; | |||
| import { msg, msgError } from "../Swal/CustomAlerts"; | |||
| import { PutAwayRecord } from "."; | |||
| import FgStockInForm from "../StockIn/FgStockInForm"; | |||
| import { readableServerMessage } from "@/app/utils/readableServerMessage"; | |||
| 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); | |||
| 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)) { | |||
| // update entries | |||
| console.log("%c Update Success:", "color:green", res); | |||
| @@ -475,8 +483,12 @@ const PutAwayModal: React.FC<Props> = ({ open, onClose, warehouse, stockInLineId | |||
| // console.log(res); | |||
| // if (res) | |||
| } 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); | |||
| } | |||
| }, | |||
| @@ -32,8 +32,12 @@ import { | |||
| ToggleButton, | |||
| ToggleButtonGroup, | |||
| Checkbox, | |||
| Accordion, | |||
| AccordionSummary, | |||
| AccordionDetails, | |||
| } from "@mui/material"; | |||
| import SearchIcon from "@mui/icons-material/Search"; | |||
| import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; | |||
| import NotificationsNoneOutlinedIcon from "@mui/icons-material/NotificationsNoneOutlined"; | |||
| import { useRouter } from "next/navigation"; | |||
| import { SessionWithTokens } from "@/config/authConfig"; | |||
| @@ -318,6 +322,8 @@ const PickerCardList: React.FC<PickerCardListProps> = ({ | |||
| dayjs.extend(duration); | |||
| const [loading, setLoading] = useState(true); | |||
| /** Same UX as Approver: Search Criteria accordion can collapse / expand. */ | |||
| const [showFilters, setShowFilters] = useState(true); | |||
| /** 這一輪全部卡片,篩選在前端做,點選不再重打 API */ | |||
| const [roundRecords, setRoundRecords] = useState<AllPickedStockTakeListReponse[]>([]); | |||
| const { data: session } = useSession() as { data: SessionWithTokens | null }; | |||
| @@ -342,7 +348,9 @@ const PickerCardList: React.FC<PickerCardListProps> = ({ | |||
| const [createFloorTabIndex, setCreateFloorTabIndex] = useState(0); | |||
| const [createDialogSearchQuery, setCreateDialogSearchQuery] = useState(""); | |||
| const [createMode, setCreateMode] = useState<"section" | "item">("section"); | |||
| /** Open item round → hide 「依區域」; open section round → hide 「依貨品」 (symmetric). */ | |||
| const [blockSectionCreate, setBlockSectionCreate] = useState(false); | |||
| const [blockItemCreate, setBlockItemCreate] = useState(false); | |||
| const [createDialogItemCode, setCreateDialogItemCode] = useState(""); | |||
| const [createDialogItemName, setCreateDialogItemName] = useState(""); | |||
| const [createDialogItemHits, setCreateDialogItemHits] = useState<CreateDialogItemHit[]>([]); | |||
| @@ -718,15 +726,28 @@ const PickerCardList: React.FC<PickerCardListProps> = ({ | |||
| getStockTakeRoundCards() | |||
| .then((res) => { | |||
| 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) => { | |||
| 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 () => { | |||
| cancelled = true; | |||
| @@ -1070,7 +1091,7 @@ const PickerCardList: React.FC<PickerCardListProps> = ({ | |||
| setCreateDialogRoundName(""); | |||
| setCreateDialogPlanStart(dayjs()); | |||
| setCreateDialogSelectedItems([]); | |||
| setCreateMode("section"); | |||
| setCreateMode(blockSectionCreate && !blockItemCreate ? "item" : "section"); | |||
| } catch (e) { | |||
| console.error(e); | |||
| } finally { | |||
| @@ -1078,6 +1099,8 @@ const PickerCardList: React.FC<PickerCardListProps> = ({ | |||
| createStockTakeInFlightRef.current = false; | |||
| } | |||
| }, [ | |||
| blockItemCreate, | |||
| blockSectionCreate, | |||
| createDialogPlanStart, | |||
| createDialogRoundName, | |||
| createDialogSelectedItems, | |||
| @@ -1235,7 +1258,7 @@ const PickerCardList: React.FC<PickerCardListProps> = ({ | |||
| setOpenConfirmDialog(false); | |||
| setCreateDialogRoundName(""); | |||
| setCreateDialogPlanStart(dayjs()); | |||
| setCreateMode(blockSectionCreate ? "item" : "section"); | |||
| setCreateMode(blockSectionCreate && !blockItemCreate ? "item" : "section"); | |||
| setCreateDialogItemCode(""); | |||
| setCreateDialogItemName(""); | |||
| setCreateDialogItemHits([]); | |||
| @@ -1246,11 +1269,28 @@ const PickerCardList: React.FC<PickerCardListProps> = ({ | |||
| <Box> | |||
| {!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> | |||
| </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 item xs={12}> | |||
| <Tabs | |||
| @@ -1546,6 +1586,8 @@ const PickerCardList: React.FC<PickerCardListProps> = ({ | |||
| </CardActions> | |||
| </CardContent> | |||
| </Card> | |||
| </AccordionDetails> | |||
| </Accordion> | |||
| <Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", mb: 2 }}> | |||
| @@ -1728,18 +1770,34 @@ const PickerCardList: React.FC<PickerCardListProps> = ({ | |||
| </Tooltip> | |||
| </DialogTitle> | |||
| <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 | |||
| value={createMode} | |||
| value={ | |||
| createMode === "item" && blockItemCreate | |||
| ? "section" | |||
| : createMode === "section" && blockSectionCreate | |||
| ? "item" | |||
| : createMode | |||
| } | |||
| onChange={(_, v: "section" | "item") => { | |||
| if (!v) return; | |||
| if (v === "section" && blockSectionCreate) return; | |||
| if (v === "item" && blockItemCreate) return; | |||
| setCreateMode(v); | |||
| }} | |||
| > | |||
| {blockSectionCreate ? null : ( | |||
| <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> | |||
| )} | |||
| </Box> | |||
| <DialogContent | |||
| dividers | |||
| @@ -229,7 +229,7 @@ const StockTakeTab: React.FC = () => { | |||
| ) | |||
| ) : null} | |||
| <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 }}> | |||
| <Typography | |||
| component="label" | |||
| @@ -49,6 +49,7 @@ | |||
| "Control Time": "Control Time", | |||
| "Create by item": "By item", | |||
| "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 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", | |||
| @@ -130,6 +130,7 @@ | |||
| "Start Stock Take Date": "盤點日期", | |||
| "Create by item": "依貨品", | |||
| "Create by section": "依區域", | |||
| "Cannot create while both section and item rounds are open": "目前同時有開著的依區域與依貨品盤點輪,無法再建立新輪", | |||
| "Create by item hint": "搜尋並勾選貨品。此輪會納入這些貨在所有樓層/倉的可用庫存。", | |||
| "Create by item search hint": "可分別輸入貨品編號、貨品名稱;兩個都填時要同時符合", | |||
| "Create by item selected count": "已選 {{count}} 項貨品", | |||