From 791f726363b675c3011beebf51c7c7833154b0a7 Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Thu, 8 Oct 2026 11:13:02 +0800 Subject: [PATCH] update muti round & create round by item --- src/app/api/stockTake/actions.ts | 119 ++++- .../ApproverStockTakeAll.tsx | 33 +- .../StockTakeManagement/PickerCardList.tsx | 480 +++++++++++++++--- .../StockTakeManagement/PickerReStockTake.tsx | 60 ++- .../StockTakeManagement/PickerStockTake.tsx | 59 ++- .../StockTakeRoundCardList.tsx | 293 +++++++++++ .../StockTakeManagement/StockTakeTab.tsx | 420 ++++++++++----- src/i18n/en/stockTake.json | 24 + src/i18n/zh/stockTake.json | 24 + 9 files changed, 1253 insertions(+), 259 deletions(-) create mode 100644 src/components/StockTakeManagement/StockTakeRoundCardList.tsx diff --git a/src/app/api/stockTake/actions.ts b/src/app/api/stockTake/actions.ts index 53d567b9..6d7e4209 100644 --- a/src/app/api/stockTake/actions.ts +++ b/src/app/api/stockTake/actions.ts @@ -117,6 +117,27 @@ export const getInventoryLotDetailsBySection = async ( //console.log('[API] Response received:', response); return response; } + +export const getInventoryLotDetailsByItem = async ( + itemId: number, + stockTakeRoundId: number, + pageNum: number = 0, + pageSize: number = 10, + notMatchOnly: boolean = false, +) => { + const params = new URLSearchParams({ + itemId: String(itemId), + stockTakeRoundId: String(stockTakeRoundId), + pageNum: String(pageNum), + pageSize: String(pageSize), + notMatchOnly: String(notMatchOnly), + }); + return serverFetchJson>( + `${BASE_API_URL}/stockTakeRecord/inventoryLotDetailsByItem?${params.toString()}`, + { method: "GET" }, + ); +}; + export interface SaveStockTakeRecordRequest { stockTakeRecordId?: number | null; inventoryLotLineId: number; @@ -144,6 +165,8 @@ export interface AllPickedStockTakeListReponse { warehouseArea: string | null; storeId: string | null; reStockTakeTrueFalse: boolean; + listMode?: string; + itemId?: number | null; } /** 與 Picker 列表一致:區域描述、盤點區域、貨品編號/名稱(逗號多關鍵字由後端解析) */ @@ -279,6 +302,7 @@ export const getStockTakeRecordsPaged = async ( area?: string; storeId?: string; onlyLatestRound?: boolean; + stockTakeRoundId?: number | null; } ) => { const searchParams = new URLSearchParams(); @@ -302,6 +326,9 @@ export const getStockTakeRecordsPaged = async ( if (params?.onlyLatestRound != null) { searchParams.set("onlyLatestRound", String(params.onlyLatestRound)); } + if (params?.stockTakeRoundId != null && params.stockTakeRoundId > 0) { + searchParams.set("stockTakeRoundId", String(params.stockTakeRoundId)); + } const url = `${BASE_API_URL}/stockTakeRecord/AllPickedStockOutRecordList?${searchParams.toString()}`; const res = await serverFetchJson>(url, { method: "GET" }); return res; @@ -315,13 +342,72 @@ export const getApproverStockTakeRecords = async () => { ); return stockTakeRecords; } -export const getLatestApproverStockTakeHeader = async () => { +export const getLatestApproverStockTakeHeader = async ( + stockTakeRoundId?: number | null +) => { + const params = new URLSearchParams(); + if (stockTakeRoundId != null && stockTakeRoundId > 0) { + params.set("stockTakeRoundId", String(stockTakeRoundId)); + } + const qs = params.toString(); return serverFetchJson( - `${BASE_API_URL}/stockTakeRecord/LatestApproverStockTakeHeader`, + `${BASE_API_URL}/stockTakeRecord/LatestApproverStockTakeHeader${qs ? `?${qs}` : ""}`, { method: "GET" } ); } +export type StockTakeRoundCard = { + stockTakeRoundId: number; + stockTakeRoundName: string | null; + stockTakeId: number; + planStartDate: string | null; + yearMonth: string; + open: boolean; + status: string; + sectionCount: number; + totalItemNumber: number; + totalInventoryLotNumber: number; + stockTakenCount: number; + approvedCount: number; + mainFloor: string | null; + reStockTakeTrueFalse: boolean; + stockTakeRoundMode?: string | null; +}; + +export type StockTakeRoundCardsRes = { + hasOpenRounds: boolean; + rounds: StockTakeRoundCard[]; + completedYearMonths: string[]; +}; + +export const getStockTakeRoundCards = async (yearMonth?: string | null) => { + const params = new URLSearchParams(); + if (yearMonth?.trim()) params.set("yearMonth", yearMonth.trim()); + const qs = params.toString(); + return serverFetchJson( + `${BASE_API_URL}/stockTakeRecord/stockTakeRoundCards${qs ? `?${qs}` : ""}`, + { method: "GET" }, + ); +}; + +export const getBusyStockTakeSections = async () => { + return serverFetchJson( + `${BASE_API_URL}/stockTakeRecord/busyStockTakeSections`, + { method: "GET" }, + ); +}; + +export const closeStockTakeRound = async (stockTakeRoundId: number) => { + return serverFetchJson<{ success: boolean; message?: string; closedCount?: number }>( + `${BASE_API_URL}/stockTakeRecord/closeRound`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ stockTakeRoundId }), + }, + ); +}; + export type LatestStockTakeRoundMeta = { stockTakeRoundId: number | null; planStartDate: string | null; @@ -333,6 +419,29 @@ export const getLatestStockTakeRoundMeta = async () => { { method: "GET" }, ); }; +export const createStockTakeForItems = async ( + itemIds: number[], + stockTakeRoundName?: string | null, + planStart?: string | null, +) => { + const trimmedName = stockTakeRoundName?.trim(); + const trimmedPlanStart = planStart?.trim(); + return serverFetchJson>( + `${BASE_API_URL}/stockTake/createForItems`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + itemIds, + ...(trimmedName ? { stockTakeRoundName: trimmedName } : {}), + ...(trimmedPlanStart ? { planStart: trimmedPlanStart } : {}), + }), + }, + ); +}; + export const createStockTakeForSections = async ( sections: string[], stockTakeRoundName?: string | null, @@ -373,9 +482,9 @@ export const saveStockTakeRecord = async ( body: JSON.stringify(request), }, ); - console.log('saveStockTakeRecord: request:', request); - console.log('saveStockTakeRecord: stockTakeId:', stockTakeId); - console.log('saveStockTakeRecord: stockTakerId:', stockTakerId); + // console.log('saveStockTakeRecord: request:', request); + //console.log('saveStockTakeRecord: stockTakeId:', stockTakeId); + //console.log('saveStockTakeRecord: stockTakerId:', stockTakerId); return result; } catch (error: any) { diff --git a/src/components/StockTakeManagement/ApproverStockTakeAll.tsx b/src/components/StockTakeManagement/ApproverStockTakeAll.tsx index 18592b85..ddb927ad 100644 --- a/src/components/StockTakeManagement/ApproverStockTakeAll.tsx +++ b/src/components/StockTakeManagement/ApproverStockTakeAll.tsx @@ -37,6 +37,7 @@ import { DialogActions, } from "@mui/material"; import { useState, useCallback, useEffect, useMemo, useRef } from "react"; +import { createPortal } from "react-dom"; import { Collapse } from "@mui/material"; import Accordion from "@mui/material/Accordion"; import AccordionSummary from "@mui/material/AccordionSummary"; @@ -75,6 +76,8 @@ interface ApproverStockTakeAllProps { mode: "pending" | "approved"; onBack?: () => void; onSnackbar: (message: string, severity: "success" | "error" | "warning") => void; + /** 審核待處理時,把批量保存按鈕掛到分頁列右側 */ + batchButtonHost?: HTMLElement | null; } type QtySelectionType = "first" | "second" | "approver"; @@ -365,6 +368,7 @@ const ApproverStockTakeAll: React.FC = ({ mode, onBack, onSnackbar, + batchButtonHost = null, }) => { const { t } = useTranslation(["stockTake", "common"]); const qtyGapWarnPercent = useStockTakeQtyGapWarnPercent(); @@ -1623,8 +1627,21 @@ const ApproverStockTakeAll: React.FC = ({ const effectivePageSize = pageSize === "all" ? Math.max(total || 0, 0) : (pageSize as number); + const batchSaveButton = mode === "pending" ? ( + + ) : null; + const batchButtonInTabBar = Boolean(batchButtonHost && batchSaveButton); + return ( + {batchButtonInTabBar ? createPortal(batchSaveButton, batchButtonHost as HTMLElement) : null} {onBack && ( )} + {(uniqueWarehouses || (batchSaveButton && !batchButtonInTabBar)) && ( - - + {uniqueWarehouses && ( <> {t("Warehouse")}: {uniqueWarehouses} )} - {mode === "pending" && ( - - )} + {!batchButtonInTabBar ? batchSaveButton : null} + )} setShowFilters(expanded)} diff --git a/src/components/StockTakeManagement/PickerCardList.tsx b/src/components/StockTakeManagement/PickerCardList.tsx index 46844717..17c13122 100644 --- a/src/components/StockTakeManagement/PickerCardList.tsx +++ b/src/components/StockTakeManagement/PickerCardList.tsx @@ -31,6 +31,7 @@ import { Tabs, ToggleButton, ToggleButtonGroup, + Checkbox, } from "@mui/material"; import SearchIcon from "@mui/icons-material/Search"; import NotificationsNoneOutlinedIcon from "@mui/icons-material/NotificationsNoneOutlined"; @@ -42,10 +43,14 @@ import { useTranslation } from "react-i18next"; import duration from "dayjs/plugin/duration"; import { AllPickedStockTakeListReponse, + createStockTakeForItems, createStockTakeForSections, getStockTakeRecordsPaged, getLatestStockTakeRoundMeta, + getBusyStockTakeSections, + getStockTakeRoundCards, } 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"; @@ -74,6 +79,15 @@ function chunkList(items: T[], size: number): T[][] { return rows; } +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; } @@ -101,10 +115,14 @@ function matchesPickerCardFilters( } if (filters.status && filters.status !== "All") { const normalized = filters.status.trim().toLowerCase(); - const accepted = normalized === "stocktaking" - ? ["stocktaking", "processing", "in_progress"] - : [normalized]; - if (!accepted.includes((session.status || "").trim().toLowerCase())) return false; + if (normalized === "notmatch") { + if (!session.reStockTakeTrueFalse) return false; + } else { + const accepted = normalized === "stocktaking" + ? ["stocktaking", "processing", "in_progress"] + : [normalized]; + if (!accepted.includes((session.status || "").trim().toLowerCase())) return false; + } } const area = filters.area?.trim() ?? ""; if (area && !(session.warehouseArea || "").toLowerCase().includes(area.toLowerCase())) return false; @@ -200,6 +218,11 @@ interface PickerCardListProps { /** 樓層/倉庫/倉位,由父層保存,從明細返回時仍顯示 */ locationPick: PickerLocationPick; onLocationPickChange: (next: PickerLocationPick) => void; + stockTakeRoundId?: number | null; + hideSectionList?: boolean; + autoOpenCreate?: boolean; + onCreateDialogClose?: () => void; + onRoundsChanged?: () => void; } export type PickerSearchKind = "location" | "section"; @@ -284,6 +307,11 @@ const PickerCardList: React.FC = ({ onAppliedFiltersChange, locationPick, onLocationPickChange, + stockTakeRoundId = null, + hideSectionList = false, + autoOpenCreate = false, + onCreateDialogClose, + onRoundsChanged, }) => { const { t } = useTranslation(["stockTake", "common"]); const router = useRouter(); @@ -300,6 +328,7 @@ const PickerCardList: React.FC = ({ const [globalRoundPlanStartDate, setGlobalRoundPlanStartDate] = useState(null); const [creating, setCreating] = useState(false); const [openConfirmDialog, setOpenConfirmDialog] = useState(false); + const [busySections, setBusySections] = useState>(() => new Set()); const [openCreateStockTakeSummaryConfirm, setOpenCreateStockTakeSummaryConfirm] = useState(false); const [missingSectionWarnDrawerOpen, setMissingSectionWarnDrawerOpen] = useState(false); const [missingSectionCount, setMissingSectionCount] = useState(0); @@ -312,6 +341,13 @@ const PickerCardList: React.FC = ({ /** 建立盤點對話框:目前選中的樓層分頁(對應 sortedFloorKeys 索引) */ const [createFloorTabIndex, setCreateFloorTabIndex] = useState(0); const [createDialogSearchQuery, setCreateDialogSearchQuery] = useState(""); + const [createMode, setCreateMode] = useState<"section" | "item">("section"); + const [blockSectionCreate, setBlockSectionCreate] = useState(false); + const [createDialogItemCode, setCreateDialogItemCode] = useState(""); + const [createDialogItemName, setCreateDialogItemName] = useState(""); + const [createDialogItemHits, setCreateDialogItemHits] = useState([]); + const [createDialogSelectedItems, setCreateDialogSelectedItems] = useState([]); + const [createDialogItemSearching, setCreateDialogItemSearching] = useState(false); const [sectionsLoading, setSectionsLoading] = useState(false); const [stockTakeSectionRows, setStockTakeSectionRows] = useState([]); const createStockTakeInFlightRef = useRef(false); @@ -341,7 +377,7 @@ const PickerCardList: React.FC = ({ : null, ); - const statusOptions = ["pending", "stockTaking", "approving", "completed"]; + const statusOptions = ["All", "pending", "stockTaking", "approving", "completed", "notMatch"]; const searchKind = searchFilters.searchKind ?? "location"; @@ -377,14 +413,24 @@ const PickerCardList: React.FC = ({ }, []); useEffect(() => { + if (autoOpenCreate) setOpenConfirmDialog(true); + }, [autoOpenCreate]); + + useEffect(() => { + if (hideSectionList) { + setLoading(false); + return; + } let cancelled = false; + setLoading(true); getStockTakeRecordsPaged(0, 10000, { sectionDescription: "All", stockTakeSections: "", status: "All", area: "", storeId: "All", - onlyLatestRound: true, + onlyLatestRound: !(stockTakeRoundId != null && stockTakeRoundId > 0), + stockTakeRoundId: stockTakeRoundId != null && stockTakeRoundId > 0 ? stockTakeRoundId : undefined, }) .then((res) => { if (cancelled) return; @@ -400,7 +446,7 @@ const PickerCardList: React.FC = ({ return () => { cancelled = true; }; - }, [listRefreshNonce]); + }, [hideSectionList, listRefreshNonce, stockTakeRoundId]); const roundSectionCodes = useMemo( () => @@ -649,6 +695,44 @@ const PickerCardList: React.FC = ({ void loadMissingStockTakeSectionIssues(); }, [openConfirmDialog, loadMissingStockTakeSectionIssues]); + useEffect(() => { + if (!openConfirmDialog) return; + let cancelled = false; + getBusyStockTakeSections() + .then((list) => { + if (cancelled) return; + setBusySections(new Set((Array.isArray(list) ? list : []).map((s) => s.trim()).filter(Boolean))); + }) + .catch((e) => { + console.error("Failed to load busy stock take sections:", e); + if (!cancelled) setBusySections(new Set()); + }); + return () => { + cancelled = true; + }; + }, [openConfirmDialog]); + + useEffect(() => { + if (!openConfirmDialog) return; + let cancelled = false; + getStockTakeRoundCards() + .then((res) => { + if (cancelled) return; + const blocked = (res.rounds || []).some( + (round) => round.open && round.stockTakeRoundMode === "item", + ); + setBlockSectionCreate(blocked); + if (blocked) setCreateMode("item"); + }) + .catch((e) => { + console.error("Failed to check open item stock take rounds:", e); + if (!cancelled) setBlockSectionCreate(false); + }); + return () => { + cancelled = true; + }; + }, [openConfirmDialog]); + const handleGoWarehouseSettings = useCallback(() => { setMissingSectionWarnDrawerOpen(false); setOpenConfirmDialog(false); @@ -660,10 +744,19 @@ const PickerCardList: React.FC = ({ }, [createDialogSelectedSections]); const handleOpenCreateStockTakeSummaryConfirm = useCallback(() => { - if (createDialogSelectedSections.length === 0) return; if (createDialogPlanStart == null || !createDialogPlanStart.isValid()) return; + if (createMode === "item") { + if (createDialogSelectedItems.length === 0) return; + } else if (createDialogSelectedSections.length === 0) { + return; + } setOpenCreateStockTakeSummaryConfirm(true); - }, [createDialogSelectedSections.length, createDialogPlanStart]); + }, [ + createDialogPlanStart, + createDialogSelectedItems.length, + createDialogSelectedSections.length, + createMode, + ]); /** 盤點區域 → 樓層:優先 API(warehouse 帶 storeId),否則用列表卡片上的 storeId */ const sectionsByStore = useMemo(() => { @@ -715,11 +808,18 @@ const PickerCardList: React.FC = ({ return keys; }, [sectionsByStore]); + const isSectionBusy = useCallback( + (section: string) => busySections.has(section.trim()), + [busySections], + ); + const allSectionsForCreateDialog = useMemo(() => { const s = new Set(); sectionsByStore.forEach((arr) => arr.forEach((x) => s.add(x))); - return Array.from(s).sort((a, b) => a.localeCompare(b)); - }, [sectionsByStore]); + return Array.from(s) + .filter((sec) => !busySections.has(sec.trim())) + .sort((a, b) => a.localeCompare(b)); + }, [busySections, sectionsByStore]); useEffect(() => { if (createFloorTabIndex >= sortedFloorKeys.length && sortedFloorKeys.length > 0) { @@ -734,7 +834,7 @@ const PickerCardList: React.FC = ({ const toggleFloorSelectAll = useCallback( (storeKey: string) => { - const list = sectionsByStore.get(storeKey) ?? []; + const list = (sectionsByStore.get(storeKey) ?? []).filter((s) => !busySections.has(s.trim())); setCreateDialogSelectedSections((prev) => { const allIn = list.length > 0 && list.every((s) => prev.includes(s)); if (allIn) { @@ -745,7 +845,7 @@ const PickerCardList: React.FC = ({ return Array.from(next).sort((a, b) => a.localeCompare(b)); }); }, - [sectionsByStore], + [busySections, sectionsByStore], ); /** 與卡片標題括號內相同:描述 / 區域 / 樓層 */ @@ -842,21 +942,27 @@ const PickerCardList: React.FC = ({ drag.sectionIds, ); const merged = new Set(drag.baseSelection); - hit.forEach((id) => merged.add(id)); + hit.forEach((id) => { + if (!busySections.has(id.trim())) merged.add(id); + }); setCreateDialogSelectedSections( Array.from(merged).sort((a, b) => a.localeCompare(b)), ); } else if (drag?.pointerDownSection) { const sec = drag.pointerDownSection; - setCreateDialogSelectedSections((prev) => { - if (prev.includes(sec)) return prev.filter((s) => s !== sec); - return [...prev, sec].sort((a, b) => a.localeCompare(b)); - }); + if (busySections.has(sec.trim())) { + /* in progress */ + } else { + setCreateDialogSelectedSections((prev) => { + if (prev.includes(sec)) return prev.filter((s) => s !== sec); + return [...prev, sec].sort((a, b) => a.localeCompare(b)); + }); + } } marqueeDragRef.current = null; setMarqueeRect(null); setMarqueePreviewIds(new Set()); - }, [getSectionIdsInMarquee]); + }, [busySections, getSectionIdsInMarquee]); const handleCreateSectionGridPointerDown = useCallback( (e: React.PointerEvent) => { @@ -920,7 +1026,11 @@ const PickerCardList: React.FC = ({ const handleCreateStockTake = useCallback(async () => { if (createStockTakeInFlightRef.current) return; - if (createDialogSelectedSections.length === 0) return; + if (createMode === "item") { + if (createDialogSelectedItems.length === 0) return; + } else if (createDialogSelectedSections.length === 0) { + return; + } createStockTakeInFlightRef.current = true; setOpenCreateStockTakeSummaryConfirm(false); setOpenConfirmDialog(false); @@ -930,11 +1040,18 @@ const PickerCardList: React.FC = ({ createDialogPlanStart != null ? dayjsToDateTimeString(createDialogPlanStart.startOf("day")) : dayjsToDateTimeString(dayjs().startOf("day")); - const result = await createStockTakeForSections( - createDialogSelectedSections, - createDialogRoundName, - planStart, - ); + const result = + createMode === "item" + ? await createStockTakeForItems( + createDialogSelectedItems.map((it) => it.id), + createDialogRoundName, + planStart, + ) + : await createStockTakeForSections( + createDialogSelectedSections, + createDialogRoundName, + planStart, + ); const createdCount = Object.values(result).filter((msg) => msg.startsWith("Created:")).length; const skippedCount = Object.values(result).filter((msg) => msg.startsWith("Skipped:")).length; const errorCount = Object.values(result).filter((msg) => msg.startsWith("Error:")).length; @@ -948,15 +1065,68 @@ const PickerCardList: React.FC = ({ onListPageChange(0); setListRefreshNonce((n) => n + 1); + onRoundsChanged?.(); setCreateDialogRoundName(""); setCreateDialogPlanStart(dayjs()); + setCreateDialogSelectedItems([]); + setCreateMode("section"); } catch (e) { console.error(e); } finally { setCreating(false); createStockTakeInFlightRef.current = false; } - }, [createDialogPlanStart, createDialogRoundName, createDialogSelectedSections, onListPageChange, t]); + }, [ + createDialogPlanStart, + createDialogRoundName, + createDialogSelectedItems, + createDialogSelectedSections, + createMode, + onListPageChange, + onRoundsChanged, + t, + ]); + useEffect(() => { + if (!openConfirmDialog || createMode !== "item") return; + const code = createDialogItemCode.trim(); + const name = createDialogItemName.trim(); + if (!code && !name) { + setCreateDialogItemHits([]); + return; + } + let cancelled = false; + const timer = window.setTimeout(() => { + setCreateDialogItemSearching(true); + const params: Record = { pageSize: 300, pageNum: 1 }; + if (code) params.code = code; + if (name) params.name = name; + fetchItemsByPage(params) + .then((page) => { + 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 || "" }); + }); + uniq.sort((a, b) => + a.code.localeCompare(b.code, undefined, { numeric: true, sensitivity: "base" }), + ); + setCreateDialogItemHits(uniq); + }) + .catch((e) => { + console.error("Failed to search items for stock take create:", e); + if (!cancelled) setCreateDialogItemHits([]); + }) + .finally(() => { + if (!cancelled) setCreateDialogItemSearching(false); + }); + }, 300); + return () => { + cancelled = true; + window.clearTimeout(timer); + }; + }, [openConfirmDialog, createMode, createDialogItemCode, createDialogItemName]); useEffect(() => { setSectionsLoading(true); fetchStockTakeSections() @@ -1060,8 +1230,21 @@ const PickerCardList: React.FC = ({ return Math.round((session.currentStockTakeItemNumber / session.totalInventoryLotNumber) * 100); }; const planStartDate = globalRoundPlanStartDate; + const closeCreateDialog = () => { + setOpenConfirmDialog(false); + setCreateDialogRoundName(""); + setCreateDialogPlanStart(dayjs()); + setCreateMode(blockSectionCreate ? "item" : "section"); + setCreateDialogItemCode(""); + setCreateDialogItemName(""); + setCreateDialogItemHits([]); + setCreateDialogSelectedItems([]); + onCreateDialogClose?.(); + }; return ( + {!hideSectionList && ( + <> @@ -1173,27 +1356,26 @@ const PickerCardList: React.FC = ({ )} - - - {t("Status")} - - + + {searchKind === "location" && ( @@ -1367,12 +1549,13 @@ const PickerCardList: React.FC = ({ - {t("Total Sections")}: {total} + {roundRecords.some((s) => s.listMode === "item") ? t("Total Items") : t("Total Sections")}: {total} {t("Start Stock Take Date")}: {planStartDate || "-"} + {stockTakeRoundId != null && stockTakeRoundId > 0 ? null : ( + )} {loading ? ( @@ -1415,8 +1599,11 @@ const PickerCardList: React.FC = ({ - {t("Section")}: {session.stockTakeSession} - {sectionMeta ? ` (${sectionMeta})` : null} + {session.listMode === "item" + ? `${t("Item")}: ${session.stockTakeSession}${ + session.stockTakeSectionDescription ? ` (${session.stockTakeSectionDescription})` : "" + }` + : `${t("Section")}: ${session.stockTakeSession}${sectionMeta ? ` (${sectionMeta})` : ""}`} @@ -1455,7 +1642,12 @@ const PickerCardList: React.FC = ({ {t("View ReStockTake")} - + + {session.reStockTakeTrueFalse ? ( + + ) : null} + + @@ -1475,15 +1667,13 @@ const PickerCardList: React.FC = ({ }} rowsPerPageOptions={[pageSize]} // 如果暂时不让用户改 pageSize,就写死 /> + )} + )} {/* Create Stock Take 確認 Dialog */} { - setOpenConfirmDialog(false); - setCreateDialogRoundName(""); - setCreateDialogPlanStart(dayjs()); - }} + onClose={closeCreateDialog} maxWidth={false} fullWidth PaperProps={{ @@ -1536,6 +1726,20 @@ const PickerCardList: React.FC = ({ + + { + if (!v) return; + setCreateMode(v); + }} + > + {blockSectionCreate ? null : ( + + )} + + + = ({ /> - {sortedFloorKeys.length === 0 && !sectionsLoading ? ( + {createMode === "item" ? ( + + {t("Create by item hint")} + + ) : sortedFloorKeys.length === 0 && !sectionsLoading ? ( {t("No stock take sections from warehouse")} @@ -1671,6 +1879,7 @@ const PickerCardList: React.FC = ({ })} )} + {createMode === "item" ? null : ( + )} - {t("Total selected sections label")}{" "} + {createMode === "item" ? t("Total selected items label") : t("Total selected sections label")}{" "} - {createDialogSelectedSections.length} + {createMode === "item" + ? createDialogSelectedItems.length + : createDialogSelectedSections.length} {" "} - {t("sections unit")} + {createMode === "item" ? t("items unit") : t("sections unit")} @@ -1712,7 +1924,108 @@ const PickerCardList: React.FC = ({ overflow: "hidden", }} > - {activeCreateFloorKey != null ? ( + {createMode === "item" ? ( + + + setCreateDialogItemCode(e.target.value)} + InputProps={{ + startAdornment: ( + + + + ), + }} + sx={createDialogCompactInputSx} + /> + setCreateDialogItemName(e.target.value)} + InputProps={{ + startAdornment: ( + + + + ), + }} + sx={createDialogCompactInputSx} + /> + + + {t("Create by item selected count", { count: createDialogSelectedItems.length })} + + + {createDialogSelectedItems.map((it) => ( + + setCreateDialogSelectedItems((prev) => prev.filter((x) => x.id !== it.id)) + } + /> + ))} + + + {createDialogItemSearching ? ( + + + + ) : createDialogItemHits.length === 0 ? ( + + {createDialogItemCode.trim() || createDialogItemName.trim() + ? t("No data") + : t("Create by item search hint")} + + ) : ( + + {createDialogItemHits.map((it) => { + const checked = createDialogSelectedItems.some((x) => x.id === it.id); + return ( + + { + setCreateDialogSelectedItems((prev) => + checked ? prev.filter((x) => x.id !== it.id) : [...prev, it], + ); + }} + /> + + + {it.code} + + + {it.name} + + + + ); + })} + + )} + + + ) : activeCreateFloorKey != null ? ( <> = ({ > {createDialogFilteredSections.map((section) => { const meta = getCreateDialogSectionMeta(section); + const busy = isSectionBusy(section); const checked = createDialogSelectedSections.includes(section); - const previewSelected = marqueePreviewIds.has(section); - const visuallySelected = checked || previewSelected; + const previewSelected = !busy && marqueePreviewIds.has(section); + const visuallySelected = !busy && (checked || previewSelected); return ( = ({ } }} sx={{ - cursor: "default", + cursor: busy ? "not-allowed" : "default", + opacity: busy ? 0.55 : 1, borderWidth: visuallySelected ? 2 : 1, borderColor: visuallySelected ? "primary.main" : "divider", bgcolor: visuallySelected @@ -1864,6 +2179,11 @@ const PickerCardList: React.FC = ({ {section} + {busy ? ( + + {t("In progress")} + + ) : null} {meta ? ( = ({ @@ -1919,7 +2235,9 @@ const PickerCardList: React.FC = ({ onClick={handleOpenCreateStockTakeSummaryConfirm} disabled={ creating || - createDialogSelectedSections.length === 0 || + (createMode === "item" + ? createDialogSelectedItems.length === 0 + : createDialogSelectedSections.length === 0) || createDialogPlanStart == null || !createDialogPlanStart.isValid() } @@ -1955,11 +2273,13 @@ const PickerCardList: React.FC = ({ : "-"} - {t("Total selected sections label")}{" "} + {createMode === "item" ? t("Total selected items label") : t("Total selected sections label")}{" "} - {createStockTakeSummarySections.length} + {createMode === "item" + ? createDialogSelectedItems.length + : createStockTakeSummarySections.length} {" "} - {t("sections unit")} + {createMode === "item" ? t("items unit") : t("sections unit")} = ({ }} > - {createStockTakeSummarySections.map((section) => { + {createMode === "item" + ? createDialogSelectedItems.map((it) => ( + + + {it.code} + + + {it.name} + + + )) + : createStockTakeSummarySections.map((section) => { const meta = getCreateDialogSectionMeta(section); return ( @@ -2002,7 +2333,12 @@ const PickerCardList: React.FC = ({ variant="contained" color="primary" onClick={handleCreateStockTake} - disabled={creating || createDialogSelectedSections.length === 0} + disabled={ + creating || + (createMode === "item" + ? createDialogSelectedItems.length === 0 + : createDialogSelectedSections.length === 0) + } > {creating ? : t("Confirm")} diff --git a/src/components/StockTakeManagement/PickerReStockTake.tsx b/src/components/StockTakeManagement/PickerReStockTake.tsx index 925b1bbb..00eb21db 100644 --- a/src/components/StockTakeManagement/PickerReStockTake.tsx +++ b/src/components/StockTakeManagement/PickerReStockTake.tsx @@ -27,6 +27,7 @@ import { BatchSaveStockTakeRecordRequest, batchSaveStockTakeRecords, batchSavePickerStockTakeInputs, + getInventoryLotDetailsByItem, getInventoryLotDetailsBySectionNotMatch } from "@/app/api/stockTake/actions"; import { buildPickerBatchSaveRequests } from "./buildPickerBatchSaveRequests"; @@ -117,15 +118,26 @@ const PickerReStockTake: React.FC = ({ actualSize = typeof size === 'string' ? parseInt(size, 10) : size; } - const response = await getInventoryLotDetailsBySectionNotMatch( - selectedSession.stockTakeSession, - selectedSession.stockTakeId > 0 ? selectedSession.stockTakeId : null, - pageNum, - actualSize, + const roundId = selectedSession.stockTakeRoundId != null && selectedSession.stockTakeRoundId > 0 ? selectedSession.stockTakeRoundId - : null - ); + : null; + const response = + selectedSession.listMode === "item" && selectedSession.itemId && roundId + ? await getInventoryLotDetailsByItem( + selectedSession.itemId, + roundId, + pageNum, + actualSize, + true, + ) + : await getInventoryLotDetailsBySectionNotMatch( + selectedSession.stockTakeSession, + selectedSession.stockTakeId > 0 ? selectedSession.stockTakeId : null, + pageNum, + actualSize, + roundId, + ); setInventoryLotDetails(Array.isArray(response.records) ? response.records : []); setTotal(response.total || 0); } catch (e) { @@ -482,7 +494,11 @@ const PickerReStockTake: React.FC = ({ {t("Back to List")} - {t("Stock Take Section")}: {selectedSession.stockTakeSession} + {selectedSession.listMode === "item" + ? `${t("Item")}: ${selectedSession.stockTakeSession}${ + selectedSession.stockTakeSectionDescription ? ` (${selectedSession.stockTakeSectionDescription})` : "" + }` + : `${t("Stock Take Section")}: ${selectedSession.stockTakeSession}`} {uniqueWarehouses && ( <> {t("Warehouse")}: {uniqueWarehouses} )} @@ -507,7 +523,9 @@ const PickerReStockTake: React.FC = ({ - {t("Warehouse Location")} + + {selectedSession.listMode === "item" ? t("Warehouse code") : t("Warehouse Location")} + {t("Item-lotNo-ExpiryDate")} {t("UOM")} {t("Stock Take Qty(include Bad Qty)= Available Qty")} @@ -536,7 +554,21 @@ const PickerReStockTake: React.FC = ({ return ( - {detail.warehouseArea || "-"}{detail.warehouseSlot || "-"} + + {selectedSession.listMode === "item" ? ( + + {detail.warehouseCode || "-"} + + {[detail.warehouseArea, detail.warehouseSlot].filter((v) => v?.trim()).join(" ") || "-"} + + + ) : ( + <> + {detail.warehouseArea || "-"} + {detail.warehouseSlot || "-"} + + )} + = ({ {!submitDisabled && isFirstSubmit ? ( - {t("First")}: = ({ {!submitDisabled && isSecondSubmit ? ( - {t("Second")}: = ({ /> ) : null} - - {!detail.firstStockTakeQty && !detail.secondStockTakeQty && !submitDisabled && ( - - - - - )} diff --git a/src/components/StockTakeManagement/PickerStockTake.tsx b/src/components/StockTakeManagement/PickerStockTake.tsx index dcb0fe1d..5db5ba5e 100644 --- a/src/components/StockTakeManagement/PickerStockTake.tsx +++ b/src/components/StockTakeManagement/PickerStockTake.tsx @@ -26,6 +26,7 @@ import { useState, useCallback, useEffect, useRef } from "react"; import { useTranslation } from "react-i18next"; import { AllPickedStockTakeListReponse, + getInventoryLotDetailsByItem, getInventoryLotDetailsBySection, InventoryLotDetailResponse, saveStockTakeRecord, @@ -149,16 +150,20 @@ const PickerStockTake: React.FC = ({ console.log('Using specific size - actualSize set to:', actualSize); } - console.log('Calling getInventoryLotDetailsBySection with actualSize:', actualSize); - const response = await getInventoryLotDetailsBySection( - selectedSession.stockTakeSession, - selectedSession.stockTakeId > 0 ? selectedSession.stockTakeId : null, - pageNum, - actualSize, + const roundId = selectedSession.stockTakeRoundId != null && selectedSession.stockTakeRoundId > 0 ? selectedSession.stockTakeRoundId - : null - ); + : null; + const response = + selectedSession.listMode === "item" && selectedSession.itemId && roundId + ? await getInventoryLotDetailsByItem(selectedSession.itemId, roundId, pageNum, actualSize) + : await getInventoryLotDetailsBySection( + selectedSession.stockTakeSession, + selectedSession.stockTakeId > 0 ? selectedSession.stockTakeId : null, + pageNum, + actualSize, + roundId, + ); setInventoryLotDetails(Array.isArray(response.records) ? response.records : []); setTotal(response.total || 0); } catch (e) { @@ -547,7 +552,11 @@ const PickerStockTake: React.FC = ({ {t("Back to List")} - {t("Stock Take Section")}: {selectedSession.stockTakeSession} + {selectedSession.listMode === "item" + ? `${t("Item")}: ${selectedSession.stockTakeSession}${ + selectedSession.stockTakeSectionDescription ? ` (${selectedSession.stockTakeSectionDescription})` : "" + }` + : `${t("Stock Take Section")}: ${selectedSession.stockTakeSession}`} {uniqueWarehouses && ( <> {t("Warehouse")}: {uniqueWarehouses} )} @@ -604,7 +613,9 @@ const PickerStockTake: React.FC = ({
- {t("Warehouse Location")} + + {selectedSession.listMode === "item" ? t("Warehouse code") : t("Warehouse Location")} + {t("Item-lotNo-ExpiryDate")} {t("UOM")} {t("Stock Take Qty(include Bad Qty)= Available Qty")} @@ -644,8 +655,19 @@ const PickerStockTake: React.FC = ({ }} > - {detail.warehouseArea || "-"} - {detail.warehouseSlot || "-"} + {selectedSession.listMode === "item" ? ( + + {detail.warehouseCode || "-"} + + {[detail.warehouseArea, detail.warehouseSlot].filter((v) => v?.trim()).join(" ") || "-"} + + + ) : ( + <> + {detail.warehouseArea || "-"} + {detail.warehouseSlot || "-"} + + )} = ({ {!submitDisabled && isFirstSubmit ? ( - {t("First")}: = ({ {!submitDisabled && isSecondSubmit ? ( - {t("Second")}: = ({ /> ) : null} - - {!detail.firstStockTakeQty && - !detail.secondStockTakeQty && - !submitDisabled && ( - - - - - )} diff --git a/src/components/StockTakeManagement/StockTakeRoundCardList.tsx b/src/components/StockTakeManagement/StockTakeRoundCardList.tsx new file mode 100644 index 00000000..a1e41e9f --- /dev/null +++ b/src/components/StockTakeManagement/StockTakeRoundCardList.tsx @@ -0,0 +1,293 @@ +"use client"; + +import { + Box, + Button, + Card, + CardActions, + CardContent, + Chip, + CircularProgress, + Grid, + LinearProgress, + Stack, + Typography, +} from "@mui/material"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import dayjs from "dayjs"; +import { OUTPUT_DATE_FORMAT } from "@/app/utils/formatUtil"; +import { + closeStockTakeRound, + getStockTakeRoundCards, + type AllPickedStockTakeListReponse, + type StockTakeRoundCard, +} from "@/app/api/stockTake/actions"; + +export function roundCardToSession(card: StockTakeRoundCard): AllPickedStockTakeListReponse { + return { + id: card.stockTakeRoundId, + stockTakeSession: card.stockTakeRoundName || "", + lastStockTakeDate: null, + status: card.status, + approverName: null, + currentStockTakeItemNumber: 0, + totalInventoryLotNumber: card.totalInventoryLotNumber || 0, + stockTakeId: card.stockTakeId, + stockTakeRoundId: card.stockTakeRoundId, + stockTakerName: null, + totalItemNumber: card.totalItemNumber || 0, + startTime: null, + endTime: null, + planStartDate: card.planStartDate, + stockTakeSectionDescription: null, + warehouseArea: null, + storeId: card.mainFloor, + reStockTakeTrueFalse: card.reStockTakeTrueFalse, + listMode: card.stockTakeRoundMode === "item" ? "item" : "section", + }; +} + +function progressPercent(done: number, total: number): number { + if (total <= 0) return 0; + return Math.min(100, Math.round((done / total) * 100)); +} + +interface StockTakeRoundCardListProps { + isAdmin: boolean; + onEnterRound: (card: StockTakeRoundCard, rounds: StockTakeRoundCard[]) => void; + onCreateClick: () => void; + creating?: boolean; + refreshNonce?: number; + yearMonth: string; + onYearMonthChange: (yearMonth: string) => void; +} + +const StockTakeRoundCardList: React.FC = ({ + isAdmin, + onEnterRound, + onCreateClick, + creating = false, + refreshNonce = 0, + yearMonth, + onYearMonthChange, +}) => { + const { t } = useTranslation(["stockTake", "common"]); + const [loading, setLoading] = useState(true); + const [hasOpenRounds, setHasOpenRounds] = useState(false); + const [rounds, setRounds] = useState([]); + const [completedYearMonths, setCompletedYearMonths] = useState([]); + const [closingId, setClosingId] = useState(null); + const closeLockRef = useRef(false); + const thisMonth = dayjs().format("YYYY-MM"); + + const load = useCallback(async (ym: string) => { + setLoading(true); + try { + const res = await getStockTakeRoundCards(ym); + setHasOpenRounds(Boolean(res.hasOpenRounds)); + setRounds(Array.isArray(res.rounds) ? res.rounds : []); + const months = Array.isArray(res.completedYearMonths) ? res.completedYearMonths : []; + setCompletedYearMonths(months); + } catch (e) { + console.error("Failed to load stock take rounds:", e); + setHasOpenRounds(false); + setRounds([]); + setCompletedYearMonths([]); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void load(yearMonth); + }, [load, yearMonth, refreshNonce]); + + // Completed months plus current month (even if no round created yet). No future months. + 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)); + }, [completedYearMonths, thisMonth]); + + const monthIndex = useMemo(() => { + const i = monthOptions.indexOf(yearMonth); + return i >= 0 ? i : 0; + }, [monthOptions, yearMonth]); + + const goMonth = (delta: number) => { + if (hasOpenRounds || monthOptions.length === 0) return; + const next = monthIndex + delta; + if (next < 0 || next >= monthOptions.length) return; + onYearMonthChange(monthOptions[next]); + }; + + const handleCloseRound = async (card: StockTakeRoundCard) => { + if (!isAdmin || closeLockRef.current) return; + closeLockRef.current = true; + setClosingId(card.stockTakeRoundId); + try { + await closeStockTakeRound(card.stockTakeRoundId); + await load(yearMonth); + } catch (e) { + console.error("Failed to close stock take round:", e); + } finally { + setClosingId(null); + closeLockRef.current = false; + } + }; + + const roundLabel = (card: StockTakeRoundCard) => { + const name = card.stockTakeRoundName?.trim(); + if (name) return name; + return `${t("Stock Take Round")} #${card.stockTakeRoundId}`; + }; + + const statusColor = (status: string) => { + const s = (status || "").toLowerCase(); + if (s === "completed") return "success"; + if (s === "approving") return "info"; + if (s === "stocktaking") return "primary"; + return "warning"; + }; + + return ( + + + + {hasOpenRounds + ? t("Open rounds this month") + : t("Completed rounds for month", { month: yearMonth })} + + + {!hasOpenRounds && monthOptions.length > 0 ? ( + <> + + + {yearMonth} + + + + ) : null} + + + + + {loading ? ( + + + + ) : rounds.length === 0 ? ( + + {t("No data")} + + ) : ( + + {rounds.map((card) => { + const isItemRound = card.stockTakeRoundMode === "item"; + const total = isItemRound ? (card.totalInventoryLotNumber || 0) : (card.sectionCount || 0); + const taken = card.stockTakenCount || 0; + const approved = card.approvedCount || 0; + return ( + + + + + + {roundLabel(card)} + + {card.stockTakeRoundMode === "item" ? ( + + ) : null} + + + {t("Plan Start Date")}:{" "} + {card.planStartDate ? dayjs(card.planStartDate).format(OUTPUT_DATE_FORMAT) : "-"} + + + {card.stockTakeRoundMode === "item" + ? `${t("Total Items")}: ${card.totalItemNumber || 0}` + : `${t("Total Sections")}: ${card.sectionCount}`} + + + {t("Main floor")}: {card.mainFloor?.trim() || "-"} + + + + + {isItemRound ? t("Stock taken lots") : t("Stock taken")} + + + {taken}/{total} + + + + + + {isItemRound ? t("Approved lots") : t("Approved progress")} + + + {approved}/{total} + + + + + + + + + {isAdmin && card.open ? ( + + ) : null} + + + + + + ); + })} + + )} + + ); +}; + +export default StockTakeRoundCardList; diff --git a/src/components/StockTakeManagement/StockTakeTab.tsx b/src/components/StockTakeManagement/StockTakeTab.tsx index f15fdb26..6f77123e 100644 --- a/src/components/StockTakeManagement/StockTakeTab.tsx +++ b/src/components/StockTakeManagement/StockTakeTab.tsx @@ -1,18 +1,40 @@ "use client"; -import { Box, Tab, Tabs, Snackbar, Alert, CircularProgress, Typography, TextField, Button, Stack } from "@mui/material"; +import { + Alert, + Box, + Button, + CircularProgress, + FormControl, + InputLabel, + MenuItem, + Select, + Snackbar, + Stack, + Tab, + Tabs, + TextField, + Typography, +} from "@mui/material"; import { useState, useCallback, useEffect, useRef } from "react"; +import dayjs from "dayjs"; import { useSession } from "next-auth/react"; import { useTranslation } from "react-i18next"; import { AUTH } from "@/authorities"; import { SessionWithTokens } from "@/config/authConfig"; -import { AllPickedStockTakeListReponse, getLatestApproverStockTakeHeader } from "@/app/api/stockTake/actions"; +import { + AllPickedStockTakeListReponse, + closeStockTakeRound, + getLatestApproverStockTakeHeader, + type StockTakeRoundCard, +} from "@/app/api/stockTake/actions"; import { fetchWarehouseListClient } from "@/app/api/warehouse/client"; import PickerCardList from "./PickerCardList"; import { EMPTY_PICKER_LOCATION, type PickerCardListFilters, type PickerLocationPick } from "./PickerCardList"; import PickerStockTake from "./PickerStockTake"; import PickerReStockTake from "./PickerReStockTake"; import ApproverStockTakeAll from "./ApproverStockTakeAll"; +import StockTakeRoundCardList, { roundCardToSession } from "./StockTakeRoundCardList"; import { useStockTakeQtyGapWarnPercent } from "./useStockTakeQtyGapWarnPercent"; import { parseQtyGapWarnPercent, @@ -46,17 +68,23 @@ const StockTakeTab: React.FC = () => { const [viewScope, setViewScope] = useState("picker"); const [approverSession, setApproverSession] = useState(null); const [approverLoading, setApproverLoading] = useState(false); - const approverHeaderLoadedRef = useRef(false); - /** 從卡片列表進入明細後返回時保留分頁 */ const [pickerListPage, setPickerListPage] = useState(0); const [pickerListPageSize] = useState(6); const [pickerSearchFilters, setPickerSearchFilters] = useState(DEFAULT_PICKER_CARD_LIST_FILTERS); const [pickerAppliedFilters, setPickerAppliedFilters] = useState(DEFAULT_PICKER_CARD_LIST_FILTERS); const [pickerLocationPick, setPickerLocationPick] = useState(EMPTY_PICKER_LOCATION); - const [snackbar, setSnackbar] = useState<{ - open: boolean; - message: string; - severity: "success" | "error" | "warning" + const [selectedRound, setSelectedRound] = useState(null); + const [switchableRounds, setSwitchableRounds] = useState([]); + const [roundListNonce, setRoundListNonce] = useState(0); + const [roundListYearMonth, setRoundListYearMonth] = useState(() => dayjs().format("YYYY-MM")); + const [batchButtonHost, setBatchButtonHost] = useState(null); + const [showCreateOnRoundPage, setShowCreateOnRoundPage] = useState(false); + const [closingRound, setClosingRound] = useState(false); + const closeRoundLock = useRef(false); + const [snackbar, setSnackbar] = useState<{ + open: boolean; + message: string; + severity: "success" | "error" | "warning"; }>({ open: false, message: "", @@ -116,23 +144,72 @@ const StockTakeTab: React.FC = () => { fetchWarehouseListClient().catch((e) => console.error("Failed to prefetch warehouses:", e)); }, []); - useEffect(() => { - if (tabValue !== 1 && tabValue !== 2) return; - if (approverHeaderLoadedRef.current) return; - approverHeaderLoadedRef.current = true; - setApproverLoading(true); - getLatestApproverStockTakeHeader() - .then((header) => { - setApproverSession(header ?? null); - }) - .catch((e) => { + const enterRound = useCallback( + async (card: StockTakeRoundCard, roundsForSwitcher?: StockTakeRoundCard[]) => { + setSelectedRound(card); + if (card.yearMonth) setRoundListYearMonth(card.yearMonth); + if (roundsForSwitcher) setSwitchableRounds(roundsForSwitcher); + setPickerListPage(0); + setPickerSearchFilters(DEFAULT_PICKER_CARD_LIST_FILTERS); + setPickerAppliedFilters(DEFAULT_PICKER_CARD_LIST_FILTERS); + setPickerLocationPick(EMPTY_PICKER_LOCATION); + setApproverLoading(true); + try { + const header = await getLatestApproverStockTakeHeader(card.stockTakeRoundId); + setApproverSession(header ?? roundCardToSession(card)); + } catch (e) { console.error(e); - setApproverSession(null); - }) - .finally(() => setApproverLoading(false)); - }, [tabValue]); + setApproverSession(roundCardToSession(card)); + } finally { + setApproverLoading(false); + } + }, + [], + ); + + const handleBackToRounds = useCallback(() => { + setSelectedRound(null); + setSelectedSession(null); + setApproverSession(null); + setShowCreateOnRoundPage(false); + setRoundListNonce((n) => n + 1); + }, []); + + const handleSwitchRound = useCallback( + (roundId: number) => { + const card = switchableRounds.find((r) => r.stockTakeRoundId === roundId); + if (!card) return; + setSelectedSession(null); + void enterRound(card); + }, + [enterRound, switchableRounds], + ); + + const handleCloseSelectedRound = useCallback(async () => { + if (!isAdmin || !selectedRound?.open || closeRoundLock.current) return; + closeRoundLock.current = true; + setClosingRound(true); + try { + await closeStockTakeRound(selectedRound.stockTakeRoundId); + handleSnackbar(t("Round closed"), "success"); + handleBackToRounds(); + } catch (e) { + handleSnackbar(e instanceof Error ? e.message : t("Failed to close round"), "error"); + } finally { + closeRoundLock.current = false; + setClosingRound(false); + } + }, [handleBackToRounds, handleSnackbar, isAdmin, selectedRound, t]); const showingPickerDetail = Boolean(selectedSession) && viewScope === "picker" && tabValue === 0; + const roundId = selectedRound?.stockTakeRoundId ?? 0; + const roundLabel = (card: StockTakeRoundCard) => { + const name = card.stockTakeRoundName?.trim(); + const title = name || `${t("Stock Take Round")} #${card.stockTakeRoundId}`; + const mode = card.stockTakeRoundMode === "item" ? t("Create by item") : t("Create by section"); + const floor = card.mainFloor?.trim(); + return floor ? `${title} · ${mode} · ${floor}` : `${title} · ${mode}`; + }; return ( @@ -152,115 +229,204 @@ const StockTakeTab: React.FC = () => { ) ) : null} - {isAdmin && ( - - - {t("qtyGapWarnPercent")} - - setQtyGapDraft(e.target.value.replace(/[^\d]/g, ""))} - inputProps={{ min: 0, max: 1000, inputMode: "numeric" }} - sx={{ - width: 88, - m: 0, - "& .MuiFilledInput-root": { height: 40 }, - "& .MuiFilledInput-input.MuiInputBase-inputSizeSmall": { - height: 40, - boxSizing: "border-box", - paddingTop: 0, - paddingBottom: 0, - lineHeight: "40px", - }, - }} - /> - - - )} - { - setTabValue(newValue); - if (newValue === 0) { - setViewScope("picker"); - } else { - setViewScope("approver-all"); - } - }} - sx={{ mb: 2 }} - > - - - - + {isAdmin && ( + + + {t("qtyGapWarnPercent")} + + setQtyGapDraft(e.target.value.replace(/[^\d]/g, ""))} + inputProps={{ min: 0, max: 1000, inputMode: "numeric" }} + sx={{ + width: 88, + m: 0, + "& .MuiFilledInput-root": { height: 40 }, + "& .MuiFilledInput-input.MuiInputBase-inputSizeSmall": { + height: 40, + boxSizing: "border-box", + paddingTop: 0, + paddingBottom: 0, + lineHeight: "40px", + }, + }} + /> + + + )} - {tabValue === 0 && ( - { - setViewScope("picker"); - handleCardClick(session); - }} - onReStockTakeClick={handleReStockTakeClick} - /> - )} - {approverHeaderLoadedRef.current || tabValue === 1 || tabValue === 2 ? ( - <> - - {approverLoading && !approverSession ? ( - - - - ) : approverSession ? ( - + { + setSwitchableRounds(listRounds); + void enterRound(card, listRounds); + }} + onCreateClick={() => setShowCreateOnRoundPage(true)} + /> + {showCreateOnRoundPage ? ( + undefined} + searchFilters={DEFAULT_PICKER_CARD_LIST_FILTERS} + appliedFilters={DEFAULT_PICKER_CARD_LIST_FILTERS} + onSearchFiltersChange={() => undefined} + onAppliedFiltersChange={() => undefined} + locationPick={EMPTY_PICKER_LOCATION} + onLocationPickChange={() => undefined} + onCardClick={() => undefined} + onReStockTakeClick={() => undefined} + hideSectionList + autoOpenCreate + onCreateDialogClose={() => setShowCreateOnRoundPage(false)} + onRoundsChanged={() => { + setShowCreateOnRoundPage(false); + setRoundListNonce((n) => n + 1); + }} /> - ) : approverHeaderLoadedRef.current ? ( - - {t("No data")} - ) : null} - - - {approverSession ? ( - + ) : ( + <> + + {switchableRounds.length > 1 ? ( + + {t("Stock Take Round")} + + + ) : ( + roundLabel(selectedRound) + )} + + + + + + + { + setTabValue(newValue); + if (newValue === 0) { + setViewScope("picker"); + } else { + setViewScope("approver-all"); + } + }} + sx={{ minHeight: 48 }} + > + + + + + + {isAdmin && selectedRound.open ? ( + + ) : null} + + + + + {tabValue === 0 && ( + { + setViewScope("picker"); + handleCardClick(session); + }} + onReStockTakeClick={handleReStockTakeClick} + onRoundsChanged={() => { + setRoundListNonce((n) => n + 1); + handleBackToRounds(); + }} /> - ) : approverHeaderLoadedRef.current && !approverLoading ? ( - - {t("No data")} - + )} + {tabValue === 1 || tabValue === 2 ? ( + <> + + {approverLoading && !approverSession ? ( + + + + ) : approverSession ? ( + + ) : ( + + {t("No data")} + + )} + + + {approverSession ? ( + + ) : !approverLoading ? ( + + {t("No data")} + + ) : null} + + ) : null} - - - ) : null} + + )} { ); }; -export default StockTakeTab; \ No newline at end of file +export default StockTakeTab; diff --git a/src/i18n/en/stockTake.json b/src/i18n/en/stockTake.json index cf9e7b80..d38f13c2 100644 --- a/src/i18n/en/stockTake.json +++ b/src/i18n/en/stockTake.json @@ -18,6 +18,20 @@ "Area": "Area", "Available QTY cannot be negative": "Available QTY cannot be negative", "Back to List": "Back to List", + "Back to rounds": "Back to rounds", + "Close round": "Close round", + "Completed rounds for month": "Completed rounds ({{month}})", + "Failed to close round": "Failed to close round", + "In progress": "In progress", + "Next month": "Next month", + "Open rounds this month": "Open rounds (this month)", + "Previous month": "Previous month", + "Round closed": "Round closed", + "Main floor": "Main floor", + "Stock taken": "Stock taken", + "Approved progress": "Approved", + "Stock taken lots": "Stock taken lots", + "Approved lots": "Approved lots", "Bad Qty": "Bad Qty", "Batch Save All": "Batch Save All", "Batch approver save completed: {{success}} success, {{errors}} errors": "Batch approver save completed: {{success}} success, {{errors}} errors", @@ -33,6 +47,14 @@ "Confirm batch save approver": "Confirm batch save", "Confirm create stock take": "Confirm create stock take", "Control Time": "Control Time", + "Create by item": "By item", + "Create by section": "By section", + "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", + "Search item code or name": "Search item code or name", + "Total selected items label": "Selected items", + "items unit": "item(s)", "Create Stock Take (Select Sections)": "Create stock take (select sections)", "Created": "Created", "Creation date": "Creation date", @@ -59,6 +81,7 @@ "Issue No": "Issue No", "Issue Remark": "Issue Remark", "Item": "Item", + "Item Name": "Item Name", "Item Code": "Item Code", "Item-lotNo-ExpiryDate": "Item-lotNo-ExpiryDate", "Last Stock Take Date": "Last Stock Take Date", @@ -151,6 +174,7 @@ "View Details": "View Details", "View ReStockTake": "View ReStockTake", "Warehouse": "Warehouse", + "Warehouse code": "Warehouse code", "Warehouse Location": "Warehouse Location", "Warehouse missing stock take section drawer hint": "These locations have no stock take section (ST-xxx) and cannot be included in stock take. Fix in warehouse settings.", "Warehouse missing stock take section empty": "No warehouses missing stock take section", diff --git a/src/i18n/zh/stockTake.json b/src/i18n/zh/stockTake.json index 8cabc7cd..633f14f4 100644 --- a/src/i18n/zh/stockTake.json +++ b/src/i18n/zh/stockTake.json @@ -1,8 +1,23 @@ { "View Details": "查看詳細", "Back to List": "返回列表", + "Back to rounds": "返回輪次列表", + "Close round": "關閉此輪", + "Completed rounds for month": "已完成輪次({{month}})", + "Failed to close round": "關閉輪次失敗", + "In progress": "進行中", + "Next month": "下一個月", + "Open rounds this month": "進行中的輪次(本月)", + "Previous month": "上一個月", + "Round closed": "已關閉此輪", + "Main floor": "主要樓層", + "Stock taken": "已盤點", + "Approved progress": "已審核", + "Stock taken lots": "已盤點批次", + "Approved lots": "已審核批次", "Stock Take Section": "盤點區域類型", "Warehouse": "倉庫", + "Warehouse code": "倉庫代碼", "All": "全部", "Rows per page": "每頁行數", "Remark": "備註", @@ -17,6 +32,7 @@ "Stock Take Section code": "盤點區域", "Stock Take Section code placeholder": "可使用逗號搜索多個區域", "Item": "貨品", + "Item Name": "貨品名稱", "Store ID": "樓層", "Pending": "待處理", "Pass": "已盤點", @@ -112,6 +128,14 @@ "Select area first": "請先選擇倉位", "Slot": "位置", "Start Stock Take Date": "盤點日期", + "Create by item": "依貨品", + "Create by section": "依區域", + "Create by item hint": "搜尋並勾選貨品。此輪會納入這些貨在所有樓層/倉的可用庫存。", + "Create by item search hint": "可分別輸入貨品編號、貨品名稱;兩個都填時要同時符合", + "Create by item selected count": "已選 {{count}} 項貨品", + "Search item code or name": "搜尋貨品編號或名稱", + "Total selected items label": "已選貨品", + "items unit": "項", "Create Stock Take (Select Sections)": "建立盤點(選擇區域)", "Total Item Kind Number": "貨品種類數量", "View ReStockTake": "查看重新盤點",