| @@ -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<RecordsRes<InventoryLotDetailResponse>>( | |||
| `${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<RecordsRes<AllPickedStockTakeListReponse>>(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<AllPickedStockTakeListReponse>( | |||
| `${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<StockTakeRoundCardsRes>( | |||
| `${BASE_API_URL}/stockTakeRecord/stockTakeRoundCards${qs ? `?${qs}` : ""}`, | |||
| { method: "GET" }, | |||
| ); | |||
| }; | |||
| export const getBusyStockTakeSections = async () => { | |||
| return serverFetchJson<string[]>( | |||
| `${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<Map<string, string>>( | |||
| `${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) { | |||
| @@ -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<ApproverStockTakeAllProps> = ({ | |||
| mode, | |||
| onBack, | |||
| onSnackbar, | |||
| batchButtonHost = null, | |||
| }) => { | |||
| const { t } = useTranslation(["stockTake", "common"]); | |||
| const qtyGapWarnPercent = useStockTakeQtyGapWarnPercent(); | |||
| @@ -1623,8 +1627,21 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||
| const effectivePageSize = | |||
| pageSize === "all" ? Math.max(total || 0, 0) : (pageSize as number); | |||
| const batchSaveButton = mode === "pending" ? ( | |||
| <Button | |||
| variant="contained" | |||
| color="primary" | |||
| onClick={handleOpenBatchSaveConfirm} | |||
| disabled={batchSaving} | |||
| > | |||
| {t("Batch Save All")} | |||
| </Button> | |||
| ) : null; | |||
| const batchButtonInTabBar = Boolean(batchButtonHost && batchSaveButton); | |||
| return ( | |||
| <Box> | |||
| {batchButtonInTabBar ? createPortal(batchSaveButton, batchButtonHost as HTMLElement) : null} | |||
| {onBack && ( | |||
| <Button | |||
| onClick={onBack} | |||
| @@ -1633,32 +1650,24 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||
| {t("Back to List")} | |||
| </Button> | |||
| )} | |||
| {(uniqueWarehouses || (batchSaveButton && !batchButtonInTabBar)) && ( | |||
| <Stack | |||
| direction="row" | |||
| justifyContent="space-between" | |||
| alignItems="center" | |||
| sx={{ mb: 2 }} | |||
| > | |||
| <Typography variant="h6" sx={{ mb: 2 }}> | |||
| <Typography variant="h6"> | |||
| {uniqueWarehouses && ( | |||
| <> {t("Warehouse")}: {uniqueWarehouses}</> | |||
| )} | |||
| </Typography> | |||
| <Stack direction="row" spacing={2} alignItems="center"> | |||
| {mode === "pending" && ( | |||
| <Button | |||
| variant="contained" | |||
| color="primary" | |||
| onClick={handleOpenBatchSaveConfirm} | |||
| disabled={batchSaving} | |||
| > | |||
| {t("Batch Save All")} | |||
| </Button> | |||
| )} | |||
| {!batchButtonInTabBar ? batchSaveButton : null} | |||
| </Stack> | |||
| </Stack> | |||
| )} | |||
| <Accordion | |||
| expanded={showFilters} | |||
| onChange={(_, expanded) => setShowFilters(expanded)} | |||
| @@ -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<T>(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<PickerCardListProps> = ({ | |||
| 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<PickerCardListProps> = ({ | |||
| const [globalRoundPlanStartDate, setGlobalRoundPlanStartDate] = useState<string | null>(null); | |||
| const [creating, setCreating] = useState(false); | |||
| const [openConfirmDialog, setOpenConfirmDialog] = useState(false); | |||
| const [busySections, setBusySections] = useState<Set<string>>(() => 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<PickerCardListProps> = ({ | |||
| /** 建立盤點對話框:目前選中的樓層分頁(對應 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<CreateDialogItemHit[]>([]); | |||
| const [createDialogSelectedItems, setCreateDialogSelectedItems] = useState<CreateDialogItemHit[]>([]); | |||
| const [createDialogItemSearching, setCreateDialogItemSearching] = useState(false); | |||
| const [sectionsLoading, setSectionsLoading] = useState(false); | |||
| const [stockTakeSectionRows, setStockTakeSectionRows] = useState<StockTakeSectionInfo[]>([]); | |||
| const createStockTakeInFlightRef = useRef(false); | |||
| @@ -341,7 +377,7 @@ const PickerCardList: React.FC<PickerCardListProps> = ({ | |||
| : 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<PickerCardListProps> = ({ | |||
| }, []); | |||
| 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<PickerCardListProps> = ({ | |||
| return () => { | |||
| cancelled = true; | |||
| }; | |||
| }, [listRefreshNonce]); | |||
| }, [hideSectionList, listRefreshNonce, stockTakeRoundId]); | |||
| const roundSectionCodes = useMemo( | |||
| () => | |||
| @@ -649,6 +695,44 @@ const PickerCardList: React.FC<PickerCardListProps> = ({ | |||
| 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<PickerCardListProps> = ({ | |||
| }, [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<PickerCardListProps> = ({ | |||
| return keys; | |||
| }, [sectionsByStore]); | |||
| const isSectionBusy = useCallback( | |||
| (section: string) => busySections.has(section.trim()), | |||
| [busySections], | |||
| ); | |||
| const allSectionsForCreateDialog = useMemo(() => { | |||
| const s = new Set<string>(); | |||
| 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<PickerCardListProps> = ({ | |||
| 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<PickerCardListProps> = ({ | |||
| return Array.from(next).sort((a, b) => a.localeCompare(b)); | |||
| }); | |||
| }, | |||
| [sectionsByStore], | |||
| [busySections, sectionsByStore], | |||
| ); | |||
| /** 與卡片標題括號內相同:描述 / 區域 / 樓層 */ | |||
| @@ -842,21 +942,27 @@ const PickerCardList: React.FC<PickerCardListProps> = ({ | |||
| 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<HTMLDivElement>) => { | |||
| @@ -920,7 +1026,11 @@ const PickerCardList: React.FC<PickerCardListProps> = ({ | |||
| 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<PickerCardListProps> = ({ | |||
| 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<PickerCardListProps> = ({ | |||
| 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<string, string | number> = { 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<PickerCardListProps> = ({ | |||
| 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 ( | |||
| <Box> | |||
| {!hideSectionList && ( | |||
| <> | |||
| <Card elevation={0} sx={{ mb: 2 }}> | |||
| <CardContent> | |||
| <Typography variant="overline" sx={{ display: "block", mb: 1 }}> | |||
| @@ -1173,27 +1356,26 @@ const PickerCardList: React.FC<PickerCardListProps> = ({ | |||
| </FormControl> | |||
| </Grid> | |||
| )} | |||
| <Grid item xs={12} md={4} sx={{ order: 2 }}> | |||
| <FormControl fullWidth size="small" sx={searchFieldSx}> | |||
| <InputLabel>{t("Status")}</InputLabel> | |||
| <Select | |||
| value={searchFilters.status} | |||
| label={t("Status")} | |||
| onChange={(e) => | |||
| applyFilters({ | |||
| ...searchFilters, | |||
| status: e.target.value, | |||
| }) | |||
| } | |||
| <Grid item xs={12} sx={{ order: searchKind === "location" ? 2 : undefined }}> | |||
| <Box sx={{ display: "flex", alignItems: "center", gap: 1.5, flexWrap: "wrap" }}> | |||
| <Typography variant="body2" sx={{ minWidth: 72, fontWeight: 600 }}> | |||
| {t("Status")} | |||
| </Typography> | |||
| <ToggleButtonGroup | |||
| exclusive | |||
| value={searchFilters.status || "All"} | |||
| onChange={(_, next: string | null) => { | |||
| if (next == null) return; | |||
| applyFilters({ ...searchFilters, status: next }); | |||
| }} | |||
| > | |||
| <MenuItem value="All">{t("All")}</MenuItem> | |||
| {statusOptions.map((status) => ( | |||
| <MenuItem key={status} value={status}> | |||
| {t(status)} | |||
| </MenuItem> | |||
| <ToggleButton key={status} value={status} sx={{ minHeight: 48, px: 2, textTransform: "none" }}> | |||
| {status === "All" ? t("All") : t(status)} | |||
| </ToggleButton> | |||
| ))} | |||
| </Select> | |||
| </FormControl> | |||
| </ToggleButtonGroup> | |||
| </Box> | |||
| </Grid> | |||
| {searchKind === "location" && ( | |||
| <Grid item xs={12} sx={{ order: 1 }}> | |||
| @@ -1367,12 +1549,13 @@ const PickerCardList: React.FC<PickerCardListProps> = ({ | |||
| <Typography variant="body2" color="text.secondary"> | |||
| {t("Total Sections")}: {total} | |||
| {roundRecords.some((s) => s.listMode === "item") ? t("Total Items") : t("Total Sections")}: {total} | |||
| </Typography> | |||
| <Typography variant="body2" color="text.secondary"> | |||
| {t("Start Stock Take Date")}: {planStartDate || "-"} | |||
| </Typography> | |||
| {stockTakeRoundId != null && stockTakeRoundId > 0 ? null : ( | |||
| <Button | |||
| variant="contained" | |||
| color="primary" | |||
| @@ -1381,6 +1564,7 @@ const PickerCardList: React.FC<PickerCardListProps> = ({ | |||
| > | |||
| {creating ? <CircularProgress size={20} /> : t("Create Stock Take (Select Sections)")} | |||
| </Button> | |||
| )} | |||
| </Box> | |||
| {loading ? ( | |||
| @@ -1415,8 +1599,11 @@ const PickerCardList: React.FC<PickerCardListProps> = ({ | |||
| <CardContent sx={{ pb: 1, flexGrow: 1 }}> | |||
| <Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 1 }}> | |||
| <Typography variant="subtitle1" fontWeight={600}> | |||
| {t("Section")}: {session.stockTakeSession} | |||
| {sectionMeta ? ` (${sectionMeta})` : null} | |||
| {session.listMode === "item" | |||
| ? `${t("Item")}: ${session.stockTakeSession}${ | |||
| session.stockTakeSectionDescription ? ` (${session.stockTakeSectionDescription})` : "" | |||
| }` | |||
| : `${t("Section")}: ${session.stockTakeSession}${sectionMeta ? ` (${sectionMeta})` : ""}`} | |||
| </Typography> | |||
| </Stack> | |||
| @@ -1455,7 +1642,12 @@ const PickerCardList: React.FC<PickerCardListProps> = ({ | |||
| {t("View ReStockTake")} | |||
| </Button> | |||
| </Stack> | |||
| <Chip size="small" label={t(session.status || "")} color={statusColor as any} /> | |||
| <Stack direction="row" spacing={0.5} flexWrap="wrap" justifyContent="flex-end"> | |||
| {session.reStockTakeTrueFalse ? ( | |||
| <Chip size="small" label={t("notMatch")} color="warning" /> | |||
| ) : null} | |||
| <Chip size="small" label={t(session.status || "")} color={statusColor as any} /> | |||
| </Stack> | |||
| </CardActions> | |||
| </Card> | |||
| </Grid> | |||
| @@ -1475,15 +1667,13 @@ const PickerCardList: React.FC<PickerCardListProps> = ({ | |||
| }} | |||
| rowsPerPageOptions={[pageSize]} // 如果暂时不让用户改 pageSize,就写死 | |||
| /> | |||
| )} | |||
| </> | |||
| )} | |||
| {/* Create Stock Take 確認 Dialog */} | |||
| <Dialog | |||
| open={openConfirmDialog} | |||
| onClose={() => { | |||
| setOpenConfirmDialog(false); | |||
| setCreateDialogRoundName(""); | |||
| setCreateDialogPlanStart(dayjs()); | |||
| }} | |||
| onClose={closeCreateDialog} | |||
| maxWidth={false} | |||
| fullWidth | |||
| PaperProps={{ | |||
| @@ -1536,6 +1726,20 @@ const PickerCardList: React.FC<PickerCardListProps> = ({ | |||
| </Box> | |||
| </Tooltip> | |||
| </DialogTitle> | |||
| <Box sx={{ px: 2, flexShrink: 0 }}> | |||
| <Tabs | |||
| value={createMode} | |||
| onChange={(_, v: "section" | "item") => { | |||
| if (!v) return; | |||
| setCreateMode(v); | |||
| }} | |||
| > | |||
| {blockSectionCreate ? null : ( | |||
| <Tab value="section" label={t("Create by section")} /> | |||
| )} | |||
| <Tab value="item" label={t("Create by item")} /> | |||
| </Tabs> | |||
| </Box> | |||
| <DialogContent | |||
| dividers | |||
| sx={{ | |||
| @@ -1611,7 +1815,11 @@ const PickerCardList: React.FC<PickerCardListProps> = ({ | |||
| /> | |||
| </LocalizationProvider> | |||
| </Box> | |||
| {sortedFloorKeys.length === 0 && !sectionsLoading ? ( | |||
| {createMode === "item" ? ( | |||
| <Typography variant="body2" color="text.secondary"> | |||
| {t("Create by item hint")} | |||
| </Typography> | |||
| ) : sortedFloorKeys.length === 0 && !sectionsLoading ? ( | |||
| <Typography variant="body2" color="text.secondary"> | |||
| {t("No stock take sections from warehouse")} | |||
| </Typography> | |||
| @@ -1671,6 +1879,7 @@ const PickerCardList: React.FC<PickerCardListProps> = ({ | |||
| })} | |||
| </Stack> | |||
| )} | |||
| {createMode === "item" ? null : ( | |||
| <Stack spacing={1}> | |||
| <Button | |||
| fullWidth | |||
| @@ -1690,12 +1899,15 @@ const PickerCardList: React.FC<PickerCardListProps> = ({ | |||
| {t("Clear selection all floors")} | |||
| </Button> | |||
| </Stack> | |||
| )} | |||
| <Typography variant="body2" color="text.secondary"> | |||
| {t("Total selected sections label")}{" "} | |||
| {createMode === "item" ? t("Total selected items label") : t("Total selected sections label")}{" "} | |||
| <Typography component="span" variant="body2" color="primary.main" fontWeight={700}> | |||
| {createDialogSelectedSections.length} | |||
| {createMode === "item" | |||
| ? createDialogSelectedItems.length | |||
| : createDialogSelectedSections.length} | |||
| </Typography>{" "} | |||
| {t("sections unit")} | |||
| {createMode === "item" ? t("items unit") : t("sections unit")} | |||
| </Typography> | |||
| </Box> | |||
| @@ -1712,7 +1924,108 @@ const PickerCardList: React.FC<PickerCardListProps> = ({ | |||
| overflow: "hidden", | |||
| }} | |||
| > | |||
| {activeCreateFloorKey != null ? ( | |||
| {createMode === "item" ? ( | |||
| <Stack spacing={1.5} sx={{ flex: 1, minHeight: 0 }}> | |||
| <Stack direction={{ xs: "column", sm: "row" }} spacing={1}> | |||
| <TextField | |||
| fullWidth | |||
| size="small" | |||
| hiddenLabel | |||
| placeholder={t("Item Code")} | |||
| value={createDialogItemCode} | |||
| onChange={(e) => setCreateDialogItemCode(e.target.value)} | |||
| InputProps={{ | |||
| startAdornment: ( | |||
| <InputAdornment position="start"> | |||
| <SearchIcon fontSize="small" color="action" /> | |||
| </InputAdornment> | |||
| ), | |||
| }} | |||
| sx={createDialogCompactInputSx} | |||
| /> | |||
| <TextField | |||
| fullWidth | |||
| size="small" | |||
| hiddenLabel | |||
| placeholder={t("Item Name")} | |||
| value={createDialogItemName} | |||
| onChange={(e) => setCreateDialogItemName(e.target.value)} | |||
| InputProps={{ | |||
| startAdornment: ( | |||
| <InputAdornment position="start"> | |||
| <SearchIcon fontSize="small" color="action" /> | |||
| </InputAdornment> | |||
| ), | |||
| }} | |||
| sx={createDialogCompactInputSx} | |||
| /> | |||
| </Stack> | |||
| <Typography variant="caption" color="text.secondary"> | |||
| {t("Create by item selected count", { count: createDialogSelectedItems.length })} | |||
| </Typography> | |||
| <Box sx={{ display: "flex", flexWrap: "wrap", gap: 0.75 }}> | |||
| {createDialogSelectedItems.map((it) => ( | |||
| <Chip | |||
| key={it.id} | |||
| size="small" | |||
| label={`${it.code} ${it.name}`.trim()} | |||
| onDelete={() => | |||
| setCreateDialogSelectedItems((prev) => prev.filter((x) => x.id !== it.id)) | |||
| } | |||
| /> | |||
| ))} | |||
| </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")} | |||
| </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], | |||
| ); | |||
| }} | |||
| /> | |||
| <Box> | |||
| <Typography variant="body2" fontWeight={600}> | |||
| {it.code} | |||
| </Typography> | |||
| <Typography variant="caption" color="text.secondary"> | |||
| {it.name} | |||
| </Typography> | |||
| </Box> | |||
| </Box> | |||
| ); | |||
| })} | |||
| </Stack> | |||
| )} | |||
| </Box> | |||
| </Stack> | |||
| ) : activeCreateFloorKey != null ? ( | |||
| <> | |||
| <Stack | |||
| direction="row" | |||
| @@ -1829,9 +2142,10 @@ const PickerCardList: React.FC<PickerCardListProps> = ({ | |||
| > | |||
| {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 ( | |||
| <Card | |||
| key={section} | |||
| @@ -1846,7 +2160,8 @@ const PickerCardList: React.FC<PickerCardListProps> = ({ | |||
| } | |||
| }} | |||
| 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<PickerCardListProps> = ({ | |||
| <Typography variant="body2" fontWeight={700} noWrap title={section}> | |||
| {section} | |||
| </Typography> | |||
| {busy ? ( | |||
| <Typography variant="caption" color="warning.main" display="block"> | |||
| {t("In progress")} | |||
| </Typography> | |||
| ) : null} | |||
| {meta ? ( | |||
| <Typography | |||
| variant="caption" | |||
| @@ -1905,11 +2225,7 @@ const PickerCardList: React.FC<PickerCardListProps> = ({ | |||
| </DialogContent> | |||
| <DialogActions sx={{ px: 3, py: 2, flexShrink: 0 }}> | |||
| <Button | |||
| onClick={() => { | |||
| setOpenConfirmDialog(false); | |||
| setCreateDialogRoundName(""); | |||
| setCreateDialogPlanStart(dayjs()); | |||
| }} | |||
| onClick={closeCreateDialog} | |||
| > | |||
| {t("Cancel")} | |||
| </Button> | |||
| @@ -1919,7 +2235,9 @@ const PickerCardList: React.FC<PickerCardListProps> = ({ | |||
| 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<PickerCardListProps> = ({ | |||
| : "-"} | |||
| </Typography> | |||
| <Typography variant="body2" fontWeight={600}> | |||
| {t("Total selected sections label")}{" "} | |||
| {createMode === "item" ? t("Total selected items label") : t("Total selected sections label")}{" "} | |||
| <Typography component="span"fontWeight={600} > | |||
| {createStockTakeSummarySections.length} | |||
| {createMode === "item" | |||
| ? createDialogSelectedItems.length | |||
| : createStockTakeSummarySections.length} | |||
| </Typography>{" "} | |||
| {t("sections unit")} | |||
| {createMode === "item" ? t("items unit") : t("sections unit")} | |||
| </Typography> | |||
| </Stack> | |||
| <Box | |||
| @@ -1973,7 +2293,18 @@ const PickerCardList: React.FC<PickerCardListProps> = ({ | |||
| }} | |||
| > | |||
| <Stack spacing={1}> | |||
| {createStockTakeSummarySections.map((section) => { | |||
| {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> | |||
| )) | |||
| : createStockTakeSummarySections.map((section) => { | |||
| const meta = getCreateDialogSectionMeta(section); | |||
| return ( | |||
| <Box key={section}> | |||
| @@ -2002,7 +2333,12 @@ const PickerCardList: React.FC<PickerCardListProps> = ({ | |||
| variant="contained" | |||
| color="primary" | |||
| onClick={handleCreateStockTake} | |||
| disabled={creating || createDialogSelectedSections.length === 0} | |||
| disabled={ | |||
| creating || | |||
| (createMode === "item" | |||
| ? createDialogSelectedItems.length === 0 | |||
| : createDialogSelectedSections.length === 0) | |||
| } | |||
| > | |||
| {creating ? <CircularProgress size={20} /> : t("Confirm")} | |||
| </Button> | |||
| @@ -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<PickerReStockTakeProps> = ({ | |||
| 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<PickerReStockTakeProps> = ({ | |||
| {t("Back to List")} | |||
| </Button> | |||
| <Typography variant="h6" sx={{ mb: 2 }}> | |||
| {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<PickerReStockTakeProps> = ({ | |||
| <Table> | |||
| <TableHead> | |||
| <TableRow> | |||
| <TableCell>{t("Warehouse Location")}</TableCell> | |||
| <TableCell> | |||
| {selectedSession.listMode === "item" ? t("Warehouse code") : t("Warehouse Location")} | |||
| </TableCell> | |||
| <TableCell>{t("Item-lotNo-ExpiryDate")}</TableCell> | |||
| <TableCell>{t("UOM")}</TableCell> | |||
| <TableCell sx={{ width: 250, minWidth: 250 }}>{t("Stock Take Qty(include Bad Qty)= Available Qty")}</TableCell> | |||
| @@ -536,7 +554,21 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({ | |||
| return ( | |||
| <TableRow key={detail.id}> | |||
| <TableCell>{detail.warehouseArea || "-"}{detail.warehouseSlot || "-"}</TableCell> | |||
| <TableCell> | |||
| {selectedSession.listMode === "item" ? ( | |||
| <Stack spacing={0.25}> | |||
| <Typography fontWeight={700}>{detail.warehouseCode || "-"}</Typography> | |||
| <Typography variant="body2" color="text.secondary"> | |||
| {[detail.warehouseArea, detail.warehouseSlot].filter((v) => v?.trim()).join(" ") || "-"} | |||
| </Typography> | |||
| </Stack> | |||
| ) : ( | |||
| <> | |||
| {detail.warehouseArea || "-"} | |||
| {detail.warehouseSlot || "-"} | |||
| </> | |||
| )} | |||
| </TableCell> | |||
| <TableCell sx={{ | |||
| maxWidth: 280, | |||
| wordBreak: 'break-word', | |||
| @@ -561,7 +593,6 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({ | |||
| {!submitDisabled && isFirstSubmit ? ( | |||
| <Stack spacing={0.5} alignItems="flex-start"> | |||
| <Stack direction="row" spacing={1} alignItems="center"> | |||
| <Typography variant="body2">{t("First")}:</Typography> | |||
| <TextField | |||
| size="small" | |||
| type="number" | |||
| @@ -645,7 +676,6 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({ | |||
| {!submitDisabled && isSecondSubmit ? ( | |||
| <Stack spacing={0.5} alignItems="flex-start"> | |||
| <Stack direction="row" spacing={1} alignItems="center"> | |||
| <Typography variant="body2">{t("Second")}:</Typography> | |||
| <TextField | |||
| size="small" | |||
| type="number" | |||
| @@ -724,12 +754,6 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({ | |||
| /> | |||
| </Typography> | |||
| ) : null} | |||
| {!detail.firstStockTakeQty && !detail.secondStockTakeQty && !submitDisabled && ( | |||
| <Typography variant="body2" color="text.secondary"> | |||
| - | |||
| </Typography> | |||
| )} | |||
| </Stack> | |||
| </TableCell> | |||
| <TableCell> | |||
| @@ -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<PickerStockTakeProps> = ({ | |||
| 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<PickerStockTakeProps> = ({ | |||
| {t("Back to List")} | |||
| </Button> | |||
| <Typography variant="h6" sx={{ mb: 2 }}> | |||
| {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<PickerStockTakeProps> = ({ | |||
| <Table> | |||
| <TableHead> | |||
| <TableRow> | |||
| <TableCell>{t("Warehouse Location")}</TableCell> | |||
| <TableCell> | |||
| {selectedSession.listMode === "item" ? t("Warehouse code") : t("Warehouse Location")} | |||
| </TableCell> | |||
| <TableCell>{t("Item-lotNo-ExpiryDate")}</TableCell> | |||
| <TableCell>{t("UOM")}</TableCell> | |||
| <TableCell sx={{ width: 250, minWidth: 250 }}>{t("Stock Take Qty(include Bad Qty)= Available Qty")}</TableCell> | |||
| @@ -644,8 +655,19 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||
| }} | |||
| > | |||
| <TableCell> | |||
| {detail.warehouseArea || "-"} | |||
| {detail.warehouseSlot || "-"} | |||
| {selectedSession.listMode === "item" ? ( | |||
| <Stack spacing={0.25}> | |||
| <Typography fontWeight={700}>{detail.warehouseCode || "-"}</Typography> | |||
| <Typography variant="body2" color="text.secondary"> | |||
| {[detail.warehouseArea, detail.warehouseSlot].filter((v) => v?.trim()).join(" ") || "-"} | |||
| </Typography> | |||
| </Stack> | |||
| ) : ( | |||
| <> | |||
| {detail.warehouseArea || "-"} | |||
| {detail.warehouseSlot || "-"} | |||
| </> | |||
| )} | |||
| </TableCell> | |||
| <TableCell | |||
| sx={{ | |||
| @@ -678,7 +700,6 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||
| {!submitDisabled && isFirstSubmit ? ( | |||
| <Stack spacing={0.5} alignItems="flex-start"> | |||
| <Stack direction="row" spacing={1} alignItems="center"> | |||
| <Typography variant="body2">{t("First")}:</Typography> | |||
| <TextField | |||
| size="small" | |||
| type="text" | |||
| @@ -764,7 +785,6 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||
| {!submitDisabled && isSecondSubmit ? ( | |||
| <Stack spacing={0.5} alignItems="flex-start"> | |||
| <Stack direction="row" spacing={1} alignItems="center"> | |||
| <Typography variant="body2">{t("Second")}:</Typography> | |||
| <TextField | |||
| size="small" | |||
| type="text" | |||
| @@ -841,17 +861,6 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||
| /> | |||
| </Typography> | |||
| ) : null} | |||
| {!detail.firstStockTakeQty && | |||
| !detail.secondStockTakeQty && | |||
| !submitDisabled && ( | |||
| <Typography | |||
| variant="body2" | |||
| color="text.secondary" | |||
| > | |||
| - | |||
| </Typography> | |||
| )} | |||
| </Stack> | |||
| </TableCell> | |||
| <TableCell> | |||
| @@ -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<StockTakeRoundCardListProps> = ({ | |||
| 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<StockTakeRoundCard[]>([]); | |||
| const [completedYearMonths, setCompletedYearMonths] = useState<string[]>([]); | |||
| const [closingId, setClosingId] = useState<number | null>(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 ( | |||
| <Box> | |||
| <Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", mb: 2, gap: 1, flexWrap: "wrap" }}> | |||
| <Typography variant="body2" color="text.secondary"> | |||
| {hasOpenRounds | |||
| ? t("Open rounds this month") | |||
| : t("Completed rounds for month", { month: yearMonth })} | |||
| </Typography> | |||
| <Stack direction="row" spacing={1} alignItems="center"> | |||
| {!hasOpenRounds && monthOptions.length > 0 ? ( | |||
| <> | |||
| <Button | |||
| size="small" | |||
| variant="outlined" | |||
| disabled={monthIndex >= monthOptions.length - 1} | |||
| onClick={() => goMonth(1)} | |||
| > | |||
| {t("Previous month")} | |||
| </Button> | |||
| <Typography variant="body2" sx={{ minWidth: 80, textAlign: "center" }}> | |||
| {yearMonth} | |||
| </Typography> | |||
| <Button size="small" variant="outlined" disabled={monthIndex <= 0} onClick={() => goMonth(-1)}> | |||
| {t("Next month")} | |||
| </Button> | |||
| </> | |||
| ) : null} | |||
| <Button variant="contained" onClick={onCreateClick} disabled={creating || !isAdmin}> | |||
| {creating ? <CircularProgress size={20} /> : t("Create Stock Take (Select Sections)")} | |||
| </Button> | |||
| </Stack> | |||
| </Box> | |||
| {loading ? ( | |||
| <Box sx={{ display: "flex", justifyContent: "center", p: 3 }}> | |||
| <CircularProgress /> | |||
| </Box> | |||
| ) : rounds.length === 0 ? ( | |||
| <Typography variant="body2" color="text.secondary" sx={{ py: 4, textAlign: "center" }}> | |||
| {t("No data")} | |||
| </Typography> | |||
| ) : ( | |||
| <Grid container spacing={2}> | |||
| {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 ( | |||
| <Grid key={card.stockTakeRoundId} item xs={12} sm={6} md={4}> | |||
| <Card | |||
| sx={{ | |||
| minHeight: 180, | |||
| display: "flex", | |||
| flexDirection: "column", | |||
| border: "1px solid", | |||
| borderColor: card.open ? "primary.main" : "success.main", | |||
| }} | |||
| > | |||
| <CardContent sx={{ pb: 1, flexGrow: 1 }}> | |||
| <Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 0.5 }}> | |||
| <Typography variant="subtitle1" fontWeight={600}> | |||
| {roundLabel(card)} | |||
| </Typography> | |||
| {card.stockTakeRoundMode === "item" ? ( | |||
| <Chip size="small" label={t("Create by item")} color="info" /> | |||
| ) : null} | |||
| </Stack> | |||
| <Typography variant="body2" color="text.secondary"> | |||
| {t("Plan Start Date")}:{" "} | |||
| {card.planStartDate ? dayjs(card.planStartDate).format(OUTPUT_DATE_FORMAT) : "-"} | |||
| </Typography> | |||
| <Typography variant="body2" color="text.secondary"> | |||
| {card.stockTakeRoundMode === "item" | |||
| ? `${t("Total Items")}: ${card.totalItemNumber || 0}` | |||
| : `${t("Total Sections")}: ${card.sectionCount}`} | |||
| </Typography> | |||
| <Typography variant="body2" color="text.secondary"> | |||
| {t("Main floor")}: {card.mainFloor?.trim() || "-"} | |||
| </Typography> | |||
| <Box sx={{ mt: 1.5 }}> | |||
| <Stack direction="row" justifyContent="space-between" sx={{ mb: 0.25 }}> | |||
| <Typography variant="caption" color="text.secondary"> | |||
| {isItemRound ? t("Stock taken lots") : t("Stock taken")} | |||
| </Typography> | |||
| <Typography variant="caption" color="text.secondary"> | |||
| {taken}/{total} | |||
| </Typography> | |||
| </Stack> | |||
| <LinearProgress | |||
| variant="determinate" | |||
| value={progressPercent(taken, total)} | |||
| sx={{ height: 8, borderRadius: 1, mb: 1 }} | |||
| /> | |||
| <Stack direction="row" justifyContent="space-between" sx={{ mb: 0.25 }}> | |||
| <Typography variant="caption" color="text.secondary"> | |||
| {isItemRound ? t("Approved lots") : t("Approved progress")} | |||
| </Typography> | |||
| <Typography variant="caption" color="text.secondary"> | |||
| {approved}/{total} | |||
| </Typography> | |||
| </Stack> | |||
| <LinearProgress | |||
| variant="determinate" | |||
| color="success" | |||
| value={progressPercent(approved, total)} | |||
| sx={{ height: 8, borderRadius: 1 }} | |||
| /> | |||
| </Box> | |||
| </CardContent> | |||
| <CardActions sx={{ pt: 0.5, justifyContent: "space-between", flexWrap: "wrap", gap: 1 }}> | |||
| <Stack direction="row" spacing={1}> | |||
| <Button size="small" variant="contained" onClick={() => onEnterRound(card, rounds)}> | |||
| {t("View Details")} | |||
| </Button> | |||
| {isAdmin && card.open ? ( | |||
| <Button | |||
| size="small" | |||
| variant="outlined" | |||
| color="warning" | |||
| disabled={closingId === card.stockTakeRoundId} | |||
| onClick={() => handleCloseRound(card)} | |||
| > | |||
| {closingId === card.stockTakeRoundId ? <CircularProgress size={16} /> : t("Close round")} | |||
| </Button> | |||
| ) : null} | |||
| </Stack> | |||
| <Chip size="small" label={t(card.status || " ")} color={statusColor(card.status) as "success" | "info" | "primary" | "warning"} /> | |||
| </CardActions> | |||
| </Card> | |||
| </Grid> | |||
| ); | |||
| })} | |||
| </Grid> | |||
| )} | |||
| </Box> | |||
| ); | |||
| }; | |||
| export default StockTakeRoundCardList; | |||
| @@ -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<ViewScope>("picker"); | |||
| const [approverSession, setApproverSession] = useState<AllPickedStockTakeListReponse | null>(null); | |||
| const [approverLoading, setApproverLoading] = useState(false); | |||
| const approverHeaderLoadedRef = useRef(false); | |||
| /** 從卡片列表進入明細後返回時保留分頁 */ | |||
| const [pickerListPage, setPickerListPage] = useState(0); | |||
| const [pickerListPageSize] = useState(6); | |||
| const [pickerSearchFilters, setPickerSearchFilters] = useState<PickerCardListFilters>(DEFAULT_PICKER_CARD_LIST_FILTERS); | |||
| const [pickerAppliedFilters, setPickerAppliedFilters] = useState<PickerCardListFilters>(DEFAULT_PICKER_CARD_LIST_FILTERS); | |||
| const [pickerLocationPick, setPickerLocationPick] = useState<PickerLocationPick>(EMPTY_PICKER_LOCATION); | |||
| const [snackbar, setSnackbar] = useState<{ | |||
| open: boolean; | |||
| message: string; | |||
| severity: "success" | "error" | "warning" | |||
| const [selectedRound, setSelectedRound] = useState<StockTakeRoundCard | null>(null); | |||
| const [switchableRounds, setSwitchableRounds] = useState<StockTakeRoundCard[]>([]); | |||
| const [roundListNonce, setRoundListNonce] = useState(0); | |||
| const [roundListYearMonth, setRoundListYearMonth] = useState(() => dayjs().format("YYYY-MM")); | |||
| const [batchButtonHost, setBatchButtonHost] = useState<HTMLDivElement | null>(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 ( | |||
| <Box> | |||
| @@ -152,115 +229,204 @@ const StockTakeTab: React.FC = () => { | |||
| ) | |||
| ) : null} | |||
| <Box sx={{ display: showingPickerDetail ? "none" : "block" }}> | |||
| {isAdmin && ( | |||
| <Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 1 }}> | |||
| <Typography | |||
| component="label" | |||
| htmlFor="stock-take-qty-gap-warn" | |||
| sx={{ m: 0, height: 40, fontSize: 18, fontWeight: 500, lineHeight: "40px" }} | |||
| > | |||
| {t("qtyGapWarnPercent")} | |||
| </Typography> | |||
| <TextField | |||
| id="stock-take-qty-gap-warn" | |||
| size="small" | |||
| type="number" | |||
| value={qtyGapDraft} | |||
| onChange={(e) => 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", | |||
| }, | |||
| }} | |||
| /> | |||
| <Button | |||
| size="small" | |||
| variant="outlined" | |||
| disabled={qtyGapSaving} | |||
| onClick={saveQtyGapWarnPercent} | |||
| sx={{ height: 40 }} | |||
| > | |||
| {t("Save")} | |||
| </Button> | |||
| </Stack> | |||
| )} | |||
| <Tabs | |||
| value={tabValue} | |||
| onChange={(e, newValue) => { | |||
| setTabValue(newValue); | |||
| if (newValue === 0) { | |||
| setViewScope("picker"); | |||
| } else { | |||
| setViewScope("approver-all"); | |||
| } | |||
| }} | |||
| sx={{ mb: 2 }} | |||
| > | |||
| <Tab label={t("Picker")} /> | |||
| <Tab label={t("Approver Pending")} /> | |||
| <Tab label={t("Approver Approved")} /> | |||
| </Tabs> | |||
| {isAdmin && ( | |||
| <Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 1 }}> | |||
| <Typography | |||
| component="label" | |||
| htmlFor="stock-take-qty-gap-warn" | |||
| sx={{ m: 0, height: 40, fontSize: 18, fontWeight: 500, lineHeight: "40px" }} | |||
| > | |||
| {t("qtyGapWarnPercent")} | |||
| </Typography> | |||
| <TextField | |||
| id="stock-take-qty-gap-warn" | |||
| size="small" | |||
| type="number" | |||
| value={qtyGapDraft} | |||
| onChange={(e) => 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", | |||
| }, | |||
| }} | |||
| /> | |||
| <Button | |||
| size="small" | |||
| variant="outlined" | |||
| disabled={qtyGapSaving} | |||
| onClick={saveQtyGapWarnPercent} | |||
| sx={{ height: 40 }} | |||
| > | |||
| {t("Save")} | |||
| </Button> | |||
| </Stack> | |||
| )} | |||
| {tabValue === 0 && ( | |||
| <PickerCardList | |||
| page={pickerListPage} | |||
| pageSize={pickerListPageSize} | |||
| onListPageChange={setPickerListPage} | |||
| searchFilters={pickerSearchFilters} | |||
| appliedFilters={pickerAppliedFilters} | |||
| onSearchFiltersChange={setPickerSearchFilters} | |||
| onAppliedFiltersChange={setPickerAppliedFilters} | |||
| locationPick={pickerLocationPick} | |||
| onLocationPickChange={setPickerLocationPick} | |||
| onCardClick={(session) => { | |||
| setViewScope("picker"); | |||
| handleCardClick(session); | |||
| }} | |||
| onReStockTakeClick={handleReStockTakeClick} | |||
| /> | |||
| )} | |||
| {approverHeaderLoadedRef.current || tabValue === 1 || tabValue === 2 ? ( | |||
| <> | |||
| <Box sx={{ display: tabValue === 1 ? "block" : "none" }}> | |||
| {approverLoading && !approverSession ? ( | |||
| <Box sx={{ display: "flex", justifyContent: "center", p: 3 }}> | |||
| <CircularProgress /> | |||
| </Box> | |||
| ) : approverSession ? ( | |||
| <ApproverStockTakeAll | |||
| selectedSession={approverSession} | |||
| mode="pending" | |||
| onSnackbar={handleSnackbar} | |||
| {!selectedRound ? ( | |||
| <> | |||
| <StockTakeRoundCardList | |||
| isAdmin={isAdmin} | |||
| refreshNonce={roundListNonce} | |||
| yearMonth={roundListYearMonth} | |||
| onYearMonthChange={setRoundListYearMonth} | |||
| creating={showCreateOnRoundPage} | |||
| onEnterRound={(card, listRounds) => { | |||
| setSwitchableRounds(listRounds); | |||
| void enterRound(card, listRounds); | |||
| }} | |||
| onCreateClick={() => setShowCreateOnRoundPage(true)} | |||
| /> | |||
| {showCreateOnRoundPage ? ( | |||
| <PickerCardList | |||
| page={0} | |||
| pageSize={pickerListPageSize} | |||
| onListPageChange={() => 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 ? ( | |||
| <Typography variant="body2" color="text.secondary"> | |||
| {t("No data")} | |||
| </Typography> | |||
| ) : null} | |||
| </Box> | |||
| <Box sx={{ display: tabValue === 2 ? "block" : "none" }}> | |||
| {approverSession ? ( | |||
| <ApproverStockTakeAll | |||
| selectedSession={approverSession} | |||
| mode="approved" | |||
| onSnackbar={handleSnackbar} | |||
| </> | |||
| ) : ( | |||
| <> | |||
| <Typography variant="h6" fontWeight={700} sx={{ mb: 1 }}> | |||
| {switchableRounds.length > 1 ? ( | |||
| <FormControl size="small" sx={{ minWidth: 220 }}> | |||
| <InputLabel>{t("Stock Take Round")}</InputLabel> | |||
| <Select | |||
| label={t("Stock Take Round")} | |||
| value={roundId} | |||
| onChange={(e) => handleSwitchRound(Number(e.target.value))} | |||
| > | |||
| {switchableRounds.map((card) => ( | |||
| <MenuItem key={card.stockTakeRoundId} value={card.stockTakeRoundId}> | |||
| {roundLabel(card)} | |||
| </MenuItem> | |||
| ))} | |||
| </Select> | |||
| </FormControl> | |||
| ) : ( | |||
| roundLabel(selectedRound) | |||
| )} | |||
| </Typography> | |||
| <Box sx={{ mb: 1 }}> | |||
| <Button variant="outlined" onClick={handleBackToRounds}> | |||
| {t("Back to rounds")} | |||
| </Button> | |||
| </Box> | |||
| <Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mb: 2 }}> | |||
| <Tabs | |||
| value={tabValue} | |||
| onChange={(e, newValue) => { | |||
| setTabValue(newValue); | |||
| if (newValue === 0) { | |||
| setViewScope("picker"); | |||
| } else { | |||
| setViewScope("approver-all"); | |||
| } | |||
| }} | |||
| sx={{ minHeight: 48 }} | |||
| > | |||
| <Tab label={t("Picker")} /> | |||
| <Tab label={t("Approver Pending")} /> | |||
| <Tab label={t("Approver Approved")} /> | |||
| </Tabs> | |||
| <Stack direction="row" spacing={1} alignItems="center"> | |||
| {isAdmin && selectedRound.open ? ( | |||
| <Button | |||
| variant="outlined" | |||
| color="warning" | |||
| disabled={closingRound} | |||
| onClick={handleCloseSelectedRound} | |||
| > | |||
| {closingRound ? <CircularProgress size={18} /> : t("Close round")} | |||
| </Button> | |||
| ) : null} | |||
| <Box ref={setBatchButtonHost} /> | |||
| </Stack> | |||
| </Stack> | |||
| {tabValue === 0 && ( | |||
| <PickerCardList | |||
| page={pickerListPage} | |||
| pageSize={pickerListPageSize} | |||
| onListPageChange={setPickerListPage} | |||
| searchFilters={pickerSearchFilters} | |||
| appliedFilters={pickerAppliedFilters} | |||
| onSearchFiltersChange={setPickerSearchFilters} | |||
| onAppliedFiltersChange={setPickerAppliedFilters} | |||
| locationPick={pickerLocationPick} | |||
| onLocationPickChange={setPickerLocationPick} | |||
| stockTakeRoundId={roundId} | |||
| onCardClick={(session) => { | |||
| setViewScope("picker"); | |||
| handleCardClick(session); | |||
| }} | |||
| onReStockTakeClick={handleReStockTakeClick} | |||
| onRoundsChanged={() => { | |||
| setRoundListNonce((n) => n + 1); | |||
| handleBackToRounds(); | |||
| }} | |||
| /> | |||
| ) : approverHeaderLoadedRef.current && !approverLoading ? ( | |||
| <Typography variant="body2" color="text.secondary"> | |||
| {t("No data")} | |||
| </Typography> | |||
| )} | |||
| {tabValue === 1 || tabValue === 2 ? ( | |||
| <> | |||
| <Box sx={{ display: tabValue === 1 ? "block" : "none" }}> | |||
| {approverLoading && !approverSession ? ( | |||
| <Box sx={{ display: "flex", justifyContent: "center", p: 3 }}> | |||
| <CircularProgress /> | |||
| </Box> | |||
| ) : approverSession ? ( | |||
| <ApproverStockTakeAll | |||
| selectedSession={approverSession} | |||
| mode="pending" | |||
| onSnackbar={handleSnackbar} | |||
| batchButtonHost={tabValue === 1 ? batchButtonHost : null} | |||
| /> | |||
| ) : ( | |||
| <Typography variant="body2" color="text.secondary"> | |||
| {t("No data")} | |||
| </Typography> | |||
| )} | |||
| </Box> | |||
| <Box sx={{ display: tabValue === 2 ? "block" : "none" }}> | |||
| {approverSession ? ( | |||
| <ApproverStockTakeAll | |||
| selectedSession={approverSession} | |||
| mode="approved" | |||
| onSnackbar={handleSnackbar} | |||
| /> | |||
| ) : !approverLoading ? ( | |||
| <Typography variant="body2" color="text.secondary"> | |||
| {t("No data")} | |||
| </Typography> | |||
| ) : null} | |||
| </Box> | |||
| </> | |||
| ) : null} | |||
| </Box> | |||
| </> | |||
| ) : null} | |||
| </> | |||
| )} | |||
| </Box> | |||
| <Snackbar | |||
| @@ -276,4 +442,4 @@ const StockTakeTab: React.FC = () => { | |||
| ); | |||
| }; | |||
| export default StockTakeTab; | |||
| export default StockTakeTab; | |||
| @@ -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", | |||
| @@ -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": "查看重新盤點", | |||