Browse Source

stock take block any other in/out while stock taking

stockTakeUpdate
CANCERYS\kw093 3 weeks ago
parent
commit
cbe50272e2
9 changed files with 209 additions and 84 deletions
  1. +30
    -2
      src/components/InventorySearch/InventoryLotLineTable.tsx
  2. +15
    -4
      src/components/Qc/QcStockInModal.tsx
  3. +85
    -61
      src/components/StockTakeManagement/ApproverStockTakeAll.tsx
  4. +4
    -1
      src/components/StockTakeManagement/PickerCardList.tsx
  5. +52
    -13
      src/components/StockTakeManagement/StockTakeRoundCardList.tsx
  6. +1
    -1
      src/components/StockTakeManagement/StockTakeTab.tsx
  7. +2
    -0
      src/components/Swal/CustomAlerts.tsx
  8. +18
    -0
      src/components/Swal/sweetalert2.css
  9. +2
    -2
      src/i18n/zh/stockTake.json

+ 30
- 2
src/components/InventorySearch/InventoryLotLineTable.tsx View File

@@ -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);
}


+ 15
- 4
src/components/Qc/QcStockInModal.tsx View File

@@ -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");
},


+ 85
- 61
src/components/StockTakeManagement/ApproverStockTakeAll.tsx View File

@@ -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>


+ 4
- 1
src/components/StockTakeManagement/PickerCardList.tsx View File

@@ -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")}


+ 52
- 13
src/components/StockTakeManagement/StockTakeRoundCardList.tsx View File

@@ -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}


+ 1
- 1
src/components/StockTakeManagement/StockTakeTab.tsx View File

@@ -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"


+ 2
- 0
src/components/Swal/CustomAlerts.tsx View File

@@ -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",


+ 18
- 0
src/components/Swal/sweetalert2.css View File

@@ -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;
}

+ 2
- 2
src/i18n/zh/stockTake.json View File

@@ -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": "盤點區域類型",


Loading…
Cancel
Save