|
- "use client";
-
- import {
- Box,
- Button,
- Card,
- CardContent,
- CardActions,
- Stack,
- Typography,
- Chip,
- CircularProgress,
- TablePagination,
- Grid,
- Dialog,
- DialogTitle,
- DialogContent,
- DialogActions,
- TextField,
- InputAdornment,
- FormControl,
- InputLabel,
- Select,
- MenuItem,
- Badge,
- IconButton,
- Tooltip,
- Drawer,
- Divider,
- Tab,
- Tabs,
- ToggleButton,
- ToggleButtonGroup,
- Checkbox,
- } from "@mui/material";
- import SearchIcon from "@mui/icons-material/Search";
- import NotificationsNoneOutlinedIcon from "@mui/icons-material/NotificationsNoneOutlined";
- import { useRouter } from "next/navigation";
- import { SessionWithTokens } from "@/config/authConfig";
- import { useSession } from "next-auth/react";
- import { useState, useCallback, useEffect, useRef, useMemo } from "react";
- 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";
- import dayjs, { type Dayjs } from "dayjs";
- import { DatePicker, LocalizationProvider } from "@mui/x-date-pickers";
- import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
- import {
- OUTPUT_DATE_FORMAT,
- OUTPUT_DATETIME_FORMAT,
- dayjsToDateTimeString,
- } from "@/app/utils/formatUtil";
- import { AUTH } from "@/authorities";
-
- const FLOOR_GROUP_UNSET = "__UNSET__";
- /** 與 controllQRcodescanWarehouse 選倉相同:樓層 → 倉庫 → 倉位 */
- const LOCATION_ALL = "ALL";
- const LOCATION_BUTTONS_PER_ROW = 15;
- const LOCATION_BUTTON_WIDTH = 80;
- const LOCATION_NONE_SECTION = "__NONE__";
-
- function chunkList<T>(items: T[], size: number): T[][] {
- const rows: T[][] = [];
- for (let i = 0; i < items.length; i += size) {
- rows.push(items.slice(i, i + size));
- }
- 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;
- }
-
- function compareLocationLabel(a: string, b: string): number {
- return a.localeCompare(b, undefined, { numeric: true, sensitivity: "base" });
- }
-
- function matchesPickerCardFilters(
- session: AllPickedStockTakeListReponse,
- filters: PickerCardListFilters,
- ): boolean {
- if (filters.sectionDescription && filters.sectionDescription !== "All") {
- if (session.stockTakeSectionDescription !== filters.sectionDescription) return false;
- }
- const sectionQuery = filters.stockTakeSession?.trim() ?? "";
- if (sectionQuery) {
- const parts = sectionQuery.split(",").map((part) => part.trim()).filter(Boolean);
- const name = (session.stockTakeSession || "").toLowerCase();
- const hit = parts.some((part) => {
- const key = part.toLowerCase();
- return name === key || name.includes(key);
- });
- if (!hit) return false;
- }
- if (filters.status && filters.status !== "All") {
- const normalized = filters.status.trim().toLowerCase();
- 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;
- if (filters.storeId && filters.storeId !== "All") {
- const floor = filters.storeId.trim().toLowerCase();
- const itemFloor = (session.storeId || "").toLowerCase();
- if (itemFloor !== floor && !itemFloor.includes(floor)) return false;
- }
- return true;
- }
- /** 移動超過此距離才視為框選(Windows 橡皮筋),否則視為單擊 */
- const MARQUEE_DRAG_THRESHOLD_PX = 4;
- /** 左側樓層列表超過此數量才啟用捲動 */
- const FLOOR_LIST_SCROLL_THRESHOLD = 8;
-
- const SEARCH_FIELD_HEIGHT = 56;
- /** 下拉最多先顯示約 8 列,其餘在選單內捲動 */
- const SEARCH_MENU_MAX_HEIGHT = 320;
- const searchMenuProps = {
- PaperProps: {
- sx: { maxHeight: SEARCH_MENU_MAX_HEIGHT },
- },
- } as const;
-
- const searchFieldSx = {
- "& .MuiOutlinedInput-root": {
- height: SEARCH_FIELD_HEIGHT,
- minHeight: SEARCH_FIELD_HEIGHT,
- boxSizing: "border-box",
- alignItems: "center",
- },
- "& .MuiSelect-select": {
- height: `${SEARCH_FIELD_HEIGHT}px !important`,
- minHeight: "0 !important",
- display: "flex",
- alignItems: "center",
- boxSizing: "border-box",
- py: "0 !important",
- },
- } as const;
-
- function selectedSectionCodes(value: string): string[] {
- if (!value.trim() || value.trim() === LOCATION_NONE_SECTION) return [];
- return value
- .split(",")
- .map((part) => part.trim())
- .filter((part) => part && part !== LOCATION_NONE_SECTION);
- }
-
- const createDialogCompactInputSx = {
- mt: 0,
- mb: 0,
- "& .MuiInputBase-root": {
- bgcolor: "background.paper",
- },
- "& .MuiOutlinedInput-input": {
- py: "8.5px",
- },
- "& .MuiInputAdornment-positionStart": {
- marginRight: 0.5,
- },
- "& .MuiInputBase-input::placeholder": {
- opacity: 1, // MUI 預設 opacity 較低,設 1 顏色才準
- color: "text.secondary", // 中等灰;要更淡用 "text.disabled"
- },
- } as const;
-
- type MarqueeRect = { left: number; top: number; width: number; height: number };
-
- function rectsIntersect(a: DOMRect, b: DOMRect): boolean {
- return !(a.right < b.left || a.left > b.right || a.bottom < b.top || a.top > b.bottom);
- }
-
- function clientMarqueeRect(startX: number, startY: number, endX: number, endY: number): DOMRect {
- const left = Math.min(startX, endX);
- const top = Math.min(startY, endY);
- const right = Math.max(startX, endX);
- const bottom = Math.max(startY, endY);
- return { left, top, right, bottom, width: right - left, height: bottom - top } as DOMRect;
- }
-
- interface PickerCardListProps {
- /** 由父層保存,從明細返回時仍回到同一頁 */
- page: number;
- pageSize: number;
- onListPageChange: (page: number) => void;
- onCardClick: (session: AllPickedStockTakeListReponse) => void;
- onReStockTakeClick: (session: AllPickedStockTakeListReponse) => void;
- searchFilters: PickerCardListFilters;
- appliedFilters: PickerCardListFilters;
- onSearchFiltersChange: (next: PickerCardListFilters) => void;
- onAppliedFiltersChange: (next: PickerCardListFilters) => void;
- /** 樓層/倉庫/倉位,由父層保存,從明細返回時仍顯示 */
- locationPick: PickerLocationPick;
- onLocationPickChange: (next: PickerLocationPick) => void;
- stockTakeRoundId?: number | null;
- hideSectionList?: boolean;
- autoOpenCreate?: boolean;
- onCreateDialogClose?: () => void;
- onRoundsChanged?: () => void;
- }
-
- export type PickerSearchKind = "location" | "section";
-
- export type PickerCardListFilters = {
- sectionDescription: string;
- stockTakeSession: string;
- status: string;
- area: string;
- storeId: string;
- searchKind: PickerSearchKind;
- };
-
- function locationQueryFromPick(
- pick: PickerLocationPick,
- warehouses: WarehouseResult[],
- ): { stockTakeSession: string; storeId: string } {
- const useSections =
- (pick.warehouse !== "" && pick.warehouse !== LOCATION_ALL) ||
- (pick.area !== "" && pick.area !== LOCATION_ALL) ||
- (pick.slot !== "" && pick.slot !== LOCATION_ALL);
- const sections = new Set<string>();
- if (useSections) {
- warehouses.forEach((w) => {
- const section = w.stockTakeSection?.trim();
- if (!section) return;
- if (pick.storeId && pick.storeId !== LOCATION_ALL && (w.store_id ?? "").trim() !== pick.storeId) return;
- if (pick.warehouse && pick.warehouse !== LOCATION_ALL && (w.warehouse ?? "").trim() !== pick.warehouse) return;
- if (pick.area && pick.area !== LOCATION_ALL && (w.area ?? "").trim() !== pick.area) return;
- if (pick.slot && pick.slot !== LOCATION_ALL && (w.slot ?? "").trim() !== pick.slot) return;
- sections.add(section);
- });
- }
- return {
- stockTakeSession: useSections
- ? Array.from(sections).sort(compareLocationLabel).join(",") || LOCATION_NONE_SECTION
- : "",
- storeId: !pick.storeId || pick.storeId === LOCATION_ALL || useSections ? "All" : pick.storeId,
- };
- }
-
- function appliedPickerFilters(
- filters: PickerCardListFilters,
- pick: PickerLocationPick,
- warehouses: WarehouseResult[],
- ): PickerCardListFilters {
- const kind = filters.searchKind ?? "location";
- if (kind === "section") {
- return {
- sectionDescription: filters.sectionDescription || "All",
- stockTakeSession: filters.stockTakeSession || "",
- status: filters.status || "All",
- area: "",
- storeId: "All",
- searchKind: "section",
- };
- }
- const location = locationQueryFromPick(pick, warehouses);
- return {
- sectionDescription: "All",
- stockTakeSession: location.stockTakeSession,
- status: filters.status || "All",
- area: "",
- storeId: location.storeId,
- searchKind: "location",
- };
- }
-
- export type PickerLocationPick = { storeId: string; warehouse: string; area: string; slot: string };
-
- export const EMPTY_PICKER_LOCATION: PickerLocationPick = { storeId: "", warehouse: "", area: "", slot: "" };
-
- const PickerCardList: React.FC<PickerCardListProps> = ({
- page,
- pageSize,
- onListPageChange,
- onCardClick,
- onReStockTakeClick,
- searchFilters,
- appliedFilters,
- onSearchFiltersChange,
- onAppliedFiltersChange,
- locationPick,
- onLocationPickChange,
- stockTakeRoundId = null,
- hideSectionList = false,
- autoOpenCreate = false,
- onCreateDialogClose,
- onRoundsChanged,
- }) => {
- const { t } = useTranslation(["stockTake", "common"]);
- const router = useRouter();
- dayjs.extend(duration);
-
- const [loading, setLoading] = useState(true);
- /** 這一輪全部卡片,篩選在前端做,點選不再重打 API */
- const [roundRecords, setRoundRecords] = useState<AllPickedStockTakeListReponse[]>([]);
- const { data: session } = useSession() as { data: SessionWithTokens | null };
- const abilities = session?.abilities ?? session?.user?.abilities ?? [];
- const canManageStockTake = abilities.some((a) => a.trim() === AUTH.ADMIN);
- /** 建立盤點後若仍在 page 0,仍強制重新載入 */
- const [listRefreshNonce, setListRefreshNonce] = useState(0);
- 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);
- const [missingSectionItems, setMissingSectionItems] = useState<MissingStockTakeSectionIssueItem[]>([]);
- const [missingSectionIssuesLimit, setMissingSectionIssuesLimit] = useState(50);
- const [missingSectionIssuesLoading, setMissingSectionIssuesLoading] = useState(false);
- const [createDialogSelectedSections, setCreateDialogSelectedSections] = useState<string[]>([]);
- const [createDialogRoundName, setCreateDialogRoundName] = useState("");
- const [createDialogPlanStart, setCreateDialogPlanStart] = useState<Dayjs | null>(() => dayjs());
- /** 建立盤點對話框:目前選中的樓層分頁(對應 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);
- const createSectionGridScrollRef = useRef<HTMLDivElement>(null);
- const createSectionCardRefs = useRef<Map<string, HTMLElement>>(new Map());
- const marqueeDragRef = useRef<{
- pointerId: number;
- startClientX: number;
- startClientY: number;
- currentClientX: number;
- currentClientY: number;
- baseSelection: Set<string>;
- isMarquee: boolean;
- pointerDownSection: string | null;
- sectionIds: string[];
- } | null>(null);
- const [marqueeRect, setMarqueeRect] = useState<MarqueeRect | null>(null);
- const [marqueePreviewIds, setMarqueePreviewIds] = useState<Set<string>>(() => new Set());
- const [stockTakeSectionOptions, setStockTakeSectionOptions] = useState<string[]>([]);
- const [locationWarehouses, setLocationWarehouses] = useState<WarehouseResult[]>([]);
- /** 由選倉寫入的盤點區域字串;使用者改過區域欄後就不再覆寫 */
- const locationOwnedSectionRef = useRef<string | null>(
- (locationPick.warehouse && locationPick.warehouse !== LOCATION_ALL) ||
- (locationPick.area && locationPick.area !== LOCATION_ALL) ||
- (locationPick.slot && locationPick.slot !== LOCATION_ALL)
- ? searchFilters.stockTakeSession
- : null,
- );
-
- const statusOptions = ["All", "pending", "stockTaking", "approving", "completed", "notMatch"];
-
- const searchKind = searchFilters.searchKind ?? "location";
-
- const handleResetSearch = () => {
- const resetFilters: PickerCardListFilters = {
- sectionDescription: "All",
- stockTakeSession: "",
- status: "All",
- area: "",
- storeId: "All",
- searchKind,
- };
- onSearchFiltersChange(resetFilters);
- onAppliedFiltersChange(resetFilters);
- onLocationPickChange(EMPTY_PICKER_LOCATION);
- locationOwnedSectionRef.current = null;
- onListPageChange(0);
- };
-
- useEffect(() => {
- let cancelled = false;
- fetchWarehouseListClient()
- .then((rows) => {
- if (!cancelled) setLocationWarehouses(Array.isArray(rows) ? rows : []);
- })
- .catch((e) => {
- console.error("Failed to load warehouses for stock take location filter:", e);
- if (!cancelled) setLocationWarehouses([]);
- });
- return () => {
- cancelled = true;
- };
- }, []);
-
- 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: !(stockTakeRoundId != null && stockTakeRoundId > 0),
- stockTakeRoundId: stockTakeRoundId != null && stockTakeRoundId > 0 ? stockTakeRoundId : undefined,
- })
- .then((res) => {
- if (cancelled) return;
- setRoundRecords(Array.isArray(res.records) ? res.records : []);
- })
- .catch((e) => {
- console.error("Failed to load this round's stock take sections:", e);
- if (!cancelled) setRoundRecords([]);
- })
- .finally(() => {
- if (!cancelled) setLoading(false);
- });
- return () => {
- cancelled = true;
- };
- }, [hideSectionList, listRefreshNonce, stockTakeRoundId]);
-
- const roundSectionCodes = useMemo(
- () =>
- Array.from(
- new Set(
- roundRecords
- .map((row) => row.stockTakeSession?.trim())
- .filter((code): code is string => Boolean(code)),
- ),
- ),
- [roundRecords],
- );
-
- const filteredSessions = useMemo(
- () => roundRecords.filter((session) => matchesPickerCardFilters(session, appliedFilters)),
- [appliedFilters, roundRecords],
- );
- const total = filteredSessions.length;
- const stockTakeSessions = useMemo(
- () => filteredSessions.slice(page * pageSize, page * pageSize + pageSize),
- [filteredSessions, page, pageSize],
- );
-
- const stockTakeLocationWarehouses = useMemo(() => {
- if (roundSectionCodes.length === 0) return [];
- const inRound = new Set(roundSectionCodes);
- return locationWarehouses.filter((w) => {
- const section = w.stockTakeSection?.trim();
- return Boolean(section && inRound.has(section));
- });
- }, [locationWarehouses, roundSectionCodes]);
-
- const locationFloorOptions = useMemo(() => {
- const set = new Set<string>();
- stockTakeLocationWarehouses.forEach((w) => {
- const floor = w.store_id?.trim();
- if (floor) set.add(floor);
- });
- return withAllOption(Array.from(set).sort(compareLocationLabel));
- }, [stockTakeLocationWarehouses]);
-
- const locationWarehouseOptions = useMemo(() => {
- if (!locationPick.storeId) return [];
- const set = new Set<string>();
- stockTakeLocationWarehouses.forEach((w) => {
- if (locationPick.storeId !== LOCATION_ALL && (w.store_id ?? "").trim() !== locationPick.storeId) return;
- const zone = w.warehouse?.trim();
- if (zone) set.add(zone);
- });
- return withAllOption(Array.from(set).sort(compareLocationLabel));
- }, [stockTakeLocationWarehouses, locationPick.storeId]);
-
- const locationAreaOptions = useMemo(() => {
- if (!locationPick.storeId || !locationPick.warehouse) return [];
- const set = new Set<string>();
- stockTakeLocationWarehouses.forEach((w) => {
- if (locationPick.storeId !== LOCATION_ALL && (w.store_id ?? "").trim() !== locationPick.storeId) return;
- if (locationPick.warehouse !== LOCATION_ALL && (w.warehouse ?? "").trim() !== locationPick.warehouse) return;
- const area = w.area?.trim();
- if (area) set.add(area);
- });
- return withAllOption(Array.from(set).sort(compareLocationLabel));
- }, [stockTakeLocationWarehouses, locationPick.storeId, locationPick.warehouse]);
-
- const locationSlotOptions = useMemo(() => {
- if (!locationPick.storeId || !locationPick.warehouse || !locationPick.area) return [];
- const set = new Set<string>();
- stockTakeLocationWarehouses.forEach((w) => {
- if (locationPick.storeId !== LOCATION_ALL && (w.store_id ?? "").trim() !== locationPick.storeId) return;
- if (locationPick.warehouse !== LOCATION_ALL && (w.warehouse ?? "").trim() !== locationPick.warehouse) return;
- if (locationPick.area !== LOCATION_ALL && (w.area ?? "").trim() !== locationPick.area) return;
- const slot = w.slot?.trim();
- if (slot) set.add(slot);
- });
- return withAllOption(Array.from(set).sort(compareLocationLabel));
- }, [stockTakeLocationWarehouses, locationPick.storeId, locationPick.warehouse, locationPick.area]);
-
- const locationWarehouseRows = useMemo(
- () => chunkList(locationWarehouseOptions, LOCATION_BUTTONS_PER_ROW),
- [locationWarehouseOptions],
- );
- const locationAreaRows = useMemo(
- () => chunkList(locationAreaOptions, LOCATION_BUTTONS_PER_ROW),
- [locationAreaOptions],
- );
- const locationSlotRows = useMemo(
- () => chunkList(locationSlotOptions, LOCATION_BUTTONS_PER_ROW),
- [locationSlotOptions],
- );
-
- const applyFilters = useCallback((next: PickerCardListFilters) => {
- const withKind = { ...next, searchKind: next.searchKind ?? "location" };
- onSearchFiltersChange(withKind);
- onAppliedFiltersChange(appliedPickerFilters(withKind, locationPick, stockTakeLocationWarehouses));
- onListPageChange(0);
- }, [locationPick, onAppliedFiltersChange, onListPageChange, onSearchFiltersChange, stockTakeLocationWarehouses]);
-
- /** 依盤點區域搜索時用這一輪全部區域;依位置搜索時下拉不顯示 */
- const searchSectionCodes = useMemo(() => {
- if (searchKind === "section" || !locationPick.storeId) return [...roundSectionCodes].sort(compareLocationLabel);
- const codes = new Set<string>();
- stockTakeLocationWarehouses.forEach((w) => {
- const section = w.stockTakeSection?.trim();
- if (!section) return;
- if (locationPick.storeId !== LOCATION_ALL && (w.store_id ?? "").trim() !== locationPick.storeId) return;
- if (locationPick.warehouse && locationPick.warehouse !== LOCATION_ALL && (w.warehouse ?? "").trim() !== locationPick.warehouse) return;
- if (locationPick.area && locationPick.area !== LOCATION_ALL && (w.area ?? "").trim() !== locationPick.area) return;
- if (locationPick.slot && locationPick.slot !== LOCATION_ALL && (w.slot ?? "").trim() !== locationPick.slot) return;
- codes.add(section);
- });
- return Array.from(codes).sort(compareLocationLabel);
- }, [locationPick, roundSectionCodes, searchKind, stockTakeLocationWarehouses]);
-
- const sectionDescriptionByCode = useMemo(() => {
- const map = new Map<string, string>();
- stockTakeSectionRows.forEach((row) => {
- const section = row.stockTakeSection?.trim();
- const text = row.stockTakeSectionDescription?.trim();
- if (section && text) map.set(section, text);
- });
- roundRecords.forEach((session) => {
- const section = session.stockTakeSession?.trim();
- const text = session.stockTakeSectionDescription?.trim();
- if (section && text && !map.has(section)) map.set(section, text);
- });
- return map;
- }, [stockTakeSectionRows, roundRecords]);
-
- const searchSectionDescriptionOptions = useMemo(() => {
- const picked = selectedSectionCodes(searchFilters.stockTakeSession).filter((code) =>
- searchSectionCodes.includes(code)
- );
- const source = picked.length > 0 ? picked : searchSectionCodes;
- const desc = new Set<string>();
- source.forEach((code) => {
- const text = sectionDescriptionByCode.get(code);
- if (text) desc.add(text);
- });
- return Array.from(desc).sort((a, b) => a.localeCompare(b));
- }, [searchFilters.stockTakeSession, searchSectionCodes, sectionDescriptionByCode]);
-
- const sectionCodeMenuOptions = useMemo(() => {
- const descFilter = searchFilters.sectionDescription !== "All" ? searchFilters.sectionDescription : "";
- if (!descFilter) return searchSectionCodes;
- return searchSectionCodes.filter((code) => sectionDescriptionByCode.get(code) === descFilter);
- }, [searchFilters.sectionDescription, searchSectionCodes, sectionDescriptionByCode]);
-
- useEffect(() => {
- if (searchKind !== "section") return;
- const current = searchFilters.sectionDescription;
- if (!current || current === "All") return;
- if (roundSectionCodes.length === 0) return;
- if (searchSectionDescriptionOptions.includes(current)) return;
- applyFilters({ ...searchFilters, sectionDescription: "All", searchKind: "section" });
- }, [
- applyFilters,
- roundSectionCodes.length,
- searchFilters,
- searchKind,
- searchSectionDescriptionOptions,
- ]);
-
- const applyLocationPick = useCallback(
- (pick: PickerLocationPick) => {
- const location = locationQueryFromPick(pick, stockTakeLocationWarehouses);
- locationOwnedSectionRef.current = location.stockTakeSession || null;
- const next: PickerCardListFilters = {
- ...searchFilters,
- searchKind: "location",
- };
- onSearchFiltersChange(next);
- onAppliedFiltersChange(appliedPickerFilters(next, pick, stockTakeLocationWarehouses));
- onListPageChange(0);
- },
- [onAppliedFiltersChange, onListPageChange, onSearchFiltersChange, searchFilters, stockTakeLocationWarehouses],
- );
-
- useEffect(() => {
- if (searchKind !== "location") return;
- if (!locationPick.storeId && !locationPick.warehouse && !locationPick.area && !locationPick.slot) return;
- if (roundSectionCodes.length === 0 || locationWarehouses.length === 0) return;
- const floorOk = !locationPick.storeId || locationFloorOptions.includes(locationPick.storeId);
- const warehouseOk = !locationPick.warehouse || locationWarehouseOptions.includes(locationPick.warehouse);
- const areaOk = !locationPick.area || locationAreaOptions.includes(locationPick.area);
- const slotOk = !locationPick.slot || locationSlotOptions.includes(locationPick.slot);
- if (floorOk && warehouseOk && areaOk && slotOk) return;
- onLocationPickChange(EMPTY_PICKER_LOCATION);
- applyLocationPick(EMPTY_PICKER_LOCATION);
- }, [applyLocationPick, locationAreaOptions, locationFloorOptions, locationPick, locationSlotOptions, locationWarehouseOptions, locationWarehouses.length, onLocationPickChange, roundSectionCodes.length]);
-
- useEffect(() => {
- let cancelled = false;
- getLatestStockTakeRoundMeta()
- .then((meta) => {
- if (cancelled) return;
- if (meta?.planStartDate) {
- setGlobalRoundPlanStartDate(dayjs(meta.planStartDate).format(OUTPUT_DATE_FORMAT));
- } else {
- setGlobalRoundPlanStartDate(null);
- }
- })
- .catch((e) => {
- console.error("Failed to load latest stock take round meta:", e);
- if (!cancelled) setGlobalRoundPlanStartDate(null);
- });
- return () => {
- cancelled = true;
- };
- }, [listRefreshNonce]);
-
- //const startIdx = page * PER_PAGE;
- //const paged = stockTakeSessions.slice(startIdx, startIdx + PER_PAGE);
-
- useEffect(() => {
- if (openConfirmDialog) {
- setCreateDialogSelectedSections([]);
- setCreateDialogRoundName("");
- setCreateDialogPlanStart(dayjs());
- setCreateFloorTabIndex(0);
- setCreateDialogSearchQuery("");
- marqueeDragRef.current = null;
- setMarqueeRect(null);
- setMarqueePreviewIds(new Set());
- setOpenCreateStockTakeSummaryConfirm(false);
- }
- }, [openConfirmDialog]);
-
- const loadMissingStockTakeSectionIssues = useCallback(async () => {
- setMissingSectionIssuesLoading(true);
- try {
- const res = await fetchMissingStockTakeSectionIssues(50);
- setMissingSectionCount(res.count);
- setMissingSectionItems(Array.isArray(res.items) ? res.items : []);
- setMissingSectionIssuesLimit(res.limit ?? 50);
- } catch (e) {
- console.error("Failed to load missing stock take section issues:", e);
- setMissingSectionCount(0);
- setMissingSectionItems([]);
- } finally {
- setMissingSectionIssuesLoading(false);
- }
- }, []);
-
- useEffect(() => {
- if (!openConfirmDialog) return;
- 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);
- router.push("/settings/warehouse");
- }, [router]);
-
- const createStockTakeSummarySections = useMemo(() => {
- return [...createDialogSelectedSections].sort((a, b) => a.localeCompare(b));
- }, [createDialogSelectedSections]);
-
- const handleOpenCreateStockTakeSummaryConfirm = useCallback(() => {
- if (createDialogPlanStart == null || !createDialogPlanStart.isValid()) return;
- if (createDialogPlanStart.startOf("day").isBefore(dayjs().startOf("day"))) return;
- if (createMode === "item") {
- if (createDialogSelectedItems.length === 0) return;
- } else if (createDialogSelectedSections.length === 0) {
- return;
- }
- setOpenCreateStockTakeSummaryConfirm(true);
- }, [
- createDialogPlanStart,
- createDialogSelectedItems.length,
- createDialogSelectedSections.length,
- createMode,
- ]);
-
- /** 盤點區域 → 樓層:優先 API(warehouse 帶 storeId),否則用列表卡片上的 storeId */
- const sectionsByStore = useMemo(() => {
- const sectionToFloor = new Map<string, string>();
- stockTakeSectionRows.forEach((row) => {
- const sec = row.stockTakeSection?.trim();
- if (!sec) return;
- const sid = row.storeId?.trim();
- if (sid) sectionToFloor.set(sec, sid);
- });
- roundRecords.forEach((session) => {
- const sec = session.stockTakeSession?.trim();
- if (!sec) return;
- const sid = session.storeId?.trim();
- if (sid && !sectionToFloor.has(sec)) sectionToFloor.set(sec, sid);
- });
-
- const allSecs = new Set<string>();
- stockTakeSectionRows.forEach((r) => {
- const s = r.stockTakeSection?.trim();
- if (s) allSecs.add(s);
- });
- stockTakeSectionOptions.forEach((s) => {
- const x = s.trim();
- if (x) allSecs.add(x);
- });
-
- const byFloor = new Map<string, Set<string>>();
- allSecs.forEach((sec) => {
- const floorKey = sectionToFloor.get(sec)?.trim() || FLOOR_GROUP_UNSET;
- if (!byFloor.has(floorKey)) byFloor.set(floorKey, new Set());
- byFloor.get(floorKey)!.add(sec);
- });
-
- const out = new Map<string, string[]>();
- byFloor.forEach((set, key) => {
- out.set(key, Array.from(set).sort((a, b) => a.localeCompare(b)));
- });
- return out;
- }, [stockTakeSectionRows, roundRecords, stockTakeSectionOptions]);
-
- const sortedFloorKeys = useMemo(() => {
- const keys = Array.from(sectionsByStore.keys());
- keys.sort((a, b) => {
- if (a === FLOOR_GROUP_UNSET) return 1;
- if (b === FLOOR_GROUP_UNSET) return -1;
- return a.localeCompare(b, undefined, { numeric: true, sensitivity: "base" });
- });
- 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)
- .filter((sec) => !busySections.has(sec.trim()))
- .sort((a, b) => a.localeCompare(b));
- }, [busySections, sectionsByStore]);
-
- useEffect(() => {
- if (createFloorTabIndex >= sortedFloorKeys.length && sortedFloorKeys.length > 0) {
- setCreateFloorTabIndex(0);
- }
- }, [sortedFloorKeys.length, createFloorTabIndex]);
-
- const floorGroupLabel = useCallback(
- (storeKey: string) => (storeKey === FLOOR_GROUP_UNSET ? t("Floor unassigned") : storeKey),
- [t],
- );
-
- const toggleFloorSelectAll = useCallback(
- (storeKey: string) => {
- 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) {
- return prev.filter((s) => !list.includes(s));
- }
- const next = new Set(prev);
- list.forEach((s) => next.add(s));
- return Array.from(next).sort((a, b) => a.localeCompare(b));
- });
- },
- [busySections, sectionsByStore],
- );
-
- /** 與卡片標題括號內相同:描述 / 區域 / 樓層 */
- const getCreateDialogSectionMeta = useCallback(
- (section: string) => {
- const row = stockTakeSectionRows.find((r) => r.stockTakeSection?.trim() === section);
- const fromList = roundRecords.find((s) => s.stockTakeSession?.trim() === section);
- const desc = row?.stockTakeSectionDescription?.trim() || fromList?.stockTakeSectionDescription?.trim() || "";
- const area = row?.warehouseArea?.trim() || fromList?.warehouseArea?.trim() || "";
- // const store = row?.storeId?.trim() || fromList?.storeId?.trim() || "";
- const parts = [desc, area].filter((v) => Boolean(v && v.trim()));
- return parts.length ? parts.join(" / ") : "";
- },
- [stockTakeSectionRows, roundRecords],
- );
-
- const activeCreateFloorKey = useMemo(() => {
- if (sortedFloorKeys.length === 0) return null;
- return sortedFloorKeys[Math.min(createFloorTabIndex, sortedFloorKeys.length - 1)];
- }, [sortedFloorKeys, createFloorTabIndex]);
-
- const activeCreateFloorSections = useMemo(() => {
- if (activeCreateFloorKey == null) return [];
- return sectionsByStore.get(activeCreateFloorKey) ?? [];
- }, [activeCreateFloorKey, sectionsByStore]);
-
- const createDialogFilteredSections = useMemo(() => {
- const q = createDialogSearchQuery.trim().toLowerCase();
- if (!q) return activeCreateFloorSections;
- return activeCreateFloorSections.filter((section) => {
- const meta = getCreateDialogSectionMeta(section);
- return section.toLowerCase().includes(q) || meta.toLowerCase().includes(q);
- });
- }, [activeCreateFloorSections, createDialogSearchQuery, getCreateDialogSectionMeta]);
-
- const getSectionIdsInMarquee = useCallback(
- (startX: number, startY: number, endX: number, endY: number, sectionIds: string[]) => {
- const marquee = clientMarqueeRect(startX, startY, endX, endY);
- const hit = new Set<string>();
- sectionIds.forEach((section) => {
- const el = createSectionCardRefs.current.get(section);
- if (!el) return;
- if (rectsIntersect(el.getBoundingClientRect(), marquee)) {
- hit.add(section);
- }
- });
- return hit;
- },
- [],
- );
-
- const syncMarqueeDragUi = useCallback(() => {
- const drag = marqueeDragRef.current;
- const scrollEl = createSectionGridScrollRef.current;
- if (!drag || !scrollEl) return;
-
- const dist = Math.hypot(
- drag.currentClientX - drag.startClientX,
- drag.currentClientY - drag.startClientY,
- );
- if (!drag.isMarquee && dist < MARQUEE_DRAG_THRESHOLD_PX) {
- setMarqueeRect(null);
- setMarqueePreviewIds(new Set());
- return;
- }
-
- drag.isMarquee = true;
- const scrollRect = scrollEl.getBoundingClientRect();
- setMarqueeRect({
- left: Math.min(drag.startClientX, drag.currentClientX) - scrollRect.left + scrollEl.scrollLeft,
- top: Math.min(drag.startClientY, drag.currentClientY) - scrollRect.top + scrollEl.scrollTop,
- width: Math.abs(drag.currentClientX - drag.startClientX),
- height: Math.abs(drag.currentClientY - drag.startClientY),
- });
- setMarqueePreviewIds(
- getSectionIdsInMarquee(
- drag.startClientX,
- drag.startClientY,
- drag.currentClientX,
- drag.currentClientY,
- drag.sectionIds,
- ),
- );
- }, [getSectionIdsInMarquee]);
-
- const finishMarqueeDrag = useCallback(() => {
- const drag = marqueeDragRef.current;
- if (drag?.isMarquee) {
- const hit = getSectionIdsInMarquee(
- drag.startClientX,
- drag.startClientY,
- drag.currentClientX,
- drag.currentClientY,
- drag.sectionIds,
- );
- const merged = new Set(drag.baseSelection);
- 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;
- 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());
- }, [busySections, getSectionIdsInMarquee]);
-
- const handleCreateSectionGridPointerDown = useCallback(
- (e: React.PointerEvent<HTMLDivElement>) => {
- if (e.button !== 0) return;
- const scrollEl = createSectionGridScrollRef.current;
- if (!scrollEl || createDialogFilteredSections.length === 0) return;
-
- const cardEl = (e.target as HTMLElement).closest("[data-section-card]");
- const pointerDownSection = cardEl?.getAttribute("data-section") ?? null;
-
- e.preventDefault();
-
- marqueeDragRef.current = {
- pointerId: e.pointerId,
- startClientX: e.clientX,
- startClientY: e.clientY,
- currentClientX: e.clientX,
- currentClientY: e.clientY,
- baseSelection: new Set(createDialogSelectedSections),
- isMarquee: false,
- pointerDownSection,
- sectionIds: createDialogFilteredSections,
- };
-
- const onPointerMove = (ev: PointerEvent) => {
- const drag = marqueeDragRef.current;
- if (!drag || ev.pointerId !== drag.pointerId) return;
- drag.currentClientX = ev.clientX;
- drag.currentClientY = ev.clientY;
- syncMarqueeDragUi();
- };
-
- const onPointerEnd = (ev: PointerEvent) => {
- const drag = marqueeDragRef.current;
- if (!drag || ev.pointerId !== drag.pointerId) return;
- drag.currentClientX = ev.clientX;
- drag.currentClientY = ev.clientY;
- syncMarqueeDragUi();
- finishMarqueeDrag();
- window.removeEventListener("pointermove", onPointerMove);
- window.removeEventListener("pointerup", onPointerEnd);
- window.removeEventListener("pointercancel", onPointerEnd);
- try {
- scrollEl.releasePointerCapture(ev.pointerId);
- } catch {
- /* ignore */
- }
- };
-
- try {
- scrollEl.setPointerCapture(e.pointerId);
- } catch {
- /* ignore */
- }
- window.addEventListener("pointermove", onPointerMove);
- window.addEventListener("pointerup", onPointerEnd);
- window.addEventListener("pointercancel", onPointerEnd);
- },
- [createDialogFilteredSections, createDialogSelectedSections, finishMarqueeDrag, syncMarqueeDragUi],
- );
-
- const handleCreateStockTake = useCallback(async () => {
- if (createStockTakeInFlightRef.current) return;
- if (createMode === "item") {
- if (createDialogSelectedItems.length === 0) return;
- } else if (createDialogSelectedSections.length === 0) {
- return;
- }
- createStockTakeInFlightRef.current = true;
- setOpenCreateStockTakeSummaryConfirm(false);
- setOpenConfirmDialog(false);
- setCreating(true);
- try {
- const planStart =
- createDialogPlanStart != null
- ? dayjsToDateTimeString(createDialogPlanStart.startOf("day"))
- : dayjsToDateTimeString(dayjs().startOf("day"));
- 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;
-
- let message = `${t("Created")}: ${createdCount}, ${t("Skipped")}: ${skippedCount}`;
- if (errorCount > 0) {
- message += `, ${t("Errors")}: ${errorCount}`;
- }
-
- console.log(message);
-
- 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,
- 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()
- .then((sections) => {
- setStockTakeSectionRows(Array.isArray(sections) ? sections : []);
- const sectionSet = new Set<string>();
- sections.forEach((s) => {
- const section = s.stockTakeSection?.trim();
- if (section) sectionSet.add(section);
- });
- setStockTakeSectionOptions(Array.from(sectionSet).sort((a, b) => a.localeCompare(b)));
- })
- .catch((e) => {
- console.error("Failed to load section descriptions for filter:", e);
- })
- .finally(() => {
- setSectionsLoading(false);
- });
- }, []);
- useEffect(() => {
- setStockTakeSectionOptions((prev) => {
- const sectionSet = new Set<string>(prev);
- roundRecords.forEach((item) => {
- const section = item.stockTakeSession?.trim();
- if (section) sectionSet.add(section);
- });
- return Array.from(sectionSet).sort((a, b) => a.localeCompare(b));
- });
- }, [roundRecords]);
- const getStatusColor = (status: string) => {
- const statusLower = status.toLowerCase();
- if (statusLower === "completed") return "success";
- if (statusLower === "in_progress" || statusLower === "processing") return "primary";
- if (statusLower === "approving") return "info";
- if (statusLower === "stockTaking") return "primary";
- if (statusLower === "no_cycle") return "default";
- return "warning";
- };
- const TimeDisplay: React.FC<{ startTime: string | null; endTime: string | null }> = ({ startTime, endTime }) => {
- const [currentTime, setCurrentTime] = useState(dayjs());
-
- useEffect(() => {
- if (!endTime && startTime) {
- const interval = setInterval(() => {
- setCurrentTime(dayjs());
- }, 1000); // 每秒更新一次
-
- return () => clearInterval(interval);
- }
- }, [startTime, endTime]);
-
- if (endTime && startTime) {
- // 当有结束时间时,计算从开始到结束的持续时间
- const start = dayjs(startTime);
- const end = dayjs(endTime);
- const duration = dayjs.duration(end.diff(start));
- const hours = Math.floor(duration.asHours());
- const minutes = duration.minutes();
- const seconds = duration.seconds();
-
- return (
- <>
- {hours.toString().padStart(2, '0')}:{minutes.toString().padStart(2, '0')}:{seconds.toString().padStart(2, '0')}
- </>
- );
- } else if (startTime) {
- // 当没有结束时间时,显示实时计时器
- const start = dayjs(startTime);
- const duration = dayjs.duration(currentTime.diff(start));
- const hours = Math.floor(duration.asHours());
- const minutes = duration.minutes();
- const seconds = duration.seconds();
-
- return (
- <>
- {hours.toString().padStart(2, '0')}:{minutes.toString().padStart(2, '0')}:{seconds.toString().padStart(2, '0')}
- </>
- );
- } else {
- return <>-</>;
- }
- };
- const startTimeDisplay = (startTime: string | null) => {
- if (startTime) {
- const start = dayjs(startTime);
- return start.format("HH:mm");
- } else {
- return "-";
- }
- };
- const endTimeDisplay = (endTime: string | null) => {
- if (endTime) {
- const end = dayjs(endTime);
- return end.format("HH:mm");
- } else {
- return "-";
- }
- };
- const getCompletionRate = (session: AllPickedStockTakeListReponse): number => {
- if (session.totalInventoryLotNumber === 0) return 0;
- 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 }}>
- {t("Search Criteria")}
- </Typography>
- <Grid container spacing={2} alignItems="flex-start">
- <Grid item xs={12}>
- <Tabs
- value={searchKind}
- onChange={(_, value: PickerSearchKind) => applyFilters({ ...searchFilters, searchKind: value })}
- sx={{ minHeight: 48 }}
- >
- <Tab
- value="location"
- label={t("Search by location")}
- sx={{ minHeight: 48, py: 1, textTransform: "none" }}
- />
- <Tab
- value="section"
- label={t("Search by stock take section")}
- sx={{ minHeight: 48, py: 1, textTransform: "none" }}
- />
- </Tabs>
- </Grid>
- {searchKind === "section" && (
- <Grid item xs={12} md={4} sx={{ order: 1 }}>
- <FormControl fullWidth size="small" sx={searchFieldSx}>
- <InputLabel>{t("Stock Take Section")}</InputLabel>
- <Select
- value={
- searchFilters.sectionDescription === "All" ||
- searchSectionDescriptionOptions.includes(searchFilters.sectionDescription)
- ? searchFilters.sectionDescription
- : "All"
- }
- MenuProps={searchMenuProps}
- label={t("Stock Take Section")}
- onChange={(e) => {
- const nextDesc = e.target.value;
- const allowed = new Set(
- nextDesc === "All"
- ? searchSectionCodes
- : searchSectionCodes.filter((code) => sectionDescriptionByCode.get(code) === nextDesc)
- );
- const kept = selectedSectionCodes(searchFilters.stockTakeSession).filter((code) => allowed.has(code));
- const nextSession = kept.join(",");
- if (nextSession !== (searchFilters.stockTakeSession || "")) {
- locationOwnedSectionRef.current = null;
- }
- applyFilters({
- ...searchFilters,
- sectionDescription: nextDesc,
- stockTakeSession: nextSession,
- });
- }}
- >
- <MenuItem value="All">{t("All")}</MenuItem>
- {searchSectionDescriptionOptions.map((desc) => (
- <MenuItem key={desc} value={desc}>
- {desc}
- </MenuItem>
- ))}
- </Select>
- </FormControl>
- </Grid>
- )}
- {searchKind === "section" && (
- <Grid item xs={12} md={4} sx={{ order: 1 }}>
- <FormControl fullWidth size="small" sx={searchFieldSx}>
- <InputLabel>{t("Stock Take Section code")}</InputLabel>
- <Select
- multiple
- MenuProps={searchMenuProps}
- value={selectedSectionCodes(searchFilters.stockTakeSession).filter((code) =>
- sectionCodeMenuOptions.includes(code)
- )}
- label={t("Stock Take Section code")}
- onChange={(e) => {
- const raw = e.target.value;
- const picked = (typeof raw === "string" ? raw.split(",") : raw)
- .map((part) => part.trim())
- .filter(Boolean);
- if (locationOwnedSectionRef.current != null) locationOwnedSectionRef.current = null;
- const descriptions = new Set<string>();
- picked.forEach((code) => {
- const text = sectionDescriptionByCode.get(code);
- if (text) descriptions.add(text);
- });
- const nextDesc =
- searchFilters.sectionDescription !== "All" &&
- picked.length > 0 &&
- !descriptions.has(searchFilters.sectionDescription)
- ? "All"
- : searchFilters.sectionDescription;
- applyFilters({
- ...searchFilters,
- sectionDescription: nextDesc,
- stockTakeSession: picked.join(","),
- });
- }}
- renderValue={(selected) => (selected as string[]).join(", ")}
- >
- {sectionCodeMenuOptions.map((code) => (
- <MenuItem key={code} value={code}>
- {code}
- </MenuItem>
- ))}
- </Select>
- </FormControl>
- </Grid>
- )}
- <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 });
- }}
- >
- {statusOptions.map((status) => (
- <ToggleButton key={status} value={status} sx={{ minHeight: 48, px: 2, textTransform: "none" }}>
- {status === "All" ? t("All") : t(status)}
- </ToggleButton>
- ))}
- </ToggleButtonGroup>
- </Box>
- </Grid>
- {searchKind === "location" && (
- <Grid item xs={12} sx={{ order: 1 }}>
- <Stack spacing={1.25}>
- <Box sx={{ display: "flex", alignItems: "center", gap: 1.5, flexWrap: "wrap" }}>
- <Typography variant="body2" sx={{ minWidth: 72, fontWeight: 600 }}>
- {t("Store ID")}
- </Typography>
- <ToggleButtonGroup
- exclusive
- value={locationPick.storeId || null}
- onChange={(_, next: string | null) => {
- const pick = { storeId: next ?? "", warehouse: "", area: "", slot: "" };
- onLocationPickChange(pick);
- applyLocationPick(pick);
- }}
- >
- {locationFloorOptions.map((floor) => (
- <ToggleButton key={floor} value={floor} sx={{ minHeight: 48, px: 2, textTransform: "none" }}>
- {floor === LOCATION_ALL ? t("All") : floor}
- </ToggleButton>
- ))}
- </ToggleButtonGroup>
- </Box>
- <Box sx={{ display: "flex", alignItems: "flex-start", gap: 1.5, flexWrap: "wrap" }}>
- <Typography variant="body2" sx={{ minWidth: 72, fontWeight: 600, mt: 0.75 }}>
- {t("Warehouse")}
- </Typography>
- <Box sx={{ display: "flex", flexDirection: "column", gap: 0.75, opacity: locationPick.storeId ? 1 : 0.45 }}>
- {locationWarehouseRows.map((row, rowIndex) => (
- <ToggleButtonGroup
- key={row.join("-") || rowIndex}
- exclusive
- disabled={!locationPick.storeId}
- value={locationPick.storeId ? locationPick.warehouse || null : null}
- onChange={(_, next: string | null) => {
- if (next == null) return;
- const pick = { ...locationPick, warehouse: next, area: "", slot: "" };
- onLocationPickChange(pick);
- applyLocationPick(pick);
- }}
- sx={{
- "& .MuiToggleButtonGroup-grouped": {
- width: LOCATION_BUTTON_WIDTH,
- minWidth: LOCATION_BUTTON_WIDTH,
- maxWidth: LOCATION_BUTTON_WIDTH,
- px: 0,
- },
- }}
- >
- {row.map((zone) => (
- <ToggleButton key={zone} value={zone} sx={{ minHeight: 48, textTransform: "none" }}>
- {zone === LOCATION_ALL ? t("All") : zone}
- </ToggleButton>
- ))}
- </ToggleButtonGroup>
- ))}
- </Box>
- {!locationPick.storeId ? (
- <Typography variant="body2" color="text.secondary" sx={{ mt: 0.75 }}>
- {t("Select floor first")}
- </Typography>
- ) : null}
- </Box>
- <Box sx={{ display: "flex", alignItems: "flex-start", gap: 1.5, flexWrap: "wrap" }}>
- <Typography variant="body2" sx={{ minWidth: 72, fontWeight: 600, mt: 0.75 }}>
- {t("Area")}
- </Typography>
- <Box
- sx={{
- display: "flex",
- flexDirection: "column",
- gap: 0.75,
- opacity: locationPick.storeId && locationPick.warehouse ? 1 : 0.45,
- }}
- >
- {locationAreaRows.map((row, rowIndex) => (
- <ToggleButtonGroup
- key={row.join("-") || rowIndex}
- exclusive
- disabled={!locationPick.storeId || !locationPick.warehouse}
- value={locationPick.warehouse ? locationPick.area || null : null}
- onChange={(_, next: string | null) => {
- if (next == null) return;
- const pick = { ...locationPick, area: next, slot: "" };
- onLocationPickChange(pick);
- applyLocationPick(pick);
- }}
- sx={{
- "& .MuiToggleButtonGroup-grouped": {
- width: LOCATION_BUTTON_WIDTH,
- minWidth: LOCATION_BUTTON_WIDTH,
- maxWidth: LOCATION_BUTTON_WIDTH,
- px: 0,
- },
- }}
- >
- {row.map((area) => (
- <ToggleButton key={area} value={area} sx={{ minHeight: 48, textTransform: "none" }}>
- {area === LOCATION_ALL ? t("All") : area}
- </ToggleButton>
- ))}
- </ToggleButtonGroup>
- ))}
- </Box>
- {!locationPick.warehouse ? (
- <Typography variant="body2" color="text.secondary" sx={{ mt: 0.75 }}>
- {locationPick.storeId ? t("Select warehouse first") : t("Select floor first")}
- </Typography>
- ) : null}
- </Box>
- <Box sx={{ display: "flex", alignItems: "flex-start", gap: 1.5, flexWrap: "wrap" }}>
- <Typography variant="body2" sx={{ minWidth: 72, fontWeight: 600, mt: 0.75 }}>
- {t("Slot")}
- </Typography>
- <Box
- sx={{
- display: "flex",
- flexDirection: "column",
- gap: 0.75,
- opacity: locationPick.storeId && locationPick.warehouse && locationPick.area ? 1 : 0.45,
- }}
- >
- {locationSlotRows.map((row, rowIndex) => (
- <ToggleButtonGroup
- key={row.join("-") || rowIndex}
- exclusive
- disabled={!locationPick.storeId || !locationPick.warehouse || !locationPick.area}
- value={locationPick.area ? locationPick.slot || null : null}
- onChange={(_, next: string | null) => {
- if (next == null) return;
- const pick = { ...locationPick, slot: next };
- onLocationPickChange(pick);
- applyLocationPick(pick);
- }}
- sx={{
- "& .MuiToggleButtonGroup-grouped": {
- width: LOCATION_BUTTON_WIDTH,
- minWidth: LOCATION_BUTTON_WIDTH,
- maxWidth: LOCATION_BUTTON_WIDTH,
- px: 0,
- },
- }}
- >
- {row.map((slot) => (
- <ToggleButton key={slot} value={slot} sx={{ minHeight: 48, textTransform: "none" }}>
- {slot === LOCATION_ALL ? t("All") : slot}
- </ToggleButton>
- ))}
- </ToggleButtonGroup>
- ))}
- </Box>
- {!locationPick.area ? (
- <Typography variant="body2" color="text.secondary" sx={{ mt: 0.75 }}>
- {locationPick.warehouse ? t("Select area first") : locationPick.storeId ? t("Select warehouse first") : t("Select floor first")}
- </Typography>
- ) : null}
- </Box>
- </Stack>
- </Grid>
- )}
- </Grid>
- <CardActions sx={{ px: 0, pt: 2, gap: 1 }}>
- <Button variant="outlined" onClick={handleResetSearch} sx={{ minHeight: 48, px: 2 }}>
- {t("Reset")}
- </Button>
- </CardActions>
- </CardContent>
- </Card>
- <Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", mb: 2 }}>
-
-
- <Typography variant="body2" color="text.secondary">
- {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"
- onClick={() => setOpenConfirmDialog(true)}
- disabled={creating || !canManageStockTake}
- >
- {creating ? <CircularProgress size={20} /> : t("Create Stock Take (Select Sections)")}
- </Button>
- )}
- </Box>
-
- {loading ? (
- <Box sx={{ display: "flex", justifyContent: "center", p: 3 }}>
- <CircularProgress />
- </Box>
- ) : (
- <Grid container spacing={2}>
- {stockTakeSessions.map((session: AllPickedStockTakeListReponse) => {
- const statusColor = getStatusColor(session.status || "");
- const lastStockTakeDate = session.lastStockTakeDate
- ? dayjs(session.lastStockTakeDate).format(OUTPUT_DATE_FORMAT)
- : "-";
- const completionRate = getCompletionRate(session);
- const sectionMeta = [
- session.stockTakeSectionDescription,
- session.warehouseArea,
- session.storeId,
- ].filter((v): v is string => Boolean(v && v.trim())).join(" / ");
-
- return (
- <Grid key={session.id} item xs={12} md={6}>
- <Card
- sx={{
- minHeight: 200,
- display: "flex",
- flexDirection: "column",
- border: "1px solid",
- borderColor: statusColor === "success" ? "success.main" : "primary.main",
- }}
- >
- <CardContent sx={{ pb: 1, flexGrow: 1 }}>
- <Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 1 }}>
- <Typography variant="subtitle1" fontWeight={600}>
- {session.listMode === "item"
- ? `${t("Item")}: ${session.stockTakeSession}${
- session.stockTakeSectionDescription ? ` (${session.stockTakeSectionDescription})` : ""
- }`
- : `${t("Section")}: ${session.stockTakeSession}${sectionMeta ? ` (${sectionMeta})` : ""}`}
- </Typography>
-
- </Stack>
-
- <Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
- {t("Last Stock Take Date")}: {lastStockTakeDate || "-"}
- </Typography>
- <Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>{t("Stock Taker")}: {session.stockTakerName}</Typography>
- <Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 1 }}>
-
- <Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>{t("start time")}: {startTimeDisplay(session.startTime) || "-"}</Typography>
- <Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>{t("end time")}: {endTimeDisplay(session.endTime) || "-"}</Typography>
- </Stack>
- <Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
- {t("Control Time")}: <TimeDisplay startTime={session.startTime} endTime={session.endTime} />
- </Typography>
- <Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>{t("Total Item Kind Number")}: {session.totalItemNumber}</Typography>
-
- </CardContent>
-
- <CardActions sx={{ pt: 0.5 ,justifyContent: "space-between"}}>
- <Stack direction="row" spacing={1}>
- <Button
- variant="contained"
- sx={{ minHeight: 48, px: 2 }}
- onClick={() => onCardClick(session)}
- >
- {t("View Details")}
- </Button>
- <Button
- variant="contained"
- sx={{ minHeight: 48, px: 2 }}
- onClick={() => onReStockTakeClick(session)}
- disabled={!session.reStockTakeTrueFalse}
- >
- {t("View ReStockTake")}
- </Button>
- </Stack>
- <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>
- );
- })}
- </Grid>
- )}
-
- {total > 0 && (
- <TablePagination
- component="div"
- count={total}
- page={page}
- rowsPerPage={pageSize}
- onPageChange={(e, newPage) => {
- onListPageChange(newPage);
- }}
- rowsPerPageOptions={[pageSize]} // 如果暂时不让用户改 pageSize,就写死
- />
- )}
- </>
- )}
- {/* Create Stock Take 確認 Dialog */}
- <Dialog
- open={openConfirmDialog}
- onClose={closeCreateDialog}
- maxWidth={false}
- fullWidth
- PaperProps={{
- sx: {
- width: "100%",
- maxWidth: { xs: "100%", sm: 960, md: 1120 },
- maxHeight: "90vh",
- display: "flex",
- flexDirection: "column",
- },
- }}
- >
- <DialogTitle
- sx={{
- pb: 1,
- flexShrink: 0,
- display: "flex",
- alignItems: "center",
- justifyContent: "space-between",
- gap: 1,
- }}
- >
- <Typography component="span" variant="h6" sx={{ fontSize: "1.25rem", fontWeight: 500 }}>
- {t("Create Stock Take (Select Sections)")}
- </Typography>
- <Tooltip
- title={
- missingSectionCount > 0
- ? t("Warehouse missing stock take section tooltip has", { count: missingSectionCount })
- : t("Warehouse missing stock take section tooltip none")
- }
- >
- <Box component="span" sx={{ flexShrink: 0, display: "inline-flex" }}>
- <IconButton
- size="small"
- aria-label={t("Warehouse missing stock take section warn title")}
- onClick={() => setMissingSectionWarnDrawerOpen(true)}
- sx={{
- color: missingSectionCount > 0 ? "warning.main" : "text.secondary",
- }}
- >
- <Badge
- color="error"
- badgeContent={missingSectionCount > 99 ? "99+" : missingSectionCount}
- invisible={missingSectionCount === 0}
- >
- <NotificationsNoneOutlinedIcon />
- </Badge>
- </IconButton>
- </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={{
- p: 0,
- flex: 1,
- minHeight: 0,
- overflow: "hidden",
- display: "flex",
- flexDirection: "column",
- }}
- >
- <Box
- sx={{
- display: "flex",
- flex: 1,
- minHeight: 0,
- flexDirection: { xs: "column", md: "row" },
- height: "100%",
- }}
- >
- {/* 左側:盤點設定與樓層 */}
- <Box
- sx={{
- width: { xs: "100%", md: 280 },
- flexShrink: 0,
- minHeight: 0,
- maxHeight: { xs: "38vh", md: "100%" },
- overflowY: "auto",
- borderRight: { md: 1 },
- borderBottom: { xs: 1, md: 0 },
- borderColor: "divider",
- p: 2.5,
- display: "flex",
- flexDirection: "column",
- gap: 2,
- bgcolor: "grey.50",
- }}
- >
- <Box>
- <Typography variant="caption" color="text.secondary" display="block" sx={{ mb: 0.5 }}>
- {t("Stock take round name")}
-
- </Typography>
- <TextField
- fullWidth
- size="small"
- hiddenLabel
- // placeholder={t("Stock take round name placeholder")}
- value={createDialogRoundName}
- onChange={(e) => setCreateDialogRoundName(e.target.value)}
- inputProps={{ maxLength: 255, "aria-label": t("Stock take round name") }}
- sx={createDialogCompactInputSx}
- />
- </Box>
- <Box>
- <Typography variant="caption" color="text.secondary" display="block" sx={{ mb: 0.5 }}>
- {t("Creation date")}
- </Typography>
- <LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="zh-hk">
- <DatePicker
- value={createDialogPlanStart}
- onChange={(newValue) => setCreateDialogPlanStart(newValue)}
- format={OUTPUT_DATE_FORMAT}
- minDate={dayjs().startOf("day")}
- slotProps={{
- textField: {
- fullWidth: true,
- size: "small",
- hiddenLabel: true,
- inputProps: { "aria-label": t("Creation date") },
- sx: createDialogCompactInputSx,
- },
- }}
- />
- </LocalizationProvider>
- </Box>
- {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>
- ) : (
- <Stack
- spacing={1}
- sx={{
- flexShrink: 0,
- ...(sortedFloorKeys.length > FLOOR_LIST_SCROLL_THRESHOLD
- ? { maxHeight: 300, overflowY: "auto", pr: 0.5 }
- : {}),
- }}
- >
- {sortedFloorKeys.map((floorKey, idx) => {
- const floorSections = sectionsByStore.get(floorKey) ?? [];
- const selected = idx === createFloorTabIndex;
- return (
- <Button
- key={floorKey}
- fullWidth
- variant="outlined"
- onClick={() => {
- setCreateFloorTabIndex(idx);
- setCreateDialogSearchQuery("");
- }}
- sx={{
- justifyContent: "space-between",
- textTransform: "none",
- py: 1.25,
- px: 1.5,
- borderRadius: 2,
- borderWidth: selected ? 2 : 1,
- borderColor: selected ? "primary.main" : "divider",
- color: selected ? "primary.main" : "text.primary",
- bgcolor: "background.paper",
- "&:hover": {
- borderColor: selected ? "primary.main" : "divider",
- bgcolor: "background.paper",
- },
- }}
- >
- <Typography variant="body2" fontWeight={selected ? 700 : 500}>
- {floorGroupLabel(floorKey)}
- </Typography>
- <Chip
- size="small"
- label={floorSections.length}
- sx={{
- height: 22,
- minWidth: 28,
- bgcolor: selected ? "primary.main" : "grey.200",
- color: selected ? "primary.contrastText" : "text.secondary",
- }}
- />
- </Button>
- );
- })}
- </Stack>
- )}
- {createMode === "item" ? null : (
- <Stack spacing={1}>
- <Button
- fullWidth
- variant="outlined"
- onClick={() => setCreateDialogSelectedSections([...allSectionsForCreateDialog])}
- disabled={allSectionsForCreateDialog.length === 0}
- sx={{ bgcolor: "background.paper" }}
- >
- {t("Select all sections all floors")}
- </Button>
- <Button
- fullWidth
- variant="outlined"
- onClick={() => setCreateDialogSelectedSections([])}
- sx={{ bgcolor: "background.paper" }}
- >
- {t("Clear selection all floors")}
- </Button>
- </Stack>
- )}
- <Typography variant="body2" color="text.secondary">
- {createMode === "item" ? t("Total selected items label") : t("Total selected sections label")}{" "}
- <Typography component="span" variant="body2" color="primary.main" fontWeight={700}>
- {createMode === "item"
- ? createDialogSelectedItems.length
- : createDialogSelectedSections.length}
- </Typography>{" "}
- {createMode === "item" ? t("items unit") : t("sections unit")}
- </Typography>
- </Box>
-
- {/* 右側:區域卡片網格 */}
- <Box
- sx={{
- flex: 1,
- minHeight: 0,
- display: "flex",
- flexDirection: "column",
- minWidth: 0,
- p: 2.5,
- bgcolor: "background.paper",
- overflow: "hidden",
- }}
- >
- {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"
- alignItems="center"
- justifyContent="space-between"
- flexWrap="wrap"
- gap={1}
- sx={{ mb: 1 }}
- >
- <Typography variant="subtitle1" fontWeight={700}>
- {t("Floor area selection header", {
- floor: floorGroupLabel(activeCreateFloorKey),
- count: activeCreateFloorSections.length,
- })}
- </Typography>
- <Button
- size="small"
- variant="outlined"
- onClick={() => toggleFloorSelectAll(activeCreateFloorKey)}
- disabled={activeCreateFloorSections.length === 0}
- >
- {activeCreateFloorSections.length > 0 &&
- activeCreateFloorSections.every((s) => createDialogSelectedSections.includes(s))
- ? t("Deselect all on this floor", {
- floor: floorGroupLabel(activeCreateFloorKey),
- })
- : t("Select all on this floor", {
- floor: floorGroupLabel(activeCreateFloorKey),
- })}
- </Button>
- </Stack>
- <TextField
- fullWidth
- size="small"
- hiddenLabel
- placeholder={t("Search section code or name")}
- value={createDialogSearchQuery}
- onChange={(e) => setCreateDialogSearchQuery(e.target.value)}
- inputProps={{ "aria-label": t("Search section code or name") }}
- InputProps={{
- startAdornment: (
- <InputAdornment position="start">
- <SearchIcon fontSize="small" color="action" />
- </InputAdornment>
- ),
- }}
- sx={createDialogCompactInputSx}
- />
- <Box
- ref={createSectionGridScrollRef}
- onPointerDown={handleCreateSectionGridPointerDown}
- sx={{
- position: "relative",
- flex: 1,
- minHeight: 0,
- mt: 2,
- overflowY: "auto",
- pr: 0.5,
- userSelect: marqueeRect ? "none" : "auto",
- touchAction: "pan-y",
- cursor: "default",
- }}
- >
- {sectionsLoading ? (
- <Box
- sx={{
- display: "flex",
- alignItems: "center",
- justifyContent: "center",
- minHeight: 240,
- }}
- >
- <CircularProgress />
- </Box>
- ) : createDialogFilteredSections.length === 0 ? (
- <Typography variant="body2" color="text.secondary" sx={{ py: 4, textAlign: "center" }}>
- {createDialogSearchQuery.trim()
- ? t("No sections match search")
- : t("No stock take sections from warehouse")}
- </Typography>
- ) : (
- <>
- {marqueeRect ? (
- <Box
- aria-hidden
- sx={{
- position: "absolute",
- left: marqueeRect.left,
- top: marqueeRect.top,
- width: marqueeRect.width,
- height: marqueeRect.height,
- border: "1px solid",
- borderColor: "primary.main",
- bgcolor: "primary.main",
- opacity: 0.28,
- pointerEvents: "none",
- zIndex: 3,
- boxSizing: "border-box",
- }}
- />
- ) : null}
- <Box
- sx={{
- display: "grid",
- gridTemplateColumns: {
- xs: "repeat(2, 1fr)",
- sm: "repeat(3, 1fr)",
- md: "repeat(4, 1fr)",
- },
- gap: 1.5,
- alignItems: "start",
- //minHeight: "100%",
- }}
- >
- {createDialogFilteredSections.map((section) => {
- const meta = getCreateDialogSectionMeta(section);
- const busy = isSectionBusy(section);
- const checked = createDialogSelectedSections.includes(section);
- const previewSelected = !busy && marqueePreviewIds.has(section);
- const visuallySelected = !busy && (checked || previewSelected);
- return (
- <Card
- key={section}
- data-section-card
- data-section={section}
- variant="outlined"
- ref={(el) => {
- if (el) {
- createSectionCardRefs.current.set(section, el);
- } else {
- createSectionCardRefs.current.delete(section);
- }
- }}
- sx={{
- cursor: busy ? "not-allowed" : "default",
- opacity: busy ? 0.55 : 1,
- borderWidth: visuallySelected ? 2 : 1,
- borderColor: visuallySelected ? "primary.main" : "divider",
- bgcolor: visuallySelected
- ? previewSelected && !checked
- ? "action.hover"
- : "action.selected"
- : "background.paper",
- transition: marqueeRect ? "none" : "border-color 0.15s, background-color 0.15s",
- "&:hover": {
- borderColor: visuallySelected ? "primary.main" : "grey.400",
- },
- }}
- >
- <CardContent sx={{ p: 1.5, "&:last-child": { pb: 1.5 } }}>
- <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"
- color="text.secondary"
- sx={{
- display: "-webkit-box",
- WebkitLineClamp: 2,
- WebkitBoxOrient: "vertical",
- overflow: "hidden",
- lineHeight: 1.35,
- mt: 0.25,
- }}
- >
- {meta}
- </Typography>
- ) : null}
- </CardContent>
- </Card>
- );
- })}
- </Box>
- </>
- )}
- </Box>
- </>
- ) : (
- <Box sx={{ display: "flex", alignItems: "center", justifyContent: "center", flex: 1 }}>
- {sectionsLoading ? (
- <CircularProgress />
- ) : (
- <Typography variant="body2" color="text.secondary">
- {t("No stock take sections from warehouse")}
- </Typography>
- )}
- </Box>
- )}
- </Box>
- </Box>
- </DialogContent>
- <DialogActions sx={{ px: 3, py: 2, flexShrink: 0 }}>
- <Button
- onClick={closeCreateDialog}
- >
- {t("Cancel")}
- </Button>
- <Button
- variant="contained"
- color="primary"
- onClick={handleOpenCreateStockTakeSummaryConfirm}
- disabled={
- creating ||
- (createMode === "item"
- ? createDialogSelectedItems.length === 0
- : createDialogSelectedSections.length === 0) ||
- createDialogPlanStart == null ||
- !createDialogPlanStart.isValid() ||
- createDialogPlanStart.startOf("day").isBefore(dayjs().startOf("day"))
- }
- >
- {t("Confirm")}
- </Button>
- </DialogActions>
- </Dialog>
-
- <Dialog
- open={openCreateStockTakeSummaryConfirm}
- onClose={() => {
- if (!creating) setOpenCreateStockTakeSummaryConfirm(false);
- }}
- maxWidth="sm"
- fullWidth
- >
- <DialogTitle>{t("Confirm create stock take")}</DialogTitle>
- <DialogContent dividers>
- <Stack spacing={1.5} sx={{ mb: 2 }}>
- <Typography variant="body2">
- <Typography component="span" color="text.secondary">
- {t("Stock take round name")}:{" "}
- </Typography>
- {createDialogRoundName.trim() || t("Not filled")}
- </Typography>
- <Typography variant="body2">
- <Typography component="span" color="text.secondary">
- {t("Creation date")}:{" "}
- </Typography>
- {createDialogPlanStart?.isValid()
- ? createDialogPlanStart.format(OUTPUT_DATE_FORMAT)
- : "-"}
- </Typography>
- <Typography variant="body2" fontWeight={600}>
- {createMode === "item" ? t("Total selected items label") : t("Total selected sections label")}{" "}
- <Typography component="span"fontWeight={600} >
- {createMode === "item"
- ? createDialogSelectedItems.length
- : createStockTakeSummarySections.length}
- </Typography>{" "}
- {createMode === "item" ? t("items unit") : t("sections unit")}
- </Typography>
- </Stack>
- <Box
- sx={{
- maxHeight: 280,
- overflowY: "auto",
- border: 1,
- borderColor: "divider",
- borderRadius: 1,
- p: 1.5,
- }}
- >
- <Stack spacing={1}>
- {createMode === "item"
- ? createDialogSelectedItems.map((it) => (
- <Box key={it.id}>
- <Typography variant="body2" fontWeight={600}>
- {it.code}
- </Typography>
- <Typography variant="caption" color="text.secondary">
- {it.name}
- </Typography>
- </Box>
- ))
- : createStockTakeSummarySections.map((section) => {
- const meta = getCreateDialogSectionMeta(section);
- return (
- <Box key={section}>
- <Typography variant="body2" fontWeight={600}>
- {section}
- </Typography>
- {meta ? (
- <Typography variant="caption" color="text.secondary">
- {meta}
- </Typography>
- ) : null}
- </Box>
- );
- })}
- </Stack>
- </Box>
- </DialogContent>
- <DialogActions sx={{ px: 3, py: 2 }}>
- <Button
- onClick={() => setOpenCreateStockTakeSummaryConfirm(false)}
- disabled={creating}
- >
- {t("Cancel")}
- </Button>
- <Button
- variant="contained"
- color="primary"
- onClick={handleCreateStockTake}
- disabled={
- creating ||
- (createMode === "item"
- ? createDialogSelectedItems.length === 0
- : createDialogSelectedSections.length === 0)
- }
- >
- {creating ? <CircularProgress size={20} /> : t("Confirm")}
- </Button>
- </DialogActions>
- </Dialog>
-
- <Drawer
- anchor="right"
- open={missingSectionWarnDrawerOpen}
- onClose={() => setMissingSectionWarnDrawerOpen(false)}
- ModalProps={{
- sx: (theme) => ({
- // Drawer 預設 z-index 1200,低於 Dialog (1300),會被建立盤點對話框遮住
- zIndex: theme.zIndex.modal + 2,
- }),
- }}
- PaperProps={{
- sx: {
- width: { xs: "100%", sm: 400 },
- p: 0,
- display: "flex",
- flexDirection: "column",
- maxHeight: "100vh",
- },
- }}
- >
- <Box sx={{ p: 2, borderBottom: 1, borderColor: "divider" }}>
- <Typography variant="h6" fontWeight={700}>
- {t("Warehouse missing stock take section warn title")}
- </Typography>
- <Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
- {t("Warehouse missing stock take section drawer hint")}
- </Typography>
- {missingSectionCount > 0 ? (
- <Typography variant="caption" color="text.secondary" display="block" sx={{ mt: 1 }}>
- {t("Warehouse missing stock take section showing", {
- shown: Math.min(missingSectionItems.length, missingSectionIssuesLimit),
- count: missingSectionCount,
- })}
- </Typography>
- ) : null}
- </Box>
- <Box sx={{ flex: 1, overflowY: "auto", px: 2, py: 1 }}>
- {missingSectionIssuesLoading ? (
- <Box sx={{ display: "flex", justifyContent: "center", py: 4 }}>
- <CircularProgress size={28} />
- </Box>
- ) : missingSectionItems.length === 0 ? (
- <Typography variant="body2" color="text.secondary" sx={{ py: 2 }}>
- {t("Warehouse missing stock take section empty")}
- </Typography>
- ) : (
- <Stack spacing={1.5} divider={<Divider flexItem />}>
- {missingSectionItems.map((row) => (
- <Box key={row.id}>
- <Typography variant="body2" fontWeight={600}>
- {row.code || `#${row.id}`}
- </Typography>
- <Typography variant="caption" color="text.secondary" display="block">
- {[row.storeId, row.warehouse, row.area, row.slot].filter(Boolean).join(" / ") ||
- "—"}
- </Typography>
- {row.order ? (
- <Typography variant="caption" color="text.secondary" display="block">
- {row.order}
- </Typography>
- ) : null}
- </Box>
- ))}
- </Stack>
- )}
- </Box>
- <Box
- sx={{
- p: 2,
- borderTop: 1,
- borderColor: "divider",
- display: "flex",
- gap: 1,
- flexWrap: "wrap",
- }}
- >
- <Button variant="outlined" onClick={() => setMissingSectionWarnDrawerOpen(false)}>
- {t("Cancel")}
- </Button>
- <Button
- variant="contained"
- color="primary"
- onClick={handleGoWarehouseSettings}
- disabled={missingSectionCount === 0}
- >
- {t("Warehouse missing stock take section go settings")}
- </Button>
- </Box>
- </Drawer>
- </Box>
- );
- };
-
- export default PickerCardList;
|