@@ -52,9 +52,10 @@ import {
getStockTakeRecordsPaged,
getLatestStockTakeRoundMeta,
getBusyStockTakeSections,
getStockTakeRoundCards,
getAvailableLotsForStockTakeCreate,
searchItemsForStockTakeCreate,
type StockTakeCreateLotOption,
} from "@/app/api/stockTake/actions";
import { fetchItemsByPage } from "@/app/api/settings/item/actions";
import { fetchStockTakeSections } from "@/app/api/warehouse/actions";
import { fetchMissingStockTakeSectionIssues, fetchWarehouseListClient } from "@/app/api/warehouse/client";
import type { MissingStockTakeSectionIssueItem, StockTakeSectionInfo, WarehouseResult } from "@/app/api/warehouse";
@@ -85,13 +86,6 @@ function chunkList<T>(items: T[], size: number): T[][] {
type CreateDialogItemHit = { id: number; code: string; name: string };
function itemRecordsFromPage(res: unknown): { id: string | number; code?: string; name?: string }[] {
const records = (res as { records?: unknown[] })?.records;
return Array.isArray(records)
? (records as { id: string | number; code?: string; name?: string }[])
: [];
}
function withAllOption(options: string[]): string[] {
return options.length > 1 ? [LOCATION_ALL, ...options] : options;
}
@@ -351,15 +345,17 @@ 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 [openingCreateDialog, setOpeningCreateDialog] = useState(false);
const openCreateDialogLockRef = useRef(false);
const [createDialogItemCode, setCreateDialogItemCode] = useState("");
const [createDialogItemName, setCreateDialogItemName] = useState("");
/** 來源:All / PP / PF / other(同庫存批次現況報告) */
const [createDialogLotOrigin, setCreateDialogLotOrigin] = useState("All");
const [createDialogItemHits, setCreateDialogItemHits] = useState<CreateDialogItemHit[]>([]);
const [createDialogSelectedItems, setCreateDialogSelectedItems] = useState<CreateDialogItemHit[]>([]);
const [createDialogLotOptions, setCreateDialogLotOptions] = useState<StockTakeCreateLotOption[]>([]);
const [createDialogSelectedLotLineIds, setCreateDialogSelectedLotLineIds] = useState<number[]>([]);
const [createDialogLotsLoading, setCreateDialogLotsLoading] = useState(false);
const [createDialogItemSearching, setCreateDialogItemSearching] = useState(false);
const [sectionsLoading, setSectionsLoading] = useState(false);
const [stockTakeSectionRows, setStockTakeSectionRows] = useState<StockTakeSectionInfo[]>([]);
@@ -425,45 +421,18 @@ const PickerCardList: React.FC<PickerCardListProps> = ({
};
}, []);
const applyCreateModeBlocksFromRounds = useCallback(
(rounds: { open?: boolean; stockTakeRoundMode?: string | null }[]) => {
const openRounds = rounds || [];
const blockSection = openRounds.some(
(round) =>
round.open &&
String(round.stockTakeRoundMode ?? "").trim().toLowerCase() === "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");
},
[],
);
const openCreateDialog = useCallback(async () => {
if (openCreateDialogLockRef.current) return;
openCreateDialogLockRef.current = true;
setOpeningCreateDialog(true);
try {
const res = await getStockTakeRoundCards();
applyCreateModeBlocksFromRounds(res.rounds || []);
} catch (e) {
console.error("Failed to check open stock take create modes:", e);
setBlockSectionCreate(false);
setBlockItemCreate(false);
setCreateMode("section");
} finally {
setOpeningCreateDialog(false);
openCreateDialogLockRef.current = false;
setOpenConfirmDialog(true);
}
}, [applyCreateModeBlocksFromRounds ]);
}, []);
useEffect(() => {
if (autoOpenCreate) void openCreateDialog();
@@ -789,14 +758,14 @@ const PickerCardList: React.FC<PickerCardListProps> = ({
if (createDialogPlanStart == null || !createDialogPlanStart.isValid()) return;
if (createDialogPlanStart.startOf("day").isBefore(dayjs().startOf("day"))) return;
if (createMode === "item") {
if (createDialogSelectedItem s.length === 0) return;
if (createDialogSelectedLotLineId s.length === 0) return;
} else if (createDialogSelectedSections.length === 0) {
return;
}
setOpenCreateStockTakeSummaryConfirm(true);
}, [
createDialogPlanStart,
createDialogSelectedItem s.length,
createDialogSelectedLotLineId s.length,
createDialogSelectedSections.length,
createMode,
]);
@@ -1070,7 +1039,7 @@ const PickerCardList: React.FC<PickerCardListProps> = ({
const handleCreateStockTake = useCallback(async () => {
if (createStockTakeInFlightRef.current) return;
if (createMode === "item") {
if (createDialogSelectedItem s.length === 0) return;
if (createDialogSelectedLotLineId s.length === 0) return;
} else if (createDialogSelectedSections.length === 0) {
return;
}
@@ -1089,6 +1058,7 @@ const PickerCardList: React.FC<PickerCardListProps> = ({
createDialogSelectedItems.map((it) => it.id),
createDialogRoundName,
planStart,
createDialogSelectedLotLineIds,
)
: await createStockTakeForSections(
createDialogSelectedSections,
@@ -1112,7 +1082,9 @@ const PickerCardList: React.FC<PickerCardListProps> = ({
setCreateDialogRoundName("");
setCreateDialogPlanStart(dayjs());
setCreateDialogSelectedItems([]);
setCreateMode(blockSectionCreate && !blockItemCreate ? "item" : "section");
setCreateDialogLotOptions([]);
setCreateDialogSelectedLotLineIds([]);
setCreateMode("section");
} catch (e) {
console.error(e);
} finally {
@@ -1120,11 +1092,10 @@ const PickerCardList: React.FC<PickerCardListProps> = ({
createStockTakeInFlightRef.current = false;
}
}, [
blockItemCreate,
blockSectionCreate,
createDialogPlanStart,
createDialogRoundName,
createDialogSelectedItems,
createDialogSelectedLotLineIds,
createDialogSelectedSections,
createMode,
onListPageChange,
@@ -1135,25 +1106,30 @@ const PickerCardList: React.FC<PickerCardListProps> = ({
if (!openConfirmDialog || createMode !== "item") return;
const code = createDialogItemCode.trim();
const name = createDialogItemName.trim();
if (!code && !name) {
const origin = createDialogLotOrigin.trim();
const hasOrigin = origin !== "" && origin.toLowerCase() !== "all";
// 來源=全部:需編號或名稱;來源=PP/PF/其他:可只依來源列出
if (!code && !name && !hasOrigin) {
setCreateDialogItemHits([]);
return;
}
let cancelled = false;
const timer = window.setTimeout(() => {
setCreateDialogItemSearching(true);
const params: Record<string, string | number> = { pageSize: 300, pageNum: 1 };
if (code) params.code = code;
if (name) params.name = name;
fetchItemsByPage(params)
.then((page) => {
searchItemsForStockTakeCreate({
code,
name,
lotOrigin: createDialogLotOrigin,
})
.then((rows) => {
if (cancelled) return;
const uniq: CreateDialogItemHit[] = [];
itemRecordsFromPage(page).forEach((row) => {
const id = Number(row.id);
if (!id) return;
uniq.push({ id, code: row.code || "", name: row.name || "" });
});
const uniq: CreateDialogItemHit[] = (Array.isArray(rows) ? rows : [])
.map((row) => ({
id: Number(row.id),
code: row.code || "",
name: row.name || "",
}))
.filter((row) => row.id > 0);
uniq.sort((a, b) =>
a.code.localeCompare(b.code, undefined, { numeric: true, sensitivity: "base" }),
);
@@ -1171,7 +1147,56 @@ const PickerCardList: React.FC<PickerCardListProps> = ({
cancelled = true;
window.clearTimeout(timer);
};
}, [openConfirmDialog, createMode, createDialogItemCode, createDialogItemName]);
}, [
openConfirmDialog,
createMode,
createDialogItemCode,
createDialogItemName,
createDialogLotOrigin,
]);
useEffect(() => {
if (!openConfirmDialog || createMode !== "item") {
setCreateDialogLotOptions([]);
return;
}
const itemIds = createDialogSelectedItems.map((it) => it.id);
if (itemIds.length === 0) {
setCreateDialogLotOptions([]);
setCreateDialogSelectedLotLineIds([]);
return;
}
let cancelled = false;
setCreateDialogLotsLoading(true);
getAvailableLotsForStockTakeCreate(itemIds, createDialogLotOrigin)
.then((lots) => {
if (cancelled) return;
const list = Array.isArray(lots) ? lots : [];
setCreateDialogLotOptions(list);
setCreateDialogSelectedLotLineIds((prev) => {
const freeIds = list.filter((l) => !l.busy).map((l) => l.inventoryLotLineId);
const selectable = new Set(freeIds);
const kept = prev.filter((id) => selectable.has(id));
const keptSet = new Set(kept);
// 保留已選,並自動勾上新貨品的可盤 lot
return [...kept, ...freeIds.filter((id) => !keptSet.has(id))];
});
})
.catch((e) => {
console.error("Failed to load lots for stock take create:", e);
if (!cancelled) {
setCreateDialogLotOptions([]);
setCreateDialogSelectedLotLineIds([]);
}
})
.finally(() => {
if (!cancelled) setCreateDialogLotsLoading(false);
});
return () => {
cancelled = true;
};
}, [openConfirmDialog, createMode, createDialogSelectedItems, createDialogLotOrigin]);
useEffect(() => {
setSectionsLoading(true);
fetchStockTakeSections()
@@ -1285,11 +1310,14 @@ const PickerCardList: React.FC<PickerCardListProps> = ({
setOpenConfirmDialog(false);
setCreateDialogRoundName("");
setCreateDialogPlanStart(dayjs());
setCreateMode(blockSectionCreate && !blockItemCreate ? "item" : "section");
setCreateMode("section");
setCreateDialogItemCode("");
setCreateDialogItemName("");
setCreateDialogLotOrigin("All");
setCreateDialogItemHits([]);
setCreateDialogSelectedItems([]);
setCreateDialogLotOptions([]);
setCreateDialogSelectedLotLineIds([]);
onCreateDialogClose?.();
};
return (
@@ -1801,34 +1829,16 @@ 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 === "item" && blockItemCreate
? "section"
: createMode === "section" && blockSectionCreate
? "item"
: createMode
}
value={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")} />
)}
{blockItemCreate ? null : (
<Tab value="item" label={t("Create by item")} />
)}
<Tab value="section" label={t("Create by section")} />
<Tab value="item" label={t("Create by item")} />
</Tabs>
)}
</Box>
<DialogContent
dividers
@@ -1992,13 +2002,13 @@ const PickerCardList: React.FC<PickerCardListProps> = ({
</Stack>
)}
<Typography variant="body2" color="text.secondary">
{createMode === "item" ? t("Total selected item s label") : t("Total selected sections label")}{" "}
{createMode === "item" ? t("Total selected lot s label") : t("Total selected sections label")}{" "}
<Typography component="span" variant="body2" color="primary.main" fontWeight={700}>
{createMode === "item"
? createDialogSelectedItem s.length
? createDialogSelectedLotLineId s.length
: createDialogSelectedSections.length}
</Typography>{" "}
{createMode === "item" ? t("item s unit") : t("sections unit")}
{createMode === "item" ? t("lot s unit") : t("sections unit")}
</Typography>
</Box>
@@ -2050,9 +2060,23 @@ const PickerCardList: React.FC<PickerCardListProps> = ({
}}
sx={createDialogCompactInputSx}
/>
<FormControl size="small" sx={{ minWidth: { xs: "100%", sm: 140 } }}>
<InputLabel id="create-lot-origin-label">{t("Lot Origin")}</InputLabel>
<Select
labelId="create-lot-origin-label"
label={t("Lot Origin")}
value={createDialogLotOrigin}
onChange={(e) => setCreateDialogLotOrigin(String(e.target.value))}
>
<MenuItem value="All">{t("All")}</MenuItem>
<MenuItem value="PP">PP</MenuItem>
<MenuItem value="PF">PF</MenuItem>
<MenuItem value="other">{t("other")}</MenuItem>
</Select>
</FormControl>
</Stack>
<Typography variant="caption" color="text.secondary">
{t("Create by item selected count", { count: createDialogSelectedItems.length })}
{t("Create by item hint" )}
</Typography>
<Box sx={{ display: "flex", flexWrap: "wrap", gap: 0.75 }}>
{createDialogSelectedItems.map((it) => (
@@ -2066,54 +2090,152 @@ const PickerCardList: React.FC<PickerCardListProps> = ({
/>
))}
</Box>
<Box sx={{ flex: 1, minHeight: 0, overflowY: "auto" }}>
{createDialogItemSearching ? (
<Box sx={{ display: "flex", justifyContent: "center", py: 3 }}>
<CircularProgress size={24} />
</Box>
) : createDialogItemHits.length === 0 ? (
<Typography variant="body2" color="text.secondary" sx={{ py: 2 }}>
{createDialogItemCode.trim() || createDialogItemName.trim()
? t("No data")
: t("Create by item search hint")}
<Box
sx={{
flex: 1,
minHeight: 0,
overflowY: "auto",
display: "grid",
gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" },
gap: 1.5,
}}
>
<Box sx={{ minHeight: 0, overflowY: "auto", border: 1, borderColor: "divider", borderRadius: 1, p: 1 }}>
<Typography variant="caption" color="text.secondary" sx={{ mb: 0.5, display: "block" }}>
{t("Create by item search hint")}
</Typography>
) : (
<Stack>
{createDialogItemHits.map((it) => {
const checked = createDialogSelectedItems.some((x) => x.id === it.id);
return (
<Box
key={it.id}
sx={{
display: "flex",
alignItems: "center",
gap: 1,
py: 0.5,
borderBottom: 1,
borderColor: "divider",
}}
>
<Checkbox
checked={checked}
onChange={() => {
setCreateDialogSelectedItems((prev) =>
checked ? prev.filter((x) => x.id !== it.id) : [...prev, it],
);
{createDialogItemSearching ? (
<Box sx={{ display: "flex", justifyContent: "center", py: 3 }}>
<CircularProgress size={24} />
</Box>
) : createDialogItemHits.length === 0 ? (
<Typography variant="body2" color="text.secondary" sx={{ py: 2 }}>
{createDialogItemCode.trim() ||
createDialogItemName.trim() ||
(createDialogLotOrigin.trim() !== "" &&
createDialogLotOrigin.trim().toLowerCase() !== "all")
? t("No data")
: t("Create by item search hint")}
</Typography>
) : (
<Stack>
{createDialogItemHits.map((it) => {
const checked = createDialogSelectedItems.some((x) => x.id === it.id);
return (
<Box
key={it.id}
sx={{
display: "flex",
alignItems: "center",
gap: 1,
py: 0.5,
borderBottom: 1,
borderColor: "divider",
}}
/>
<Box>
<Typography variant="body2" fontWeight={600}>
{it.code}
</Typography>
<Typography variant="caption" color="text.secondary">
{it.name}
</Typography>
>
<Checkbox
checked={checked}
onChange={() => {
setCreateDialogSelectedItems((prev) =>
checked ? prev.filter((x) => x.id !== it.id) : [...prev, it],
);
}}
/>
<Box>
<Typography variant="body2" fontWeight={600}>
{it.code}
</Typography>
<Typography variant="caption" color="text.secondary">
{it.name}
</Typography>
</Box>
</Box>
</Box>
);
})}
);
})}
</Stack>
)}
</Box>
<Box sx={{ minHeight: 0, overflowY: "auto", border: 1, borderColor: "divider", borderRadius: 1, p: 1 }}>
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 0.5 }}>
<Typography variant="caption" color="text.secondary">
{t("Create by item lot detail", {
count: createDialogSelectedLotLineIds.length,
})}
</Typography>
<Button
size="small"
disabled={createDialogLotOptions.every((l) => l.busy)}
onClick={() =>
setCreateDialogSelectedLotLineIds(
createDialogLotOptions.filter((l) => !l.busy).map((l) => l.inventoryLotLineId),
)
}
>
{t("Select all free lots")}
</Button>
</Stack>
)}
{createDialogLotsLoading ? (
<Box sx={{ display: "flex", justifyContent: "center", py: 3 }}>
<CircularProgress size={24} />
</Box>
) : createDialogSelectedItems.length === 0 ? (
<Typography variant="body2" color="text.secondary" sx={{ py: 2 }}>
{t("Create by item select item first")}
</Typography>
) : createDialogLotOptions.length === 0 ? (
<Typography variant="body2" color="text.secondary" sx={{ py: 2 }}>
{t("No available lots for selected items")}
</Typography>
) : (
<Stack>
{createDialogLotOptions.map((lot) => {
const checked = createDialogSelectedLotLineIds.includes(lot.inventoryLotLineId);
return (
<Box
key={lot.inventoryLotLineId}
sx={{
display: "flex",
alignItems: "flex-start",
gap: 1,
py: 0.75,
borderBottom: 1,
borderColor: "divider",
opacity: lot.busy ? 0.55 : 1,
}}
>
<Checkbox
size="small"
disabled={lot.busy}
checked={checked && !lot.busy}
onChange={() => {
if (lot.busy) return;
setCreateDialogSelectedLotLineIds((prev) =>
checked
? prev.filter((id) => id !== lot.inventoryLotLineId)
: [...prev, lot.inventoryLotLineId],
);
}}
/>
<Box sx={{ minWidth: 0 }}>
<Typography variant="body2" fontWeight={600}>
{lot.itemCode} · {lot.lotNo || "-"}
</Typography>
<Typography variant="caption" color="text.secondary" display="block">
{lot.stockTakeSection || "-"}
{lot.warehouseCode ? ` · ${lot.warehouseCode}` : ""}
</Typography>
{lot.busy ? (
<Typography variant="caption" color="warning.main">
{t("Lot busy in open stock take")}
</Typography>
) : null}
</Box>
</Box>
);
})}
</Stack>
)}
</Box>
</Box>
</Stack>
) : activeCreateFloorKey != null ? (
@@ -2327,7 +2449,7 @@ const PickerCardList: React.FC<PickerCardListProps> = ({
disabled={
creating ||
(createMode === "item"
? createDialogSelectedItem s.length === 0
? createDialogSelectedLotLineId s.length === 0
: createDialogSelectedSections.length === 0) ||
createDialogPlanStart == null ||
!createDialogPlanStart.isValid() ||
@@ -2365,13 +2487,13 @@ const PickerCardList: React.FC<PickerCardListProps> = ({
: "-"}
</Typography>
<Typography variant="body2" fontWeight={600}>
{createMode === "item" ? t("Total selected item s label") : t("Total selected sections label")}{" "}
<Typography component="span"fontWeight={600} >
{createMode === "item" ? t("Total selected lot s label") : t("Total selected sections label")}{" "}
<Typography component="span" fontWeight={600}>
{createMode === "item"
? createDialogSelectedItem s.length
? createDialogSelectedLotLineId s.length
: createStockTakeSummarySections.length}
</Typography>{" "}
{createMode === "item" ? t("item s unit") : t("sections unit")}
{createMode === "item" ? t("lot s unit") : t("sections unit")}
</Typography>
</Stack>
<Box
@@ -2386,16 +2508,19 @@ const PickerCardList: React.FC<PickerCardListProps> = ({
>
<Stack spacing={1}>
{createMode === "item"
? createDialogSelectedItems.map((it) => (
<Box key={it.id}>
<Typography variant="body2" fontWeight={600}>
{it.code}
</Typography>
<Typography variant="caption" color="text.secondary">
{it.name}
</Typography>
</Box>
))
? createDialogLotOptions
.filter((lot) => createDialogSelectedLotLineIds.includes(lot.inventoryLotLineId))
.map((lot) => (
<Box key={lot.inventoryLotLineId}>
<Typography variant="body2" fontWeight={600}>
{lot.itemCode} · {lot.lotNo || "-"}
</Typography>
<Typography variant="caption" color="text.secondary">
{lot.stockTakeSection || "-"}
{lot.warehouseCode ? ` · ${lot.warehouseCode}` : ""}
</Typography>
</Box>
))
: createStockTakeSummarySections.map((section) => {
const meta = getCreateDialogSectionMeta(section);
return (
@@ -2428,7 +2553,7 @@ const PickerCardList: React.FC<PickerCardListProps> = ({
disabled={
creating ||
(createMode === "item"
? createDialogSelectedItem s.length === 0
? createDialogSelectedLotLineId s.length === 0
: createDialogSelectedSections.length === 0)
}
>