Przeglądaj źródła

search ui update

stockTakeUpdate
CANCERYS\kw093 1 miesiąc temu
rodzic
commit
e40405f419
7 zmienionych plików z 1434 dodań i 415 usunięć
  1. +1
    -1
      src/app/api/stockTake/actions.ts
  2. +12
    -0
      src/app/api/warehouse/client.ts
  3. +684
    -214
      src/components/StockTakeManagement/ApproverStockTakeAll.tsx
  4. +656
    -133
      src/components/StockTakeManagement/PickerCardList.tsx
  5. +65
    -67
      src/components/StockTakeManagement/StockTakeTab.tsx
  6. +8
    -0
      src/i18n/en/stockTake.json
  7. +8
    -0
      src/i18n/zh/stockTake.json

+ 1
- 1
src/app/api/stockTake/actions.ts Wyświetl plik

@@ -114,7 +114,7 @@ export const getInventoryLotDetailsBySection = async (
},
);
console.log('[API] Response received:', response);
//console.log('[API] Response received:', response);
return response;
}
export interface SaveStockTakeRecordRequest {


+ 12
- 0
src/app/api/warehouse/client.ts Wyświetl plik

@@ -58,7 +58,19 @@ export const printWarehouseQrCode = async (data: PrintWarehouseQrCodeRequest): P
}
};

let warehouseListCache: Promise<WarehouseResult[]> | null = null;

export const fetchWarehouseListClient = async (): Promise<WarehouseResult[]> => {
if (!warehouseListCache) {
warehouseListCache = fetchWarehouseListFromApi().catch((error) => {
warehouseListCache = null;
throw error;
});
}
return warehouseListCache;
};

const fetchWarehouseListFromApi = async (): Promise<WarehouseResult[]> => {
const token = localStorage.getItem("accessToken");
const response = await fetch(`${NEXT_PUBLIC_API_URL}/warehouse`, {


+ 684
- 214
src/components/StockTakeManagement/ApproverStockTakeAll.tsx
Plik diff jest za duży
Wyświetl plik


+ 656
- 133
src/components/StockTakeManagement/PickerCardList.tsx Wyświetl plik

@@ -22,12 +22,15 @@ import {
InputLabel,
Select,
MenuItem,
Autocomplete,
Badge,
IconButton,
Tooltip,
Drawer,
Divider,
Tab,
Tabs,
ToggleButton,
ToggleButtonGroup,
} from "@mui/material";
import SearchIcon from "@mui/icons-material/Search";
import NotificationsNoneOutlinedIcon from "@mui/icons-material/NotificationsNoneOutlined";
@@ -44,8 +47,8 @@ import {
getLatestStockTakeRoundMeta,
} from "@/app/api/stockTake/actions";
import { fetchStockTakeSections } from "@/app/api/warehouse/actions";
import { fetchMissingStockTakeSectionIssues } from "@/app/api/warehouse/client";
import type { MissingStockTakeSectionIssueItem, StockTakeSectionInfo } from "@/app/api/warehouse";
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";
@@ -57,11 +60,100 @@ import {
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;
}

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();
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,
@@ -105,16 +197,81 @@ interface PickerCardListProps {
appliedFilters: PickerCardListFilters;
onSearchFiltersChange: (next: PickerCardListFilters) => void;
onAppliedFiltersChange: (next: PickerCardListFilters) => void;
/** 樓層/倉庫/倉位,由父層保存,從明細返回時仍顯示 */
locationPick: PickerLocationPick;
onLocationPickChange: (next: PickerLocationPick) => 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,
@@ -125,14 +282,16 @@ const PickerCardList: React.FC<PickerCardListProps> = ({
appliedFilters,
onSearchFiltersChange,
onAppliedFiltersChange,
locationPick,
onLocationPickChange,
}) => {
const { t } = useTranslation(["stockTake", "common"]);
const router = useRouter();
dayjs.extend(duration);

const [loading, setLoading] = useState(false);
const [stockTakeSessions, setStockTakeSessions] = useState<AllPickedStockTakeListReponse[]>([]);
const [total, setTotal] = useState(0);
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);
@@ -171,22 +330,20 @@ const PickerCardList: React.FC<PickerCardListProps> = ({
} | null>(null);
const [marqueeRect, setMarqueeRect] = useState<MarqueeRect | null>(null);
const [marqueePreviewIds, setMarqueePreviewIds] = useState<Set<string>>(() => new Set());
const [sectionDescriptionOptions, setSectionDescriptionOptions] = useState<string[]>([]);
const [stockTakeSectionOptions, setStockTakeSectionOptions] = useState<string[]>([]);
const [storeIdOptions, setStoreIdOptions] = useState<string[]>(["2F", "4F"]);
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 = ["pending", "stockTaking", "approving", "completed"];

const handleSearch = () => {
onAppliedFiltersChange({
sectionDescription: searchFilters.sectionDescription || "All",
stockTakeSession: searchFilters.stockTakeSession || "",
status: searchFilters.status || "All",
area: searchFilters.area || "",
storeId: searchFilters.storeId || "All",
});
onListPageChange(0);
};
const searchKind = searchFilters.searchKind ?? "location";

const handleResetSearch = () => {
const resetFilters: PickerCardListFilters = {
@@ -195,34 +352,47 @@ const PickerCardList: React.FC<PickerCardListProps> = ({
status: "All",
area: "",
storeId: "All",
searchKind,
};
onSearchFiltersChange(resetFilters);
onAppliedFiltersChange(resetFilters);
onLocationPickChange(EMPTY_PICKER_LOCATION);
locationOwnedSectionRef.current = null;
onListPageChange(0);
};

useEffect(() => {
let cancelled = false;
setLoading(true);
getStockTakeRecordsPaged(page, pageSize, {
sectionDescription: appliedFilters.sectionDescription,
stockTakeSections: appliedFilters.stockTakeSession,
status: appliedFilters.status,
area: appliedFilters.area,
storeId: appliedFilters.storeId,
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(() => {
let cancelled = false;
getStockTakeRecordsPaged(0, 10000, {
sectionDescription: "All",
stockTakeSections: "",
status: "All",
area: "",
storeId: "All",
onlyLatestRound: true,
})
.then((res) => {
if (cancelled) return;
setStockTakeSessions(Array.isArray(res.records) ? res.records : []);
setTotal(res.total || 0);
setRoundRecords(Array.isArray(res.records) ? res.records : []);
})
.catch((e) => {
console.error(e);
if (!cancelled) {
setStockTakeSessions([]);
setTotal(0);
}
console.error("Failed to load this round's stock take sections:", e);
if (!cancelled) setRoundRecords([]);
})
.finally(() => {
if (!cancelled) setLoading(false);
@@ -230,7 +400,196 @@ const PickerCardList: React.FC<PickerCardListProps> = ({
return () => {
cancelled = true;
};
}, [page, pageSize, appliedFilters, listRefreshNonce]);
}, [listRefreshNonce]);

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;
@@ -315,7 +674,7 @@ const PickerCardList: React.FC<PickerCardListProps> = ({
const sid = row.storeId?.trim();
if (sid) sectionToFloor.set(sec, sid);
});
stockTakeSessions.forEach((session) => {
roundRecords.forEach((session) => {
const sec = session.stockTakeSession?.trim();
if (!sec) return;
const sid = session.storeId?.trim();
@@ -344,7 +703,7 @@ const PickerCardList: React.FC<PickerCardListProps> = ({
out.set(key, Array.from(set).sort((a, b) => a.localeCompare(b)));
});
return out;
}, [stockTakeSectionRows, stockTakeSessions, stockTakeSectionOptions]);
}, [stockTakeSectionRows, roundRecords, stockTakeSectionOptions]);

const sortedFloorKeys = useMemo(() => {
const keys = Array.from(sectionsByStore.keys());
@@ -393,14 +752,14 @@ const PickerCardList: React.FC<PickerCardListProps> = ({
const getCreateDialogSectionMeta = useCallback(
(section: string) => {
const row = stockTakeSectionRows.find((r) => r.stockTakeSection?.trim() === section);
const fromList = stockTakeSessions.find((s) => s.stockTakeSession?.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, stockTakeSessions],
[stockTakeSectionRows, roundRecords],
);

const activeCreateFloorKey = useMemo(() => {
@@ -603,20 +962,12 @@ const PickerCardList: React.FC<PickerCardListProps> = ({
fetchStockTakeSections()
.then((sections) => {
setStockTakeSectionRows(Array.isArray(sections) ? sections : []);
const descSet = new Set<string>();
const sectionSet = new Set<string>();
const storeIdSet = new Set<string>(["2F", "4F"]);
sections.forEach((s) => {
const section = s.stockTakeSection?.trim();
if (section) sectionSet.add(section);
const desc = s.stockTakeSectionDescription?.trim();
if (desc) descSet.add(desc);
const storeId = s.storeId?.trim();
if (storeId) storeIdSet.add(storeId);
});
setStockTakeSectionOptions(Array.from(sectionSet).sort((a, b) => a.localeCompare(b)));
setSectionDescriptionOptions(Array.from(descSet).sort((a, b) => a.localeCompare(b)));
setStoreIdOptions(Array.from(storeIdSet).sort((a, b) => a.localeCompare(b)));
})
.catch((e) => {
console.error("Failed to load section descriptions for filter:", e);
@@ -628,23 +979,13 @@ const PickerCardList: React.FC<PickerCardListProps> = ({
useEffect(() => {
setStockTakeSectionOptions((prev) => {
const sectionSet = new Set<string>(prev);
stockTakeSessions.forEach((item) => {
roundRecords.forEach((item) => {
const section = item.stockTakeSession?.trim();
if (section) sectionSet.add(section);
});
return Array.from(sectionSet).sort((a, b) => a.localeCompare(b));
});
}, [stockTakeSessions]);
useEffect(() => {
setStoreIdOptions((prev) => {
const storeIdSet = new Set<string>([...prev, "2F", "4F"]);
stockTakeSessions.forEach((item) => {
const storeId = item.storeId?.trim();
if (storeId) storeIdSet.add(storeId);
});
return Array.from(storeIdSet).sort((a, b) => a.localeCompare(b));
});
}, [stockTakeSessions]);
}, [roundRecords]);
const getStatusColor = (status: string) => {
const statusLower = status.toLowerCase();
if (statusLower === "completed") return "success";
@@ -726,23 +1067,59 @@ const PickerCardList: React.FC<PickerCardListProps> = ({
<Typography variant="overline" sx={{ display: "block", mb: 1 }}>
{t("Search Criteria")}
</Typography>
<Grid container spacing={2}>
<Grid item xs={12} md={4}>
<FormControl fullWidth>
<Grid container spacing={2} alignItems="flex-start">
<Grid item xs={12}>
<Tabs
value={searchKind}
onChange={(_, value: PickerSearchKind) => applyFilters({ ...searchFilters, searchKind: value })}
sx={{ minHeight: 36 }}
>
<Tab
value="location"
label={t("Search by location")}
sx={{ minHeight: 36, py: 0.5, textTransform: "none" }}
/>
<Tab
value="section"
label={t("Search by stock take section")}
sx={{ minHeight: 36, py: 0.5, 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
size="small"
value={searchFilters.sectionDescription}
value={
searchFilters.sectionDescription === "All" ||
searchSectionDescriptionOptions.includes(searchFilters.sectionDescription)
? searchFilters.sectionDescription
: "All"
}
MenuProps={searchMenuProps}
label={t("Stock Take Section")}
onChange={(e) =>
onSearchFiltersChange({
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: e.target.value,
})
}
sectionDescription: nextDesc,
stockTakeSession: nextSession,
});
}}
>
<MenuItem value="All">{t("All")}</MenuItem>
{sectionDescriptionOptions.map((desc) => (
{searchSectionDescriptionOptions.map((desc) => (
<MenuItem key={desc} value={desc}>
{desc}
</MenuItem>
@@ -750,40 +1127,60 @@ const PickerCardList: React.FC<PickerCardListProps> = ({
</Select>
</FormControl>
</Grid>
<Grid item xs={12} md={4}>
<Autocomplete
freeSolo
options={stockTakeSectionOptions}
value={searchFilters.stockTakeSession}
onChange={(_, newValue) =>
onSearchFiltersChange({
...searchFilters,
stockTakeSession: newValue || "",
})
}
onInputChange={(_, newValue) =>
onSearchFiltersChange({
...searchFilters,
stockTakeSession: newValue,
})
}
renderInput={(params) => (
<TextField
{...params}
fullWidth
label={t("Stock Take Section (can use , to search multiple sections)")}
/>
)}
/>
)}
{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} md={4}>
<FormControl fullWidth>
)}
<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) =>
onSearchFiltersChange({
applyFilters({
...searchFilters,
status: e.target.value,
})
@@ -798,49 +1195,175 @@ const PickerCardList: React.FC<PickerCardListProps> = ({
</Select>
</FormControl>
</Grid>
<Grid item xs={12} md={6}>
<TextField
fullWidth
label={t("Area")}
value={searchFilters.area}
onChange={(e) =>
onSearchFiltersChange({
...searchFilters,
area: e.target.value,
})
}
/>
</Grid>
<Grid item xs={12} md={6}>
<FormControl fullWidth>
<InputLabel>{t("Store ID")}</InputLabel>
<Select
value={searchFilters.storeId}
label={t("Store ID")}
onChange={(e) =>
onSearchFiltersChange({
...searchFilters,
storeId: e.target.value,
})
}
>
<MenuItem value="All">{t("All")}</MenuItem>
{storeIdOptions.map((storeId) => (
<MenuItem key={storeId} value={storeId}>
{storeId}
</MenuItem>
))}
</Select>
</FormControl>
{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
size="small"
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={{ px: 1.5, 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
size="small"
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={{ 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
size="small"
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={{ 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
size="small"
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={{ 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}>
{t("Reset")}
</Button>
<Button variant="contained" onClick={handleSearch}>
{t("Search")}
</Button>
</CardActions>
</CardContent>
</Card>


+ 65
- 67
src/components/StockTakeManagement/StockTakeTab.tsx Wyświetl plik

@@ -7,8 +7,9 @@ import { useTranslation } from "react-i18next";
import { AUTH } from "@/authorities";
import { SessionWithTokens } from "@/config/authConfig";
import { AllPickedStockTakeListReponse, getLatestApproverStockTakeHeader } from "@/app/api/stockTake/actions";
import { fetchWarehouseListClient } from "@/app/api/warehouse/client";
import PickerCardList from "./PickerCardList";
import type { PickerCardListFilters } from "./PickerCardList";
import { EMPTY_PICKER_LOCATION, type PickerCardListFilters, type PickerLocationPick } from "./PickerCardList";
import PickerStockTake from "./PickerStockTake";
import PickerReStockTake from "./PickerReStockTake";
import ApproverStockTakeAll from "./ApproverStockTakeAll";
@@ -26,6 +27,7 @@ const DEFAULT_PICKER_CARD_LIST_FILTERS: PickerCardListFilters = {
status: "All",
area: "",
storeId: "All",
searchKind: "location",
};

const StockTakeTab: React.FC = () => {
@@ -44,11 +46,13 @@ 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;
@@ -108,8 +112,14 @@ const StockTakeTab: React.FC = () => {
}
}, [handleSnackbar, isAdmin, qtyGapDraft, t]);

useEffect(() => {
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) => {
@@ -122,41 +132,26 @@ const StockTakeTab: React.FC = () => {
.finally(() => setApproverLoading(false));
}, [tabValue]);

if (selectedSession && viewScope === "picker") {
return (
<Box>
{viewScope === "picker" && (
tabValue === 0 ? (
viewMode === "reStockTake" ? (
<PickerReStockTake
selectedSession={selectedSession}
onBack={handleBackToList}
onSnackbar={handleSnackbar}
/>
) : (
<PickerStockTake
selectedSession={selectedSession}
onBack={handleBackToList}
onSnackbar={handleSnackbar}
/>
)
) : null
)}
<Snackbar
open={snackbar.open}
autoHideDuration={6000}
onClose={() => setSnackbar({ ...snackbar, open: false })}
>
<Alert onClose={() => setSnackbar({ ...snackbar, open: false })} severity={snackbar.severity}>
{snackbar.message}
</Alert>
</Snackbar>
</Box>
);
}
const showingPickerDetail = Boolean(selectedSession) && viewScope === "picker" && tabValue === 0;

return (
<Box>
{showingPickerDetail && selectedSession ? (
viewMode === "reStockTake" ? (
<PickerReStockTake
selectedSession={selectedSession}
onBack={handleBackToList}
onSnackbar={handleSnackbar}
/>
) : (
<PickerStockTake
selectedSession={selectedSession}
onBack={handleBackToList}
onSnackbar={handleSnackbar}
/>
)
) : null}
<Box sx={{ display: showingPickerDetail ? "none" : "block" }}>
{isAdmin && (
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 1 }}>
<Typography
@@ -223,6 +218,8 @@ const StockTakeTab: React.FC = () => {
appliedFilters={pickerAppliedFilters}
onSearchFiltersChange={setPickerSearchFilters}
onAppliedFiltersChange={setPickerAppliedFilters}
locationPick={pickerLocationPick}
onLocationPickChange={setPickerLocationPick}
onCardClick={(session) => {
setViewScope("picker");
handleCardClick(session);
@@ -230,40 +227,41 @@ const StockTakeTab: React.FC = () => {
onReStockTakeClick={handleReStockTakeClick}
/>
)}
{tabValue === 1 && (
<Box>
{approverLoading ? (
<Box sx={{ display: "flex", justifyContent: "center", p: 3 }}>
<CircularProgress />
</Box>
) : approverSession ? (
<ApproverStockTakeAll
selectedSession={approverSession}
mode="pending"
onSnackbar={handleSnackbar}
/>
) : (
<Typography variant="body2" color="text.secondary">
{t("No data")}
</Typography>
)}
</Box>
)}
{tabValue === 2 && (
<Box>
{approverSession ? (
<ApproverStockTakeAll
selectedSession={approverSession}
mode="approved"
onSnackbar={handleSnackbar}
/>
) : (
<Typography variant="body2" color="text.secondary">
{t("No data")}
</Typography>
)}
</Box>
)}
{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}
/>
) : 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}
/>
) : approverHeaderLoadedRef.current && !approverLoading ? (
<Typography variant="body2" color="text.secondary">
{t("No data")}
</Typography>
) : null}
</Box>
</>
) : null}
</Box>

<Snackbar
open={snackbar.open}


+ 8
- 0
src/i18n/en/stockTake.json Wyświetl plik

@@ -100,6 +100,8 @@
"Save": "Save",
"Search": "Search",
"Search Criteria": "Search Criteria",
"Search by location": "Search by location",
"Search by stock take section": "Search by section",
"Search section code or name": "Search code or name (e.g. ST-042 or drinks)",
"Second": "Second",
"Second QTY is not available": "Second count quantity is not available",
@@ -120,6 +122,8 @@
"Stock Take Round": "Stock Take Round",
"Stock Take Section": "Stock Take Section",
"Stock Take Section (can use , to search multiple sections)": "Stock Take Section (can use , to search multiple sections)",
"Stock Take Section code": "Stock take section",
"Stock Take Section code placeholder": "Use comma for multiple sections",
"Stock Taker": "Stock Taker",
"Stock take qty exceeds maximum": "Stock take quantity cannot exceed 999,999,999,999",
"Stock take record ID is required": "Stock take record ID is required",
@@ -173,6 +177,10 @@
"sections unit": "area(s)",
"selected stock take qty": "selected stock take qty",
"start time": "start time",
"Select floor first": "Select floor first",
"Select warehouse first": "Select warehouse first",
"Select area first": "Select area first",
"Slot": "Slot",
"qtyGapWarnPercent": "Qty variance warning %",
"qtyGapWarnPercentSaved": "Qty gap warning percent saved",
"qtyGapWarnPercentInvalid": "Enter a whole number from 0 to 1000",


+ 8
- 0
src/i18n/zh/stockTake.json Wyświetl plik

@@ -11,7 +11,11 @@
"Selected Qty": "選擇數量",
"Not Match": "要求重點",
"Search Criteria": "搜索條件",
"Search by location": "依位置搜索",
"Search by stock take section": "依盤點區域搜索",
"Stock Take Section (can use , to search multiple sections)": "盤點區域(可使用逗號搜索多個區域)",
"Stock Take Section code": "盤點區域",
"Stock Take Section code placeholder": "可使用逗號搜索多個區域",
"Item": "貨品",
"Store ID": "樓層",
"Pending": "待處理",
@@ -103,6 +107,10 @@
"No adjustments found": "未找到調整",
"Floor unassigned": "未設定樓層",
"Area": "倉位",
"Select floor first": "請先選擇樓層",
"Select warehouse first": "請先選擇倉庫",
"Select area first": "請先選擇倉位",
"Slot": "位置",
"Start Stock Take Date": "盤點日期",
"Create Stock Take (Select Sections)": "建立盤點(選擇區域)",
"Total Item Kind Number": "貨品種類數量",


Ładowanie…
Anuluj
Zapisz