Parcourir la source

update stock take and stock take report

production
CANCERYS\kw093 il y a 1 semaine
Parent
révision
16d1d0f193
12 fichiers modifiés avec 442 ajouts et 299 suppressions
  1. +8
    -0
      src/app/(main)/report/page.tsx
  2. +13
    -1
      src/app/api/stockTake/actions.ts
  3. +1
    -0
      src/app/api/warehouse/index.ts
  4. +0
    -94
      src/components/JoWorkbench/newJobPickExecution.tsx
  5. +1
    -1
      src/components/StockTakeManagement/ApproverStockTake.tsx
  6. +184
    -96
      src/components/StockTakeManagement/ApproverStockTakeAll.tsx
  7. +178
    -82
      src/components/StockTakeManagement/PickerCardList.tsx
  8. +8
    -2
      src/components/StockTakeManagement/PickerReStockTake.tsx
  9. +12
    -13
      src/components/StockTakeManagement/PickerStockTake.tsx
  10. +26
    -2
      src/config/reportConfig.ts
  11. +3
    -5
      src/i18n/zh/common.json
  12. +8
    -3
      src/i18n/zh/inventory.json

+ 8
- 0
src/app/(main)/report/page.tsx Voir le fichier

@@ -141,6 +141,14 @@ export default function ReportPage() {
}
// Clear dynamic options when report changes
setDynamicOptions({});

// Default "All" (no filter) for stock take variance report conditions.
if (selectedReportId === 'rep-012') {
setCriteria({
store_id: 'All',
status: 'All',
});
}
}, [selectedReportId]);

// React 18 Strict Mode (dev) mounts → unmounts → remounts, so effects with [] run twice.


+ 13
- 1
src/app/api/stockTake/actions.ts Voir le fichier

@@ -29,6 +29,7 @@ export interface InventoryLotDetailResponse {
warehouseSlot: string;
warehouseArea: string;
warehouse: string;
storeId?: string | null;
varianceQty: number | null;
status: string;
remarks: string | null;
@@ -137,6 +138,8 @@ export interface AllPickedStockTakeListReponse {
endTime: string | null;
planStartDate: string | null;
stockTakeSectionDescription: string | null;
warehouseArea: string | null;
storeId: string | null;
reStockTakeTrueFalse: boolean;
}

@@ -254,7 +257,7 @@ export const getStockTakeRecords = async () => {
export const getStockTakeRecordsPaged = async (
pageNum: number,
pageSize: number,
params?: { sectionDescription?: string; stockTakeSections?: string }
params?: { sectionDescription?: string; stockTakeSections?: string; status?: string; area?: string; storeId?: string }
) => {
const searchParams = new URLSearchParams();
searchParams.set("pageNum", String(pageNum));
@@ -265,6 +268,15 @@ export const getStockTakeRecordsPaged = async (
if (params?.stockTakeSections?.trim()) {
searchParams.set("stockTakeSections", params.stockTakeSections.trim());
}
if (params?.status && params.status !== "All") {
searchParams.set("status", params.status);
}
if (params?.area?.trim()) {
searchParams.set("area", params.area.trim());
}
if (params?.storeId && params.storeId !== "All") {
searchParams.set("storeId", params.storeId);
}
const url = `${BASE_API_URL}/stockTakeRecord/AllPickedStockOutRecordList?${searchParams.toString()}`;
const res = await serverFetchJson<RecordsRes<AllPickedStockTakeListReponse>>(url, { method: "GET" });
return res;


+ 1
- 0
src/app/api/warehouse/index.ts Voir le fichier

@@ -39,5 +39,6 @@ export interface StockTakeSectionInfo {
id: string;
stockTakeSection: string;
stockTakeSectionDescription: string | null;
storeId?: string | null;
warehouseCount: number;
}

+ 0
- 94
src/components/JoWorkbench/newJobPickExecution.tsx Voir le fichier

@@ -44,9 +44,6 @@ import {
checkAndCompletePickOrderByConsoCode,
confirmLotSubstitution,
updateStockOutLineStatusByQRCodeAndLotNo, // ✅ 添加
batchSubmitList, // ✅ 添加
batchSubmitListRequest, // ✅ 添加
batchSubmitListLineRequest,
} from "@/app/api/pickOrder/actions";
// 修改:使用 Job Order API
import {
@@ -3783,92 +3780,6 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBackToList }) => {
});
}

// Hold-only:與整批相同規則,只送一筆 batch,把實揈/完成狀態寫回 DB
if (useHoldOnlyApi && pickOrderIdEarly && solId > 0) {
const freshData =
await fetchJobOrderLotsHierarchicalByPickOrderIdWorkbench(pickOrderIdEarly);
const flatLots = getAllLotsFromHierarchical(freshData);
const lotRow = flatLots.find(
(l: any) => Number(l.stockOutLineId) === solId,
);
if (!lotRow) {
throw new Error(
"Could not find lot row after refresh for batch submit",
);
}

const requiredQty = Number(
lotRow.requiredQty || lotRow.pickOrderLineRequiredQty || 0,
);
const issuePickedVal = picked;
const currentActualPickQty = Number(
issuePickedVal ?? lotRow.actualPickQty ?? 0,
);
const onlyComplete =
lotRow.stockOutLineStatus === "partially_completed" ||
lotRow.stockOutLineStatus === "PARTIALLY_COMPLETE" ||
issuePickedVal !== undefined;
const expired = isLotAvailabilityExpired(lotRow);
const unavailable = isInventoryLotLineUnavailable(lotRow);

let targetActual: number;
let newStatus: string;

if (unavailable) {
targetActual = currentActualPickQty;
newStatus = "completed";
} else if (expired && issuePickedVal === undefined) {
targetActual = 0;
newStatus = "completed";
} else if (onlyComplete) {
targetActual = currentActualPickQty;
newStatus = "completed";
} else {
const remainingQty = Math.max(
0,
requiredQty - currentActualPickQty,
);
const cumulativeQty = currentActualPickQty + remainingQty;
targetActual = cumulativeQty;
newStatus = "partially_completed";
if (requiredQty > 0 && cumulativeQty >= requiredQty) {
newStatus = "completed";
}
}

const line: batchSubmitListLineRequest = {
stockOutLineId: solId,
pickOrderLineId: Number(lotRow.pickOrderLineId),
inventoryLotLineId: lotRow.lotId ? Number(lotRow.lotId) : null,
requiredQty,
actualPickQty: targetActual,
stockOutLineStatus: newStatus,
pickOrderConsoCode: String(lotRow.pickOrderConsoCode || ""),
noLot: Boolean(lotRow.noLot === true),
};

const batchResult = await batchSubmitList({
userId: currentUserId || 0,
lines: [line],
});

if (!batchResult || batchResult.code !== "SUCCESS") {
throw new Error(
batchResult?.message ||
"Batch submit failed after hold adjustment",
);
}

const conso = String(lotRow.pickOrderConsoCode || "").trim();
if (conso) {
try {
await checkAndCompletePickOrderByConsoCode(conso);
} catch (e) {
console.error("❌ completion check after single batch:", e);
}
}
}

setPickExecutionFormOpen(false);
setSelectedLotForExecutionForm(null);

@@ -3880,16 +3791,11 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBackToList }) => {
},
[
fetchJobOrderData,
getAllLotsFromHierarchical,
currentUserId,
selectedLotForExecutionForm,
updateHandledBy,
filterArgs,
session?.user?.name,
batchSubmitList,
checkAndCompletePickOrderByConsoCode,
isLotAvailabilityExpired,
isInventoryLotLineUnavailable,
],
);
// Calculate remaining required quantity


+ 1
- 1
src/components/StockTakeManagement/ApproverStockTake.tsx Voir le fichier

@@ -689,7 +689,7 @@ const ApproverStockTake: React.FC<ApproverStockTakeProps> = ({
variant="outlined"
color="warning"
onClick={() => handleUpdateStatusToNotMatch(detail)}
disabled={updatingStatus || detail.stockTakeRecordStatus === "completed"}
disabled={updatingStatus || detail.stockTakeRecordStatus === "completed"||hasSecond}
>
{t("ReStockTake")}
</Button>


+ 184
- 96
src/components/StockTakeManagement/ApproverStockTakeAll.tsx Voir le fichier

@@ -18,6 +18,15 @@ import {
Radio,
TablePagination,
TableSortLabel,
Card,
CardContent,
CardActions,
Grid,
FormControl,
InputLabel,
Select,
MenuItem,
Autocomplete,
} from "@mui/material";
import { useState, useCallback, useEffect, useMemo } from "react";
import { Collapse } from "@mui/material";
@@ -37,7 +46,6 @@ import {
updateStockTakeRecordStatusToNotMatch,
type ApproverInventoryLotDetailsQuery,
} from "@/app/api/stockTake/actions";
import SearchBox, { Criterion } from "@/components/SearchBox/SearchBox";
import { fetchStockTakeSections } from "@/app/api/warehouse/actions";
import { useSession } from "next-auth/react";
import { SessionWithTokens } from "@/config/authConfig";
@@ -66,13 +74,12 @@ type ApprovedSortKey =
| "stockTakerName"
| "variance";

type ApproverSearchKey = "sectionDescription" | "stockTakeSession" | "itemKeyword" | "warehouseKeyword"|"status";

type ApproverSearchFilters = {
sectionDescription: string;
stockTakeSession: string;
itemKeyword: string;
warehouseKeyword: string;
storeId: string;
status: string;
};

@@ -85,15 +92,6 @@ function buildApproverInventoryQuery(filters: ApproverSearchFilters): ApproverIn
};
}

function hasAnyApproverSearchCriterion(f: ApproverSearchFilters): boolean {
return (
(f.sectionDescription && f.sectionDescription !== "All") ||
f.stockTakeSession.trim() !== "" ||
f.itemKeyword.trim() !== "" ||
f.warehouseKeyword.trim() !== ""
);
}

function isBlankApproverField(value: string | undefined): boolean {
return value == null || String(value).trim() === "";
}
@@ -202,9 +200,15 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({
const [total, setTotal] = useState(0);
const [approvedSortKey, setApprovedSortKey] = useState<ApprovedSortKey | null>(null);
const [approvedSortDir, setApprovedSortDir] = useState<"asc" | "desc">("asc");
const [sectionDescriptionAutocompleteOptions, setSectionDescriptionAutocompleteOptions] = useState<
{ value: string; label: string }[]
>([]);
const [sectionDescriptionOptions, setSectionDescriptionOptions] = useState<string[]>([]);
const [stockTakeSectionOptions, setStockTakeSectionOptions] = useState<string[]>([]);
const [storeIdOptions, setStoreIdOptions] = useState<string[]>(["2F", "4F"]);
const [searchSectionDescription, setSearchSectionDescription] = useState<string>("All");
const [searchStockTakeSession, setSearchStockTakeSession] = useState<string>("");
const [searchItemKeyword, setSearchItemKeyword] = useState<string>("");
const [searchWarehouseKeyword, setSearchWarehouseKeyword] = useState<string>("");
const [searchStoreId, setSearchStoreId] = useState<string>("All");
const [searchStatus, setSearchStatus] = useState<string>(mode === "pending" ? "pass" : "All");
const [showFilters, setShowFilters] = useState(true)
const [appliedFilters, setAppliedFilters] = useState<ApproverSearchFilters | null>(null);

@@ -227,76 +231,32 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({
[]
);

const handleApproverSearchBoxSearch = useCallback(
(inputs: Record<ApproverSearchKey | `${ApproverSearchKey}To`, string>) => {
const next: ApproverSearchFilters = {
sectionDescription: inputs.sectionDescription || "All",
stockTakeSession: inputs.stockTakeSession || "",
itemKeyword: inputs.itemKeyword || "",
warehouseKeyword: inputs.warehouseKeyword || "",
status: inputs.status || "All",
};
/*
if (!hasAnyApproverSearchCriterion(next)) {
onSnackbar(t("Please set at least one search criterion"), "warning");
return;
}
*/
setAppliedFilters(next);
setPage(0);
},
[onSnackbar, t]
);

const handleApproverSearchBoxReset = useCallback(() => {
const handleSearch = useCallback(() => {
const next: ApproverSearchFilters = {
sectionDescription: searchSectionDescription || "All",
stockTakeSession: searchStockTakeSession || "",
itemKeyword: searchItemKeyword || "",
warehouseKeyword: searchWarehouseKeyword || "",
storeId: searchStoreId || "All",
status: mode === "pending" ? (searchStatus || "pass") : "All",
};
setAppliedFilters(next);
setPage(0);
}, [searchSectionDescription, searchStockTakeSession, searchItemKeyword, searchWarehouseKeyword, searchStoreId, searchStatus, mode]);

const handleResetSearch = useCallback(() => {
const defaultStatus = mode === "pending" ? "pass" : "All";
setSearchSectionDescription("All");
setSearchStockTakeSession("");
setSearchItemKeyword("");
setSearchWarehouseKeyword("");
setSearchStoreId("All");
setSearchStatus(defaultStatus);
setAppliedFilters(null);
setPage(0);
setInventoryLotDetails([]);
setTotal(0);
}, []);

const approverSearchCriteria: Criterion<ApproverSearchKey>[] = useMemo(
() => [
{
type: "autocomplete",
label: t("Stock Take Section Description"),
paramName: "sectionDescription",
options: sectionDescriptionAutocompleteOptions,
needAll: true,
},
{
type: "text",
label: t("Stock Take Section (can use , to search multiple sections)"),
paramName: "stockTakeSession",
placeholder: "",
},
{
type: "text",
label: t("Item"),
paramName: "itemKeyword",
placeholder: "",
},

{
type: "text",
label: t("Warehouse"),
paramName: "warehouseKeyword",
placeholder: "",
},
{
type: "select-labelled",
label: t("Record Status"),
paramName: "status",
options: [
{ label: t("All"), value: "All" },
{ label: t("Pending"), value: "pending" },
{ label: t("Not Match"), value: "notMatch" },
{ label: t("Pass"), value: "pass" }, // UI=Pass,值=completed
],
}
],
[t, sectionDescriptionAutocompleteOptions]
);
}, [mode]);

const loadDetails = useCallback(async (filters: ApproverSearchFilters) => {
setLoadingDetails(true);
@@ -342,13 +302,19 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({
fetchStockTakeSections()
.then((sections) => {
const descSet = new Set<string>();
const sectionSet = new Set<string>();
const storeSet = new Set<string>(["2F", "4F"]);
sections.forEach((s) => {
const section = s.stockTakeSection?.trim();
if (section) sectionSet.add(section);
const desc = s.stockTakeSectionDescription?.trim();
if (desc) descSet.add(desc);
const storeId = s.storeId?.trim();
if (storeId) storeSet.add(storeId);
});
setSectionDescriptionAutocompleteOptions(
Array.from(descSet).map((desc) => ({ value: desc, label: desc }))
);
setStockTakeSectionOptions(Array.from(sectionSet).sort((a, b) => a.localeCompare(b)));
setSectionDescriptionOptions(Array.from(descSet).sort((a, b) => a.localeCompare(b)));
setStoreIdOptions(Array.from(storeSet).sort((a, b) => a.localeCompare(b)));
})
.catch((e) => {
console.error("Failed to load section descriptions for approver search:", e);
@@ -361,6 +327,7 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({
setApproverQty({});
setApproverBadQty({});
setAppliedFilters(null);
setSearchStatus(mode === "pending" ? "pass" : "All");
setPage(0);
setInventoryLotDetails([]);
setTotal(0);
@@ -491,8 +458,14 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({
const percent = parseFloat(variancePercentTolerance || "0");
const thresholdPercent = isNaN(percent) || percent < 0 ? 0 : percent;
const statusFilter = appliedFilters?.status ?? "All";
const statusFilter = mode === "pending" ? (appliedFilters?.status ?? "pass") : "All";
const storeIdFilter = appliedFilters?.storeId ?? "All";
return inventoryLotDetails.filter((detail) => {
if (storeIdFilter !== "All") {
if ((detail.storeId || "").trim().toLowerCase() !== storeIdFilter.trim().toLowerCase()) {
return false;
}
}
if (statusFilter !== "All") {
const rowStatus = normalizeStatus(detail.stockTakeRecordStatus);
const wanted = normalizeStatus(statusFilter);
@@ -526,6 +499,8 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({
variancePercentTolerance,
qtySelection,
calculateDifference,
appliedFilters,
mode,
]);

const sortedDetails = useMemo(() => {
@@ -890,7 +865,7 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({
{
field: "qtyBlock",
headerName: t("Stock Take Qty(include Bad Qty)= Available Qty"),
minWidth: 420,
minWidth: 320,
flex: 3,
sortable: false,
renderCell: (params: GridRenderCellParams<InventoryLotDetailResponse>) => {
@@ -949,7 +924,8 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({
{formatNumber(
(detail.firstStockTakeQty ?? 0) + (detail.firstBadQty ?? 0)
)}{" "}
({detail.firstBadQty ?? 0}) ={" "}
{/* ({detail.firstBadQty ?? 0}) */}
={" "}
{formatNumber(detail.firstStockTakeQty ?? 0)}
</Typography>
</Stack>
@@ -974,7 +950,8 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({
(detail.secondStockTakeQty ?? 0) +
(detail.secondBadQty ?? 0)
)}{" "}
({detail.secondBadQty ?? 0}) ={" "}
{/* ({detail.secondBadQty ?? 0}) */}
={" "}
{formatNumber(detail.secondStockTakeQty ?? 0)}
</Typography>
</Stack>
@@ -1013,6 +990,7 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({
inputProps={{ inputMode: "numeric", pattern: "[0-9]*" }}
/>
{/*
<TextField
size="small"
type="number"
@@ -1030,6 +1008,8 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({
disabled={mode === "approved" || selection !== "approver"}
inputProps={{ inputMode: "numeric", pattern: "[0-9]*" }}
/>
*/
}
<Typography variant="body2" sx={{ minWidth: 90 }}>
= {formatNumber(approverGoodQty)}
</Typography>
@@ -1124,8 +1104,7 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({
sortable: false,
renderCell: (params) => {
const detail = params.row;
const hasSecond =
detail.secondStockTakeQty != null && detail.secondStockTakeQty >= 0;
const hasSecond = detail.secondStockTakeQty != null && detail.secondStockTakeQty >= 0;
const selection =
qtySelection[detail.id] || (hasSecond ? "second" : "first");
@@ -1149,7 +1128,7 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({
variant="outlined"
color="warning"
onClick={() => handleUpdateStatusToNotMatch(detail)}
disabled={updatingStatus}
disabled={updatingStatus||hasSecond}
>
{t("Not Match")}
</Button>
@@ -1189,6 +1168,25 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({
]);
const effectivePageSize =
pageSize === "all" ? Math.max(total || 0, 0) : (pageSize as number);
useEffect(() => {
setStockTakeSectionOptions((prev) => {
const sectionSet = new Set<string>(prev);
inventoryLotDetails.forEach((item) => {
const section = item.stockTakeSection?.trim();
if (section) sectionSet.add(section);
});
return Array.from(sectionSet).sort((a, b) => a.localeCompare(b));
});
setStoreIdOptions((prev) => {
const storeSet = new Set<string>([...prev, "2F", "4F"]);
inventoryLotDetails.forEach((item) => {
const storeId = item.storeId?.trim();
if (storeId) storeSet.add(storeId);
});
return Array.from(storeSet).sort((a, b) => a.localeCompare(b));
});
}, [inventoryLotDetails]);

return (
<Box>
{onBack && (
@@ -1260,11 +1258,101 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({

<AccordionDetails>
<Box sx={{ width: "100%" }}>
<SearchBox<ApproverSearchKey>
criteria={approverSearchCriteria}
onSearch={handleApproverSearchBoxSearch}
onReset={handleApproverSearchBoxReset}
/>
<Card elevation={0}>
<CardContent>
<Grid container spacing={2}>
<Grid item xs={12} md={4}>
<FormControl fullWidth>
<InputLabel>{t("Stock Take Section")}</InputLabel>
<Select
value={searchSectionDescription}
label={t("Stock Take Section")}
onChange={(e) => setSearchSectionDescription(e.target.value)}
>
<MenuItem value="All">{t("All")}</MenuItem>
{sectionDescriptionOptions.map((desc) => (
<MenuItem key={desc} value={desc}>
{desc}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid item xs={12} md={4}>
<Autocomplete
freeSolo
options={stockTakeSectionOptions}
value={searchStockTakeSession}
onInputChange={(_, newValue) => setSearchStockTakeSession(newValue)}
renderInput={(params) => (
<TextField
{...params}
fullWidth
label={t("Stock Take Section (can use , to search multiple sections)")}
/>
)}
/>
</Grid>
<Grid item xs={12} md={4}>
<TextField
fullWidth
label={t("Item")}
value={searchItemKeyword}
onChange={(e) => setSearchItemKeyword(e.target.value)}
/>
</Grid>
<Grid item xs={12} md={4}>
<TextField
fullWidth
label={t("Warehouse")}
value={searchWarehouseKeyword}
onChange={(e) => setSearchWarehouseKeyword(e.target.value)}
/>
</Grid>
<Grid item xs={12} md={4}>
<FormControl fullWidth>
<InputLabel>{t("Store ID")}</InputLabel>
<Select
value={searchStoreId}
label={t("Store ID")}
onChange={(e) => setSearchStoreId(e.target.value)}
>
<MenuItem value="All">{t("All")}</MenuItem>
{storeIdOptions.map((storeId) => (
<MenuItem key={storeId} value={storeId}>
{storeId}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
{mode === "pending" && (
<Grid item xs={12} md={4}>
<FormControl fullWidth>
<InputLabel>{t("Record Status")}</InputLabel>
<Select
value={searchStatus}
label={t("Record Status")}
onChange={(e) => setSearchStatus(e.target.value)}
>
<MenuItem value="pending">{t("Pending")}</MenuItem>
<MenuItem value="notMatch">{t("Not Match")}</MenuItem>
<MenuItem value="pass">{t("Pass")}</MenuItem>
</Select>
</FormControl>
</Grid>
)}
</Grid>
<CardActions sx={{ px: 0, pt: 2, gap: 1 }}>
<Button variant="outlined" onClick={handleResetSearch}>
{t("Reset")}
</Button>
<Button variant="contained" onClick={handleSearch}>
{t("Search")}
</Button>
</CardActions>
</CardContent>
</Card>
</Box>
</AccordionDetails>
</Accordion>


+ 178
- 82
src/components/StockTakeManagement/PickerCardList.tsx Voir le fichier

@@ -12,21 +12,24 @@ import {
CircularProgress,
TablePagination,
Grid,
LinearProgress,
Dialog,
DialogTitle,
DialogContent,
DialogContentText,
DialogActions,
TextField,
FormControl,
InputLabel,
Select,
MenuItem,
Autocomplete,
} from "@mui/material";
import { SessionWithTokens } from "@/config/authConfig";
import { useSession } from "next-auth/react";
import SearchBox, { Criterion } from "@/components/SearchBox/SearchBox";
import { useState, useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next";
import duration from "dayjs/plugin/duration";
import {
getStockTakeRecords,
AllPickedStockTakeListReponse,
createStockTakeForSections,
getStockTakeRecordsPaged,
@@ -36,7 +39,6 @@ import { fetchStockTakeSections } from "@/app/api/warehouse/actions";
import dayjs from "dayjs";
import { OUTPUT_DATE_FORMAT } from "@/app/utils/formatUtil";
import { AUTH } from "@/authorities";
const PER_PAGE = 6;

interface PickerCardListProps {
/** 由父層保存,從明細返回時仍回到同一頁 */
@@ -57,7 +59,6 @@ const PickerCardList: React.FC<PickerCardListProps> = ({
const { t } = useTranslation(["inventory", "common"]);
dayjs.extend(duration);

const PER_PAGE = 6;
const [loading, setLoading] = useState(false);
const [stockTakeSessions, setStockTakeSessions] = useState<AllPickedStockTakeListReponse[]>([]);
const [total, setTotal] = useState(0);
@@ -68,66 +69,45 @@ const PickerCardList: React.FC<PickerCardListProps> = ({
const [listRefreshNonce, setListRefreshNonce] = useState(0);
const [creating, setCreating] = useState(false);
const [openConfirmDialog, setOpenConfirmDialog] = useState(false);
const [filterSectionDescription, setFilterSectionDescription] = useState<string>("All");
const [filterStockTakeSession, setFilterStockTakeSession] = useState<string>("");
const [sectionDescriptionAutocompleteOptions, setSectionDescriptionAutocompleteOptions] = useState<{ value: string; label: string }[]>([]);
type PickerSearchKey = "sectionDescription" | "stockTakeSession";
const sectionDescriptionOptions = Array.from(
new Set(
stockTakeSessions
.map((s) => s.stockTakeSectionDescription)
.filter((v): v is string => !!v)
)
);
/*
// 按 description + section 双条件过滤
const filteredSessions = stockTakeSessions.filter((s) => {
const matchDesc =
filterSectionDescription === "All" ||
s.stockTakeSectionDescription === filterSectionDescription;
const [sectionDescriptionOptions, setSectionDescriptionOptions] = useState<string[]>([]);
const [stockTakeSectionOptions, setStockTakeSectionOptions] = useState<string[]>([]);
const [storeIdOptions, setStoreIdOptions] = useState<string[]>(["2F", "4F"]);
const [searchSectionDescription, setSearchSectionDescription] = useState<string>("All");
const [searchStockTakeSession, setSearchStockTakeSession] = useState<string>("");
const [searchStatus, setSearchStatus] = useState<string>("All");
const [searchArea, setSearchArea] = useState<string>("");
const [searchStoreId, setSearchStoreId] = useState<string>("All");

const sessionParts = (filterStockTakeSession ?? "")
.split(",")
.map((p) => p.trim().toLowerCase())
.filter(Boolean);
const matchSession =
sessionParts.length === 0 ||
sessionParts.some((part) =>
(s.stockTakeSession ?? "").toString().toLowerCase().includes(part)
);
const [filterSectionDescription, setFilterSectionDescription] = useState<string>("All");
const [filterStockTakeSession, setFilterStockTakeSession] = useState<string>("");
const [filterStatus, setFilterStatus] = useState<string>("All");
const [filterArea, setFilterArea] = useState<string>("");
const [filterStoreId, setFilterStoreId] = useState<string>("All");

return matchDesc && matchSession;
});
*/
const statusOptions = ["pending", "stockTaking", "approving", "completed"];

// SearchBox 的条件配置
const criteria: Criterion<PickerSearchKey>[] = [
{
type: "autocomplete",
label: t("Stock Take Section Description"),
paramName: "sectionDescription",
options: sectionDescriptionAutocompleteOptions,
needAll: true,
},
{
type: "text",
label: t("Stock Take Section (can use , to search multiple sections)"),
paramName: "stockTakeSession",
placeholder: "",
},
];
const handleSearch = () => {
setFilterSectionDescription(searchSectionDescription || "All");
setFilterStockTakeSession(searchStockTakeSession || "");
setFilterStatus(searchStatus || "All");
setFilterArea(searchArea || "");
setFilterStoreId(searchStoreId || "All");
onListPageChange(0);
};

const handleSearch = (inputs: Record<PickerSearchKey | `${PickerSearchKey}To`, string>) => {
setFilterSectionDescription(inputs.sectionDescription || "All");
setFilterStockTakeSession(inputs.stockTakeSession || "");
onListPageChange(0);
};
const handleResetSearch = () => {
setFilterSectionDescription("All");
setFilterStockTakeSession("");
onListPageChange(0);
};
const handleResetSearch = () => {
setSearchSectionDescription("All");
setSearchStockTakeSession("");
setSearchStatus("All");
setSearchArea("");
setSearchStoreId("All");
setFilterSectionDescription("All");
setFilterStockTakeSession("");
setFilterStatus("All");
setFilterArea("");
setFilterStoreId("All");
onListPageChange(0);
};

useEffect(() => {
let cancelled = false;
@@ -135,6 +115,9 @@ const handleResetSearch = () => {
getStockTakeRecordsPaged(page, pageSize, {
sectionDescription: filterSectionDescription,
stockTakeSections: filterStockTakeSession,
status: filterStatus,
area: filterArea,
storeId: filterStoreId,
})
.then((res) => {
if (cancelled) return;
@@ -154,7 +137,7 @@ const handleResetSearch = () => {
return () => {
cancelled = true;
};
}, [page, pageSize, filterSectionDescription, filterStockTakeSession, listRefreshNonce]);
}, [page, pageSize, filterSectionDescription, filterStockTakeSession, filterStatus, filterArea, filterStoreId, listRefreshNonce]);

//const startIdx = page * PER_PAGE;
//const paged = stockTakeSessions.slice(startIdx, startIdx + PER_PAGE);
@@ -187,18 +170,44 @@ const handleResetSearch = () => {
fetchStockTakeSections()
.then((sections) => {
const descSet = new Set<string>();
const sectionSet = new Set<string>();
const storeIdSet = new Set<string>(["2F", "4F"]);
sections.forEach((s) => {
const section = s.stockTakeSection?.trim();
if (section) sectionSet.add(section);
const desc = s.stockTakeSectionDescription?.trim();
if (desc) descSet.add(desc);
const storeId = s.storeId?.trim();
if (storeId) storeIdSet.add(storeId);
});
setSectionDescriptionAutocompleteOptions(
Array.from(descSet).map((desc) => ({ value: desc, label: desc }))
);
setStockTakeSectionOptions(Array.from(sectionSet).sort((a, b) => a.localeCompare(b)));
setSectionDescriptionOptions(Array.from(descSet).sort((a, b) => a.localeCompare(b)));
setStoreIdOptions(Array.from(storeIdSet).sort((a, b) => a.localeCompare(b)));
})
.catch((e) => {
console.error("Failed to load section descriptions for filter:", e);
});
}, []);
useEffect(() => {
setStockTakeSectionOptions((prev) => {
const sectionSet = new Set<string>(prev);
stockTakeSessions.forEach((item) => {
const section = item.stockTakeSession?.trim();
if (section) sectionSet.add(section);
});
return Array.from(sectionSet).sort((a, b) => a.localeCompare(b));
});
}, [stockTakeSessions]);
useEffect(() => {
setStoreIdOptions((prev) => {
const storeIdSet = new Set<string>([...prev, "2F", "4F"]);
stockTakeSessions.forEach((item) => {
const storeId = item.storeId?.trim();
if (storeId) storeIdSet.add(storeId);
});
return Array.from(storeIdSet).sort((a, b) => a.localeCompare(b));
});
}, [stockTakeSessions]);
const getStatusColor = (status: string) => {
const statusLower = status.toLowerCase();
if (statusLower === "completed") return "success";
@@ -277,23 +286,99 @@ const handleResetSearch = () => {
if (!first?.planStartDate) return null;
return dayjs(first.planStartDate).format(OUTPUT_DATE_FORMAT);
})();
if (loading) {
return (
<Box sx={{ display: "flex", justifyContent: "center", p: 3 }}>
<CircularProgress />
</Box>
);
}

return (
<Box>
<Box sx={{ width: "100%", mb: 2 }}>
<SearchBox<PickerSearchKey>
criteria={criteria}
onSearch={handleSearch}
onReset={handleResetSearch}
/>
</Box>
<Card elevation={0} sx={{ mb: 2 }}>
<CardContent>
<Typography variant="overline" sx={{ display: "block", mb: 1 }}>
{t("Search Criteria")}
</Typography>
<Grid container spacing={2}>
<Grid item xs={12} md={4}>
<FormControl fullWidth>
<InputLabel>{t("Stock Take Section")}</InputLabel>
<Select
value={searchSectionDescription}
label={t("Stock Take Section")}
onChange={(e) => setSearchSectionDescription(e.target.value)}
>
<MenuItem value="All">{t("All")}</MenuItem>
{sectionDescriptionOptions.map((desc) => (
<MenuItem key={desc} value={desc}>
{desc}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid item xs={12} md={4}>
<Autocomplete
freeSolo
options={stockTakeSectionOptions}
value={searchStockTakeSession}
onInputChange={(_, newValue) => setSearchStockTakeSession(newValue)}
renderInput={(params) => (
<TextField
{...params}
fullWidth
label={t("Stock Take Section (can use , to search multiple sections)")}
/>
)}
/>
</Grid>
<Grid item xs={12} md={4}>
<FormControl fullWidth>
<InputLabel>{t("Status")}</InputLabel>
<Select
value={searchStatus}
label={t("Status")}
onChange={(e) => setSearchStatus(e.target.value)}
>
<MenuItem value="All">{t("All")}</MenuItem>
{statusOptions.map((status) => (
<MenuItem key={status} value={status}>
{t(status)}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid item xs={12} md={6}>
<TextField
fullWidth
label={t("Area")}
value={searchArea}
onChange={(e) => setSearchArea(e.target.value)}
/>
</Grid>
<Grid item xs={12} md={6}>
<FormControl fullWidth>
<InputLabel>{t("Store ID")}</InputLabel>
<Select
value={searchStoreId}
label={t("Store ID")}
onChange={(e) => setSearchStoreId(e.target.value)}
>
<MenuItem value="All">{t("All")}</MenuItem>
{storeIdOptions.map((storeId) => (
<MenuItem key={storeId} value={storeId}>
{storeId}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
</Grid>
<CardActions sx={{ px: 0, pt: 2, gap: 1 }}>
<Button variant="outlined" onClick={handleResetSearch}>
{t("Reset")}
</Button>
<Button variant="contained" onClick={handleSearch}>
{t("Search")}
</Button>
</CardActions>
</CardContent>
</Card>
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", mb: 2 }}>

@@ -314,13 +399,23 @@ const handleResetSearch = () => {
</Button>
</Box>

{loading ? (
<Box sx={{ display: "flex", justifyContent: "center", p: 3 }}>
<CircularProgress />
</Box>
) : (
<Grid container spacing={2}>
{stockTakeSessions.map((session: AllPickedStockTakeListReponse) => {
{stockTakeSessions.map((session: AllPickedStockTakeListReponse) => {
const statusColor = getStatusColor(session.status || "");
const lastStockTakeDate = session.lastStockTakeDate
? dayjs(session.lastStockTakeDate).format(OUTPUT_DATE_FORMAT)
: "-";
const completionRate = getCompletionRate(session);
const sectionMeta = [
session.stockTakeSectionDescription,
session.warehouseArea,
session.storeId,
].filter((v): v is string => Boolean(v && v.trim())).join(" / ");

return (
<Grid key={session.id} item xs={12} sm={6} md={4}>
@@ -337,7 +432,7 @@ const handleResetSearch = () => {
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 1 }}>
<Typography variant="subtitle1" fontWeight={600}>
{t("Section")}: {session.stockTakeSession}
{session.stockTakeSectionDescription ? ` (${session.stockTakeSectionDescription})` : null}
{sectionMeta ? ` (${sectionMeta})` : null}
</Typography>
</Stack>
@@ -383,6 +478,7 @@ const handleResetSearch = () => {
);
})}
</Grid>
)}

{total > 0 && (
<TablePagination


+ 8
- 2
src/components/StockTakeManagement/PickerReStockTake.tsx Voir le fichier

@@ -495,6 +495,7 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({
}}
placeholder={t("Stock Take Qty")}
/>
{/*
<TextField
size="small"
type="number"
@@ -520,6 +521,7 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({
}}
placeholder={t("Bad Qty")}
/>
*/}
<Typography variant="body2">
= {formatNumber(parseFloat(inputs.firstQty || "0") - parseFloat(inputs.firstBadQty || "0"))}
</Typography>
@@ -528,7 +530,8 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({
<Typography variant="body2">
{t("First")}:{" "}
{formatNumber((detail.firstStockTakeQty ?? 0) + (detail.firstBadQty ?? 0))}{" "}
({formatNumber(detail.firstBadQty ?? 0)}) ={" "}
{/* ({formatNumber(detail.firstBadQty ?? 0)}) */}
={" "}
{formatNumber(detail.firstStockTakeQty ?? 0)}
</Typography>
) : null}
@@ -562,6 +565,7 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({
}}
placeholder={t("Stock Take Qty")}
/>
{/*
<TextField
size="small"
type="number"
@@ -587,6 +591,7 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({
}}
placeholder={t("Bad Qty")}
/>
*/}
<Typography variant="body2">
= {formatNumber(parseFloat(inputs.secondQty || "0") - parseFloat(inputs.secondBadQty || "0"))}
</Typography>
@@ -595,7 +600,8 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({
<Typography variant="body2">
{t("Second")}:{" "}
{formatNumber((detail.secondStockTakeQty ?? 0) + (detail.secondBadQty ?? 0))}{" "}
({formatNumber(detail.secondBadQty ?? 0)}) ={" "}
{/* ({formatNumber(detail.secondBadQty ?? 0)}) */}
={" "}
{formatNumber(detail.secondStockTakeQty ?? 0)}
</Typography>
) : null}


+ 12
- 13
src/components/StockTakeManagement/PickerStockTake.tsx Voir le fichier

@@ -191,18 +191,9 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({
const totalQtyStr = isFirstSubmit ? recordInputs[detail.id]?.firstQty : recordInputs[detail.id]?.secondQty;
const badQtyStr = isFirstSubmit ? recordInputs[detail.id]?.firstBadQty : recordInputs[detail.id]?.secondBadQty;
// 只檢查 totalQty,Bad Qty 未輸入時預設為 0
if (!totalQtyStr) {
onSnackbar(
isFirstSubmit
? t("Please enter QTY")
: t("Please enter Second QTY"),
"error"
);
return;
}

const totalQty = parseFloat(totalQtyStr);
const totalQty = parseFloat(totalQtyStr || "0") || 0;
const badQty = parseFloat(badQtyStr || "0") || 0; // 空字串時為 0
if (Number.isNaN(totalQty)) {
@@ -617,6 +608,7 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({
}}
placeholder={t("Stock Take Qty")}
/>
{/*
<TextField
size="small"
type="number"
@@ -643,6 +635,7 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({
}}
placeholder={t("Bad Qty")}
/>
*/}
<Typography variant="body2">
=
{formatNumber(
@@ -658,11 +651,13 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({
(detail.firstStockTakeQty ?? 0) +
(detail.firstBadQty ?? 0)
)}{" "}
{/*
(
{formatNumber(
detail.firstBadQty ?? 0
)}
) ={" "}
*/}
={" "}
{formatNumber(detail.firstStockTakeQty ?? 0)}
</Typography>
) : null}
@@ -693,6 +688,7 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({
}}
placeholder={t("Stock Take Qty")}
/>
{/*
<TextField
size="small"
type="number"
@@ -715,6 +711,7 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({
}}
placeholder={t("Bad Qty")}
/>
*/}
<Typography variant="body2">
=
{formatNumber(
@@ -730,11 +727,13 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({
(detail.secondStockTakeQty ?? 0) +
(detail.secondBadQty ?? 0)
)}{" "}
{/*
(
{formatNumber(
detail.secondBadQty ?? 0
)}
) ={" "}
*/}
={" "}
{formatNumber(detail.secondStockTakeQty ?? 0)}
</Typography>
) : null}


+ 26
- 2
src/config/reportConfig.ts Voir le fichier

@@ -110,7 +110,7 @@ export const REPORTS: ReportDefinition[] = [
apiEndpoint: `${NEXT_PUBLIC_API_URL}/report/print-stock-take-variance-v2`,
fields: [
{
label: "盤點輪次 Stock Take Round",
label: "盤點輪次",
name: "stockTakeRoundId",
type: "select",
required: true,
@@ -118,7 +118,31 @@ export const REPORTS: ReportDefinition[] = [
dynamicOptionsEndpoint: `${NEXT_PUBLIC_API_URL}/report/stock-take-rounds`,
options: []
},
{ label: "物料編號 Item Code", name: "itemCode", type: "text", required: false},
{ label: "物料編號", name: "itemCode", type: "text", required: false},
{
label: "倉庫樓層",
name: "store_id",
type: "select",
required: false,
options: [
{ label: "全部", value: "All" },
{ label: "1F", value: "1F" },
{ label: "2F", value: "2F" },
{ label: "3F", value: "3F" },
{ label: "4F", value: "4F" }
],
},
{
label: "狀態",
name: "status",
type: "select",
required: false,
options: [
{ label: "全部", value: "All" },
{ label: "待盤點", value: "pending" },
{ label: "已審核", value: "completed" }
],
},
]
},
{ id: "rep-011",


+ 3
- 5
src/i18n/zh/common.json Voir le fichier

@@ -15,8 +15,6 @@
"Floor": "樓層",
"Job Order Type": "工單類型",

"FG": "成品",
"WIP": "半成品",
"BOM Type": "BOM 類型",
"No Lot": "沒有批號",
"Select All": "全選",
@@ -110,7 +108,7 @@
"code": "編號",
"Name": "名稱",
"Assignment successful": "分配成功",
"Pass": "通過",
"Unable to get user ID": "無法獲取用戶ID",
"Unknown error: ": "未知錯誤: ",
"Please try again later.": "請稍後重試。",
@@ -468,8 +466,8 @@
"Powder_Mixture": "箱料粉",
"Powder Mixture": "箱料粉",
"Not Match": "數值不符",
"Pass": "通過",
"pass": "通過",
"Pass": "已盤點",
"pass": "已盤點",
"Actions": "操作",
"Insert": "插入",
"Move to order": "移動到指定順序",


+ 8
- 3
src/i18n/zh/inventory.json Voir le fichier

@@ -50,11 +50,13 @@
"Batch Save All": "批量保存所有",
"not match": "數值不符",
"Not Match": "數值不符",
"Pass": "通過",
"Pass": "已盤點",
"Area": "區域",
"Selected Qty": "選擇數量",
"Show Search Filters": "顯示搜索器",
"Hide Search Filters": "隱藏搜索器",
"Stock Take Qty(include Bad Qty)= Available Qty": "盤點數(含壞品)= 可用數",
"Stock Take Qty(include Bad Qty)= Available Qty": "盤點數= 可用數",
"View ReStockTake": "查看重新盤點",
"Stock Take Qty": "盤點數",
"variance Percentage": "差異百分比",
@@ -97,9 +99,11 @@
"book qty": "帳面庫存",
"start time": "開始時間",
"end time": "結束時間",
"notmatch": "數值不符",
"Only Variance": "僅差異",
"Control Time": "操作時間",
"pass": "通過",
"Batch approver save completed: {{success}} success, {{errors}} errors": "批次審核儲存完成:成功 {{success}} 筆,錯誤 {{errors}} 筆",
"pass": "已盤點",
"not pass": "不通過",
"Available": "可用",
"approving": "審核中",
@@ -126,6 +130,7 @@
"Create Stock Take for All Sections": "為所有區域創建盤點",
"section": "區域",
"Stock Take Section": "盤點區域",
"Store ID":"樓層",
"Warehouse Location": "倉庫位置",
"UOM": "單位",
"First Qty": "第一次盤點數量",


Chargement…
Annuler
Enregistrer