| @@ -5,8 +5,10 @@ import { Suspense } from "react"; | |||
| import { Stack } from "@mui/material"; | |||
| import { Button } from "@mui/material"; | |||
| import Link from "next/link"; | |||
| import WarehouseHandle from "@/components/WarehouseHandle"; | |||
| import Add from "@mui/icons-material/Add"; | |||
| import WarehouseTabs from "@/components/Warehouse/WarehouseTabs"; | |||
| import WarehouseHandleWrapper from "@/components/WarehouseHandle/WarehouseHandleWrapper"; | |||
| import TabStockTakeSectionMapping from "@/components/Warehouse/TabStockTakeSectionMapping"; | |||
| export const metadata: Metadata = { | |||
| title: "Warehouse Management", | |||
| @@ -16,12 +18,7 @@ const Warehouse: React.FC = async () => { | |||
| const { t } = await getServerI18n("warehouse"); | |||
| return ( | |||
| <> | |||
| <Stack | |||
| direction="row" | |||
| justifyContent="space-between" | |||
| flexWrap="wrap" | |||
| rowGap={2} | |||
| > | |||
| <Stack direction="row" justifyContent="space-between" flexWrap="wrap" rowGap={2}> | |||
| <Typography variant="h4" marginInlineEnd={2}> | |||
| {t("Warehouse")} | |||
| </Typography> | |||
| @@ -35,11 +32,14 @@ const Warehouse: React.FC = async () => { | |||
| </Button> | |||
| </Stack> | |||
| <I18nProvider namespaces={["warehouse", "common", "dashboard"]}> | |||
| <Suspense fallback={<WarehouseHandle.Loading />}> | |||
| <WarehouseHandle /> | |||
| <Suspense fallback={null}> | |||
| <WarehouseTabs | |||
| tab0Content={<WarehouseHandleWrapper />} | |||
| tab1Content={<TabStockTakeSectionMapping />} | |||
| /> | |||
| </Suspense> | |||
| </I18nProvider> | |||
| </> | |||
| ); | |||
| }; | |||
| export default Warehouse; | |||
| export default Warehouse; | |||
| @@ -349,6 +349,7 @@ export interface AllJoborderProductProcessInfoResponse { | |||
| jobOrderId: number; | |||
| timeNeedToComplete: number; | |||
| uom: string; | |||
| isDrink?: boolean | null; | |||
| stockInLineId: number; | |||
| jobOrderCode: string; | |||
| productProcessLineCount: number; | |||
| @@ -737,9 +738,13 @@ export const newUpdateProductProcessLineQrscan = cache(async (request: NewProduc | |||
| } | |||
| ); | |||
| }); | |||
| export const fetchAllJoborderProductProcessInfo = cache(async () => { | |||
| export const fetchAllJoborderProductProcessInfo = cache(async (isDrink?: boolean | null) => { | |||
| const query = isDrink !== undefined && isDrink !== null | |||
| ? `?isDrink=${isDrink}` | |||
| : ""; | |||
| return serverFetchJson<AllJoborderProductProcessInfoResponse[]>( | |||
| `${BASE_API_URL}/product-process/Demo/Process/all`, | |||
| `${BASE_API_URL}/product-process/Demo/Process/all${query}`, | |||
| { | |||
| method: "GET", | |||
| next: { tags: ["productProcess"] }, | |||
| @@ -3,7 +3,7 @@ | |||
| import { serverFetchString, serverFetchWithNoContent, serverFetchJson } from "@/app/utils/fetchUtil"; | |||
| import { BASE_API_URL } from "@/config/api"; | |||
| import { revalidateTag } from "next/cache"; | |||
| import { WarehouseResult } from "./index"; | |||
| import { WarehouseResult, StockTakeSectionInfo } from "./index"; | |||
| import { cache } from "react"; | |||
| export interface WarehouseInputs { | |||
| @@ -17,6 +17,7 @@ export interface WarehouseInputs { | |||
| slot?: string; | |||
| order?: string; | |||
| stockTakeSection?: string; | |||
| stockTakeSectionDescription?: string; | |||
| } | |||
| export const fetchWarehouseDetail = cache(async (id: number) => { | |||
| @@ -81,4 +82,62 @@ export const importNewWarehouse = async (data: FormData) => { | |||
| }, | |||
| ); | |||
| return importWarehouse; | |||
| } | |||
| } | |||
| export const fetchStockTakeSections = cache(async () => { | |||
| return serverFetchJson<StockTakeSectionInfo[]>(`${BASE_API_URL}/warehouse/stockTakeSections`, { | |||
| next: { tags: ["warehouse"] }, | |||
| }); | |||
| }); | |||
| export const updateSectionDescription = async (section: string, stockTakeSectionDescription: string | null) => { | |||
| await serverFetchWithNoContent( | |||
| `${BASE_API_URL}/warehouse/section/${encodeURIComponent(section)}/description`, | |||
| { | |||
| method: "PATCH", | |||
| headers: { "Content-Type": "application/json" }, | |||
| body: JSON.stringify({ stockTakeSectionDescription }), | |||
| } | |||
| ); | |||
| revalidateTag("warehouse"); | |||
| }; | |||
| export const clearWarehouseSection = async (warehouseId: number) => { | |||
| const result = await serverFetchJson<WarehouseResult>( | |||
| `${BASE_API_URL}/warehouse/${warehouseId}/clearSection`, | |||
| { method: "POST" } | |||
| ); | |||
| revalidateTag("warehouse"); | |||
| return result; | |||
| }; | |||
| export const getWarehousesBySection = cache(async (stockTakeSection: string) => { | |||
| const list = await serverFetchJson<WarehouseResult[]>(`${BASE_API_URL}/warehouse`, { | |||
| next: { tags: ["warehouse"] }, | |||
| }); | |||
| const items = Array.isArray(list) ? list : []; | |||
| return items.filter((w) => w.stockTakeSection === stockTakeSection); | |||
| }); | |||
| export const searchWarehousesForAddToSection = cache(async ( | |||
| params: { store_id?: string; warehouse?: string; area?: string; slot?: string }, | |||
| currentSection: string | |||
| ) => { | |||
| const list = await serverFetchJson<WarehouseResult[]>(`${BASE_API_URL}/warehouse`, { | |||
| next: { tags: ["warehouse"] }, | |||
| }); | |||
| const items = Array.isArray(list) ? list : []; | |||
| const storeId = params.store_id?.trim(); | |||
| const warehouse = params.warehouse?.trim(); | |||
| const area = params.area?.trim(); | |||
| const slot = params.slot?.trim(); | |||
| return items.filter((w) => { | |||
| if (w.stockTakeSection != null && w.stockTakeSection !== currentSection) return false; | |||
| if (!w.code) return true; | |||
| const parts = w.code.split("-"); | |||
| if (storeId && parts[0] !== storeId) return false; | |||
| if (warehouse && parts[1] !== warehouse) return false; | |||
| if (area && parts[2] !== area) return false; | |||
| if (slot && parts[3] !== slot) return false; | |||
| return true; | |||
| }); | |||
| }); | |||
| @@ -15,6 +15,7 @@ export interface WarehouseResult { | |||
| slot?: string; | |||
| order?: string; | |||
| stockTakeSection?: string; | |||
| stockTakeSectionDescription?: string; | |||
| } | |||
| export interface WarehouseCombo { | |||
| @@ -34,3 +35,9 @@ export const fetchWarehouseCombo = cache(async () => { | |||
| next: { tags: ["warehouseCombo"] }, | |||
| }); | |||
| }); | |||
| export interface StockTakeSectionInfo { | |||
| id: string; | |||
| stockTakeSection: string; | |||
| stockTakeSectionDescription: string | null; | |||
| warehouseCount: number; | |||
| } | |||
| @@ -41,6 +41,7 @@ const CreateWarehouse: React.FC = () => { | |||
| slot: "", | |||
| order: "", | |||
| stockTakeSection: "", | |||
| stockTakeSectionDescription: "", | |||
| }); | |||
| } catch (error) { | |||
| console.log(error); | |||
| @@ -89,7 +90,8 @@ const CreateWarehouse: React.FC = () => { | |||
| router.replace("/settings/warehouse"); | |||
| } catch (e) { | |||
| console.log(e); | |||
| setServerError(t("An error has occurred. Please try again later.")); | |||
| const message = e instanceof Error ? e.message : t("An error has occurred. Please try again later."); | |||
| setServerError(message); | |||
| } | |||
| }, | |||
| [router, t], | |||
| @@ -153,6 +153,14 @@ const WarehouseDetail: React.FC = () => { | |||
| helperText={errors.stockTakeSection?.message} | |||
| /> | |||
| </Box> | |||
| <Box sx={{ flex: 1, minWidth: "150px", ml: 2 }}> | |||
| <TextField | |||
| label={t("stockTakeSectionDescription")} | |||
| fullWidth | |||
| size="small" | |||
| {...register("stockTakeSectionDescription")} | |||
| /> | |||
| </Box> | |||
| </Box> | |||
| </CardContent> | |||
| @@ -52,7 +52,8 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ onSelectProcess | |||
| const [openModal, setOpenModal] = useState<boolean>(false); | |||
| const [modalInfo, setModalInfo] = useState<StockInLineInput>(); | |||
| const currentUserId = session?.id ? parseInt(session.id) : undefined; | |||
| type ProcessFilter = "all" | "drink" | "other"; | |||
| const [filter, setFilter] = useState<ProcessFilter>("all"); | |||
| const [suggestedLocationCode, setSuggestedLocationCode] = useState<string | null>(null); | |||
| const handleAssignPickOrder = useCallback(async (pickOrderId: number, jobOrderId?: number, productProcessId?: number) => { | |||
| if (!currentUserId) { | |||
| @@ -108,7 +109,10 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ onSelectProcess | |||
| const fetchProcesses = useCallback(async () => { | |||
| setLoading(true); | |||
| try { | |||
| const data = await fetchAllJoborderProductProcessInfo(); | |||
| const isDrinkParam = | |||
| filter === "all" ? undefined : filter === "drink" ? true : false; | |||
| const data = await fetchAllJoborderProductProcessInfo(isDrinkParam); | |||
| setProcesses(data || []); | |||
| setPage(0); | |||
| } catch (e) { | |||
| @@ -117,7 +121,7 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ onSelectProcess | |||
| } finally { | |||
| setLoading(false); | |||
| } | |||
| }, []); | |||
| }, [filter]); | |||
| useEffect(() => { | |||
| fetchProcesses(); | |||
| @@ -176,6 +180,29 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ onSelectProcess | |||
| </Box> | |||
| ) : ( | |||
| <Box> | |||
| <Box sx={{ display: 'flex', gap: 1, alignItems: 'center', flexWrap: 'wrap', mb: 2 }}> | |||
| <Button | |||
| variant={filter === 'all' ? 'contained' : 'outlined'} | |||
| size="small" | |||
| onClick={() => setFilter('all')} | |||
| > | |||
| {t("All")} | |||
| </Button> | |||
| <Button | |||
| variant={filter === 'drink' ? 'contained' : 'outlined'} | |||
| size="small" | |||
| onClick={() => setFilter('drink')} | |||
| > | |||
| {t("Drink")} | |||
| </Button> | |||
| <Button | |||
| variant={filter === 'other' ? 'contained' : 'outlined'} | |||
| size="small" | |||
| onClick={() => setFilter('other')} | |||
| > | |||
| {t("Other")} | |||
| </Button> | |||
| </Box> | |||
| <Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}> | |||
| {t("Total processes")}: {processes.length} | |||
| </Typography> | |||
| @@ -98,6 +98,23 @@ const SearchPage: React.FC<Props> = ({ dataList }) => { | |||
| lotId = item.lotId; | |||
| itemId = item.itemId; | |||
| } | |||
| } else if (tab === "expiry") { | |||
| const item = expiryItems.find((i) => i.id === id); | |||
| if (!item) { | |||
| alert(t("Item not found")); | |||
| return; | |||
| } | |||
| try { | |||
| // 如果想要 loading 效果,可以这里把 id 加进 submittingIds | |||
| await submitExpiryItem(item.id, currentUserId); | |||
| // 成功后,从列表移除这一行,或直接 reload | |||
| // setExpiryItems(prev => prev.filter(i => i.id !== id)); | |||
| window.location.reload(); | |||
| } catch (e) { | |||
| alert(t("Failed to submit expiry item")); | |||
| } | |||
| return; // 记得 return,避免再走到下面的 lotId/itemId 分支 | |||
| } | |||
| if (lotId && itemId) { | |||
| @@ -109,7 +126,7 @@ const SearchPage: React.FC<Props> = ({ dataList }) => { | |||
| alert(t("Item not found")); | |||
| } | |||
| }, | |||
| [tab, currentUserId, t, missItems, badItems] | |||
| [tab, currentUserId, t, missItems, badItems, expiryItems] | |||
| ); | |||
| const handleFormSuccess = useCallback(() => { | |||
| @@ -19,6 +19,7 @@ import { | |||
| DialogContentText, | |||
| DialogActions, | |||
| } from "@mui/material"; | |||
| import SearchBox, { Criterion } from "@/components/SearchBox/SearchBox"; | |||
| import { useState, useCallback, useEffect } from "react"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import duration from "dayjs/plugin/duration"; | |||
| @@ -50,6 +51,58 @@ const PickerCardList: React.FC<PickerCardListProps> = ({ onCardClick, onReStockT | |||
| const [total, setTotal] = useState(0); | |||
| const [creating, setCreating] = useState(false); | |||
| const [openConfirmDialog, setOpenConfirmDialog] = useState(false); | |||
| const [filterSectionDescription, setFilterSectionDescription] = useState<string>("All"); | |||
| const [filterStockTakeSession, setFilterStockTakeSession] = useState<string>(""); | |||
| type PickerSearchKey = "sectionDescription" | "stockTakeSession"; | |||
| const sectionDescriptionOptions = Array.from( | |||
| new Set( | |||
| stockTakeSessions | |||
| .map((s) => s.stockTakeSectionDescription) | |||
| .filter((v): v is string => !!v) | |||
| ) | |||
| ); | |||
| // 按 description + section 双条件过滤 | |||
| const filteredSessions = stockTakeSessions.filter((s) => { | |||
| const matchDesc = | |||
| filterSectionDescription === "All" || | |||
| s.stockTakeSectionDescription === filterSectionDescription; | |||
| const matchSession = | |||
| !filterStockTakeSession || | |||
| (s.stockTakeSession ?? "") | |||
| .toString() | |||
| .toLowerCase() | |||
| .includes(filterStockTakeSession.toLowerCase()); | |||
| return matchDesc && matchSession; | |||
| }); | |||
| // SearchBox 的条件配置 | |||
| const criteria: Criterion<PickerSearchKey>[] = [ | |||
| { | |||
| type: "select", | |||
| label: "Stock Take Section Description", | |||
| paramName: "sectionDescription", | |||
| options: sectionDescriptionOptions, | |||
| }, | |||
| { | |||
| type: "text", | |||
| label: "Stock Take Section", | |||
| paramName: "stockTakeSession", | |||
| placeholder: "e.g. A01", | |||
| }, | |||
| ]; | |||
| const handleSearch = (inputs: Record<PickerSearchKey | `${PickerSearchKey}To`, string>) => { | |||
| setFilterSectionDescription(inputs.sectionDescription || "All"); | |||
| setFilterStockTakeSession(inputs.stockTakeSession || ""); | |||
| }; | |||
| const handleResetSearch = () => { | |||
| setFilterSectionDescription("All"); | |||
| setFilterStockTakeSession(""); | |||
| }; | |||
| const fetchStockTakeSessions = useCallback( | |||
| async (pageNum: number, size: number) => { | |||
| setLoading(true); | |||
| @@ -188,8 +241,15 @@ const [total, setTotal] = useState(0); | |||
| return ( | |||
| <Box> | |||
| <Box sx={{ width: "100%", mb: 2 }}> | |||
| <SearchBox<PickerSearchKey> | |||
| criteria={criteria} | |||
| onSearch={handleSearch} | |||
| onReset={handleResetSearch} | |||
| /> | |||
| </Box> | |||
| <Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", mb: 2 }}> | |||
| <Typography variant="body2" color="text.secondary"> | |||
| {t("Total Sections")}: {stockTakeSessions.length} | |||
| @@ -209,7 +269,7 @@ const [total, setTotal] = useState(0); | |||
| </Box> | |||
| <Grid container spacing={2}> | |||
| {stockTakeSessions.map((session: AllPickedStockTakeListReponse) => { | |||
| {filteredSessions.map((session: AllPickedStockTakeListReponse) => { | |||
| const statusColor = getStatusColor(session.status || ""); | |||
| const lastStockTakeDate = session.lastStockTakeDate | |||
| ? dayjs(session.lastStockTakeDate).format(OUTPUT_DATE_FORMAT) | |||
| @@ -229,10 +289,11 @@ const [total, setTotal] = useState(0); | |||
| > | |||
| <CardContent sx={{ pb: 1, flexGrow: 1 }}> | |||
| <Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 1 }}> | |||
| <Typography variant="subtitle1" fontWeight={600}> | |||
| {t("Section")}: {session.stockTakeSession} | |||
| </Typography> | |||
| <Typography variant="subtitle1" fontWeight={600}> | |||
| {t("Section")}: {session.stockTakeSession} | |||
| {session.stockTakeSectionDescription ? ` (${session.stockTakeSectionDescription})` : null} | |||
| </Typography> | |||
| </Stack> | |||
| <Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}> | |||
| @@ -0,0 +1,355 @@ | |||
| "use client"; | |||
| import React, { useCallback, useEffect, useMemo, useState } from "react"; | |||
| import { | |||
| Box, | |||
| Button, | |||
| Dialog, | |||
| DialogActions, | |||
| DialogContent, | |||
| DialogTitle, | |||
| Stack, | |||
| TextField, | |||
| Typography, | |||
| CircularProgress, | |||
| IconButton, | |||
| TableContainer, | |||
| Table, | |||
| TableHead, | |||
| TableRow, | |||
| TableCell, | |||
| TableBody, | |||
| } from "@mui/material"; | |||
| import Delete from "@mui/icons-material/Delete"; | |||
| import Add from "@mui/icons-material/Add"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import { Edit } from "@mui/icons-material"; | |||
| import SearchBox, { Criterion } from "@/components/SearchBox/SearchBox"; | |||
| import SearchResults, { Column } from "@/components/SearchResults/SearchResults"; | |||
| import { | |||
| fetchStockTakeSections, | |||
| updateSectionDescription, | |||
| clearWarehouseSection, | |||
| getWarehousesBySection, | |||
| searchWarehousesForAddToSection, | |||
| editWarehouse, | |||
| } from "@/app/api/warehouse/actions"; | |||
| import { WarehouseResult } from "@/app/api/warehouse"; | |||
| import { StockTakeSectionInfo } from "@/app/api/warehouse"; | |||
| import { deleteDialog, successDialog } from "@/components/Swal/CustomAlerts"; | |||
| type SearchKey = "stockTakeSection" | "stockTakeSectionDescription"; | |||
| export default function TabStockTakeSectionMapping() { | |||
| const { t } = useTranslation(["warehouse", "common"]); | |||
| const [sections, setSections] = useState<StockTakeSectionInfo[]>([]); | |||
| const [filteredSections, setFilteredSections] = useState<StockTakeSectionInfo[]>([]); | |||
| const [selectedSection, setSelectedSection] = useState<StockTakeSectionInfo | null>(null); | |||
| const [warehousesInSection, setWarehousesInSection] = useState<WarehouseResult[]>([]); | |||
| const [loading, setLoading] = useState(true); | |||
| const [openDialog, setOpenDialog] = useState(false); | |||
| const [editDesc, setEditDesc] = useState(""); | |||
| const [savingDesc, setSavingDesc] = useState(false); | |||
| const [warehouseList, setWarehouseList] = useState<WarehouseResult[]>([]); | |||
| const [openAddDialog, setOpenAddDialog] = useState(false); | |||
| const [addStoreId, setAddStoreId] = useState(""); | |||
| const [addWarehouse, setAddWarehouse] = useState(""); | |||
| const [addArea, setAddArea] = useState(""); | |||
| const [addSlot, setAddSlot] = useState(""); | |||
| const [addSearchResults, setAddSearchResults] = useState<WarehouseResult[]>([]); | |||
| const [addSearching, setAddSearching] = useState(false); | |||
| const [addingWarehouseId, setAddingWarehouseId] = useState<number | null>(null); | |||
| const loadSections = useCallback(async () => { | |||
| setLoading(true); | |||
| try { | |||
| const data = await fetchStockTakeSections(); | |||
| const withId = (data ?? []).map((s) => ({ | |||
| ...s, | |||
| id: s.stockTakeSection, | |||
| })); | |||
| setSections(withId); | |||
| setFilteredSections(withId); | |||
| } catch (e) { | |||
| console.error(e); | |||
| setSections([]); | |||
| setFilteredSections([]); | |||
| } finally { | |||
| setLoading(false); | |||
| } | |||
| }, []); | |||
| useEffect(() => { | |||
| loadSections(); | |||
| }, [loadSections]); | |||
| const handleViewSection = useCallback(async (section: StockTakeSectionInfo) => { | |||
| setSelectedSection(section); | |||
| setEditDesc(section.stockTakeSectionDescription ?? ""); | |||
| setOpenDialog(true); | |||
| try { | |||
| const list = await getWarehousesBySection(section.stockTakeSection); | |||
| setWarehousesInSection(list ?? []); | |||
| } catch (e) { | |||
| console.error(e); | |||
| setWarehousesInSection([]); | |||
| } | |||
| }, []); | |||
| const criteria: Criterion<SearchKey>[] = useMemo( | |||
| () => [ | |||
| { type: "text", label: "Stock Take Section", paramName: "stockTakeSection", placeholder: "" }, | |||
| { type: "text", label: "Stock Take Section Description", paramName: "stockTakeSectionDescription", placeholder: "" }, | |||
| ], | |||
| [] | |||
| ); | |||
| const handleSearch = useCallback((inputs: Record<SearchKey | `${SearchKey}To`, string>) => { | |||
| const section = (inputs.stockTakeSection ?? "").trim().toLowerCase(); | |||
| const desc = (inputs.stockTakeSectionDescription ?? "").trim().toLowerCase(); | |||
| setFilteredSections( | |||
| sections.filter( | |||
| (s) => | |||
| (!section || (s.stockTakeSection ?? "").toLowerCase().includes(section)) && | |||
| (!desc || (s.stockTakeSectionDescription ?? "").toLowerCase().includes(desc)) | |||
| ) | |||
| ); | |||
| }, [sections]); | |||
| const handleReset = useCallback(() => { | |||
| setFilteredSections(sections); | |||
| }, [sections]); | |||
| const handleSaveDescription = useCallback(async () => { | |||
| if (!selectedSection) return; | |||
| setSavingDesc(true); | |||
| try { | |||
| await updateSectionDescription(selectedSection.stockTakeSection, editDesc || null); | |||
| await loadSections(); | |||
| if (selectedSection) { | |||
| setSelectedSection((prev) => (prev ? { ...prev, stockTakeSectionDescription: editDesc || null } : null)); | |||
| } | |||
| successDialog(t("Saved"), t); | |||
| } catch (e) { | |||
| console.error(e); | |||
| } finally { | |||
| setSavingDesc(false); | |||
| } | |||
| }, [selectedSection, editDesc, loadSections, t]); | |||
| const handleRemoveWarehouse = useCallback( | |||
| (warehouse: WarehouseResult) => { | |||
| deleteDialog(async () => { | |||
| try { | |||
| await clearWarehouseSection(warehouse.id); | |||
| setWarehousesInSection((prev) => prev.filter((w) => w.id !== warehouse.id)); | |||
| successDialog(t("Delete Success"), t); | |||
| } catch (e) { | |||
| console.error(e); | |||
| } | |||
| }, t); | |||
| }, | |||
| [t] | |||
| ); | |||
| const handleOpenAddWarehouse = useCallback(() => { | |||
| setAddStoreId(""); | |||
| setAddWarehouse(""); | |||
| setAddArea(""); | |||
| setAddSlot(""); | |||
| setAddSearchResults([]); | |||
| setOpenAddDialog(true); | |||
| }, []); | |||
| const handleAddSearch = useCallback(async () => { | |||
| if (!selectedSection) return; | |||
| setAddSearching(true); | |||
| try { | |||
| const params: { store_id?: string; warehouse?: string; area?: string; slot?: string } = {}; | |||
| if (addStoreId.trim()) params.store_id = addStoreId.trim(); | |||
| if (addWarehouse.trim()) params.warehouse = addWarehouse.trim(); | |||
| if (addArea.trim()) params.area = addArea.trim(); | |||
| if (addSlot.trim()) params.slot = addSlot.trim(); | |||
| const list = await searchWarehousesForAddToSection(params, selectedSection.stockTakeSection); | |||
| setAddSearchResults(list ?? []); | |||
| } catch (e) { | |||
| console.error(e); | |||
| setAddSearchResults([]); | |||
| } finally { | |||
| setAddSearching(false); | |||
| } | |||
| }, [selectedSection, addStoreId, addWarehouse, addArea, addSlot]); | |||
| const handleAddWarehouseToSection = useCallback( | |||
| async (w: WarehouseResult) => { | |||
| if (!selectedSection) return; | |||
| setAddingWarehouseId(w.id); | |||
| try { | |||
| await editWarehouse(w.id, { | |||
| stockTakeSection: selectedSection.stockTakeSection, | |||
| stockTakeSectionDescription: selectedSection.stockTakeSectionDescription ?? undefined, | |||
| }); | |||
| setWarehousesInSection((prev) => [...prev, w]); | |||
| setAddSearchResults((prev) => prev.filter((x) => x.id !== w.id)); | |||
| successDialog(t("Add Success") ?? t("Saved"), t); | |||
| } catch (e) { | |||
| console.error(e); | |||
| } finally { | |||
| setAddingWarehouseId(null); | |||
| } | |||
| }, | |||
| [selectedSection, t] | |||
| ); | |||
| const columns = useMemo<Column<StockTakeSectionInfo>[]>( | |||
| () => [ | |||
| { name: "stockTakeSection", label: t("stockTakeSection"), align: "left", sx: { width: "25%" } }, | |||
| { name: "stockTakeSectionDescription", label: t("stockTakeSectionDescription"), align: "left", sx: { width: "35%" } }, | |||
| { | |||
| name: "id", | |||
| label: t("Edit"), | |||
| onClick: (row) => handleViewSection(row), | |||
| buttonIcon: <Edit />, | |||
| buttonIcons: {} as Record<keyof StockTakeSectionInfo, React.ReactNode>, | |||
| color: "primary", | |||
| sx: { width: "20%" }, | |||
| }, | |||
| ], | |||
| [t, handleViewSection] | |||
| ); | |||
| if (loading) { | |||
| return ( | |||
| <Box sx={{ display: "flex", justifyContent: "center", minHeight: 200, alignItems: "center" }}> | |||
| <CircularProgress /> | |||
| </Box> | |||
| ); | |||
| } | |||
| return ( | |||
| <Box> | |||
| <SearchBox<SearchKey> criteria={criteria} onSearch={handleSearch} onReset={handleReset} /> | |||
| <SearchResults<StockTakeSectionInfo> items={filteredSections} columns={columns} /> | |||
| <Dialog open={openDialog} onClose={() => setOpenDialog(false)} maxWidth="md" fullWidth sx={{ zIndex: 1000 }}> | |||
| <DialogTitle> | |||
| {t("Mapping Details")} - {selectedSection?.stockTakeSection} ({selectedSection?.stockTakeSectionDescription ?? ""}) | |||
| </DialogTitle> | |||
| <DialogContent> | |||
| <Stack direction="row" alignItems="center" spacing={2} sx={{ mb: 1, minHeight: 40 }}> | |||
| <Typography variant="body2" sx={{ display: "flex", alignItems: "center" }}> | |||
| {t("stockTakeSectionDescription")} | |||
| </Typography> | |||
| <TextField size="small" value={editDesc} onChange={(e) => setEditDesc(e.target.value)} sx={{ minWidth: 200 }} /> | |||
| <Button variant="contained" size="small" disabled={savingDesc} onClick={handleSaveDescription}> | |||
| {t("Save")} | |||
| </Button> | |||
| <Box sx={{ flex: 1 }} /> | |||
| <Button variant="contained" startIcon={<Add />} onClick={handleOpenAddWarehouse}> | |||
| {t("Add Warehouse")} | |||
| </Button> | |||
| </Stack> | |||
| <TableContainer> | |||
| <Table size="small"> | |||
| <TableHead> | |||
| <TableRow> | |||
| <TableCell>{t("code")}</TableCell> | |||
| <TableCell>{t("Actions")}</TableCell> | |||
| </TableRow> | |||
| </TableHead> | |||
| <TableBody> | |||
| {warehousesInSection.length === 0 ? ( | |||
| <TableRow><TableCell colSpan={3} align="center">{t("No warehouses")}</TableCell></TableRow> | |||
| ) : ( | |||
| warehousesInSection.map((w) => ( | |||
| <TableRow key={w.id}> | |||
| <TableCell>{w.code}</TableCell> | |||
| <TableCell> | |||
| <IconButton color="error" size="small" onClick={() => handleRemoveWarehouse(w)}> | |||
| <Delete /> | |||
| </IconButton> | |||
| </TableCell> | |||
| </TableRow> | |||
| )) | |||
| )} | |||
| </TableBody> | |||
| </Table> | |||
| </TableContainer> | |||
| </DialogContent> | |||
| <DialogActions> | |||
| <Button onClick={() => setOpenDialog(false)}>{t("Cancel")}</Button> | |||
| </DialogActions> | |||
| </Dialog> | |||
| <Dialog open={openAddDialog} onClose={() => setOpenAddDialog(false)} maxWidth="sm" fullWidth sx={{ zIndex: 1000 }}> | |||
| <DialogTitle>{t("Add Warehouse")}</DialogTitle> | |||
| <DialogContent> | |||
| <Stack spacing={2} sx={{ pt: 1 }}> | |||
| <TextField | |||
| size="small" | |||
| label={t("Store ID")} | |||
| value={addStoreId} | |||
| onChange={(e) => setAddStoreId(e.target.value)} | |||
| fullWidth | |||
| /> | |||
| <TextField | |||
| size="small" | |||
| label={t("warehouse")} | |||
| value={addWarehouse} | |||
| onChange={(e) => setAddWarehouse(e.target.value)} | |||
| fullWidth | |||
| /> | |||
| <TextField | |||
| size="small" | |||
| label={t("area")} | |||
| value={addArea} | |||
| onChange={(e) => setAddArea(e.target.value)} | |||
| fullWidth | |||
| /> | |||
| <TextField | |||
| size="small" | |||
| label={t("slot")} | |||
| value={addSlot} | |||
| onChange={(e) => setAddSlot(e.target.value)} | |||
| fullWidth | |||
| /> | |||
| <Button variant="contained" onClick={handleAddSearch} disabled={addSearching}> | |||
| {addSearching ? <CircularProgress size={20} /> : t("Search")} | |||
| </Button> | |||
| <TableContainer> | |||
| <Table size="small"> | |||
| <TableHead> | |||
| <TableRow> | |||
| <TableCell>{t("code")}</TableCell> | |||
| <TableCell>{t("name")}</TableCell> | |||
| <TableCell>{t("Actions")}</TableCell> | |||
| </TableRow> | |||
| </TableHead> | |||
| <TableBody> | |||
| {addSearchResults | |||
| .filter((w) => !warehousesInSection.some((inc) => inc.id === w.id)) | |||
| .map((w) => ( | |||
| <TableRow key={w.id}> | |||
| <TableCell>{w.code}</TableCell> | |||
| <TableCell>{w.name}</TableCell> | |||
| <TableCell> | |||
| <Button | |||
| size="small" | |||
| variant="outlined" | |||
| disabled={addingWarehouseId === w.id} | |||
| onClick={() => handleAddWarehouseToSection(w)} | |||
| > | |||
| {addingWarehouseId === w.id ? <CircularProgress size={16} /> : t("Add")} | |||
| </Button> | |||
| </TableCell> | |||
| </TableRow> | |||
| ))} | |||
| </TableBody> | |||
| </Table> | |||
| </TableContainer> | |||
| </Stack> | |||
| </DialogContent> | |||
| <DialogActions> | |||
| <Button onClick={() => setOpenAddDialog(false)}>{t("Cancel")}</Button> | |||
| </DialogActions> | |||
| </Dialog> | |||
| </Box> | |||
| ); | |||
| } | |||
| @@ -0,0 +1,520 @@ | |||
| "use client"; | |||
| import { useCallback, useMemo, useState } from "react"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import SearchResults, { Column } from "../SearchResults/SearchResults"; | |||
| import DeleteIcon from "@mui/icons-material/Delete"; | |||
| import EditIcon from "@mui/icons-material/Edit"; | |||
| import { useRouter } from "next/navigation"; | |||
| import { deleteDialog, successDialog } from "../Swal/CustomAlerts"; | |||
| import { WarehouseResult } from "@/app/api/warehouse"; | |||
| import { deleteWarehouse, editWarehouse } from "@/app/api/warehouse/actions"; | |||
| import Card from "@mui/material/Card"; | |||
| import CardContent from "@mui/material/CardContent"; | |||
| import CardActions from "@mui/material/CardActions"; | |||
| import Typography from "@mui/material/Typography"; | |||
| import TextField from "@mui/material/TextField"; | |||
| import Button from "@mui/material/Button"; | |||
| import Box from "@mui/material/Box"; | |||
| import RestartAlt from "@mui/icons-material/RestartAlt"; | |||
| import Search from "@mui/icons-material/Search"; | |||
| import InputAdornment from "@mui/material/InputAdornment"; | |||
| import Dialog from "@mui/material/Dialog"; | |||
| import DialogTitle from "@mui/material/DialogTitle"; | |||
| import DialogContent from "@mui/material/DialogContent"; | |||
| import DialogActions from "@mui/material/DialogActions"; | |||
| interface Props { | |||
| warehouses: WarehouseResult[]; | |||
| } | |||
| type SearchQuery = Partial<Omit<WarehouseResult, "id">>; | |||
| type SearchParamNames = keyof SearchQuery; | |||
| const WarehouseHandle: React.FC<Props> = ({ warehouses }) => { | |||
| const { t } = useTranslation(["warehouse", "common"]); | |||
| const [filteredWarehouse, setFilteredWarehouse] = useState(warehouses); | |||
| const [pagingController, setPagingController] = useState({ | |||
| pageNum: 1, | |||
| pageSize: 10, | |||
| }); | |||
| const router = useRouter(); | |||
| const [isSearching, setIsSearching] = useState(false); | |||
| // State for editing order & stockTakeSection | |||
| const [editingWarehouse, setEditingWarehouse] = useState<WarehouseResult | null>(null); | |||
| const [editValues, setEditValues] = useState({ | |||
| order: "", | |||
| stockTakeSection: "", | |||
| stockTakeSectionDescription: "", | |||
| }); | |||
| const [isSavingEdit, setIsSavingEdit] = useState(false); | |||
| const [editError, setEditError] = useState(""); | |||
| const [searchInputs, setSearchInputs] = useState({ | |||
| store_id: "", | |||
| warehouse: "", | |||
| area: "", | |||
| slot: "", | |||
| stockTakeSection: "", | |||
| stockTakeSectionDescription: "", | |||
| }); | |||
| const onDeleteClick = useCallback((warehouse: WarehouseResult) => { | |||
| deleteDialog(async () => { | |||
| try { | |||
| await deleteWarehouse(warehouse.id); | |||
| setFilteredWarehouse(prev => prev.filter(w => w.id !== warehouse.id)); | |||
| router.refresh(); | |||
| successDialog(t("Delete Success"), t); | |||
| } catch (error) { | |||
| console.error("Failed to delete warehouse:", error); | |||
| } | |||
| }, t); | |||
| }, [t, router]); | |||
| const handleReset = useCallback(() => { | |||
| setSearchInputs({ | |||
| store_id: "", | |||
| warehouse: "", | |||
| area: "", | |||
| slot: "", | |||
| stockTakeSection: "", | |||
| stockTakeSectionDescription: "", | |||
| }); | |||
| setFilteredWarehouse(warehouses); | |||
| setPagingController({ pageNum: 1, pageSize: pagingController.pageSize }); | |||
| }, [warehouses, pagingController.pageSize]); | |||
| const onEditClick = useCallback((warehouse: WarehouseResult) => { | |||
| setEditingWarehouse(warehouse); | |||
| setEditValues({ | |||
| order: warehouse.order ?? "", | |||
| stockTakeSection: warehouse.stockTakeSection ?? "", | |||
| stockTakeSectionDescription: warehouse.stockTakeSectionDescription ?? "", | |||
| }); | |||
| setEditError(""); | |||
| }, []); | |||
| const handleEditClose = useCallback(() => { | |||
| if (isSavingEdit) return; | |||
| setEditingWarehouse(null); | |||
| setEditError(""); | |||
| }, [isSavingEdit]); | |||
| const handleEditSave = useCallback(async () => { | |||
| if (!editingWarehouse) return; | |||
| const trimmedOrder = editValues.order.trim(); | |||
| const trimmedStockTakeSection = editValues.stockTakeSection.trim(); | |||
| const trimmedStockTakeSectionDescription = editValues.stockTakeSectionDescription.trim(); | |||
| const orderPattern = /^[A-Za-z0-9]{2}-[A-Za-z0-9]{3}$/; | |||
| const sectionPattern = /^[A-Za-z0-9]{2}-[A-Za-z0-9]{3}$/; | |||
| if (trimmedOrder && !orderPattern.test(trimmedOrder)) { | |||
| setEditError(`${t("order")} 格式必須為 XF-YYY`); | |||
| return; | |||
| } | |||
| if (trimmedStockTakeSection && !sectionPattern.test(trimmedStockTakeSection)) { | |||
| setEditError(`${t("stockTakeSection")} 格式必須為 ST-YYY`); | |||
| return; | |||
| } | |||
| try { | |||
| setIsSavingEdit(true); | |||
| setEditError(""); | |||
| await editWarehouse(editingWarehouse.id, { | |||
| order: trimmedOrder || undefined, | |||
| stockTakeSection: trimmedStockTakeSection || undefined, | |||
| stockTakeSectionDescription: trimmedStockTakeSectionDescription || undefined, | |||
| }); | |||
| setFilteredWarehouse((prev) => | |||
| prev.map((w) => | |||
| w.id === editingWarehouse.id | |||
| ? { | |||
| ...w, | |||
| order: trimmedOrder || undefined, | |||
| stockTakeSection: trimmedStockTakeSection || undefined, | |||
| stockTakeSectionDescription: trimmedStockTakeSectionDescription || undefined, | |||
| } | |||
| : w, | |||
| ), | |||
| ); | |||
| router.refresh(); | |||
| setEditingWarehouse(null); | |||
| } catch (error: unknown) { | |||
| console.error("Failed to edit warehouse:", error); | |||
| const message = error instanceof Error ? error.message : t("An error has occurred. Please try again later."); | |||
| setEditError(message); | |||
| } finally { | |||
| setIsSavingEdit(false); | |||
| } | |||
| }, [editValues, editingWarehouse, router, t, setFilteredWarehouse]); | |||
| const handleSearch = useCallback(() => { | |||
| setIsSearching(true); | |||
| try { | |||
| let results: WarehouseResult[] = warehouses; | |||
| const storeId = searchInputs.store_id?.trim() || ""; | |||
| const warehouse = searchInputs.warehouse?.trim() || ""; | |||
| const area = searchInputs.area?.trim() || ""; | |||
| const slot = searchInputs.slot?.trim() || ""; | |||
| const stockTakeSection = searchInputs.stockTakeSection?.trim() || ""; | |||
| const stockTakeSectionDescription = searchInputs.stockTakeSectionDescription?.trim() || ""; | |||
| if (storeId || warehouse || area || slot || stockTakeSection || stockTakeSectionDescription) { | |||
| results = warehouses.filter((warehouseItem) => { | |||
| if (stockTakeSection) { | |||
| const itemStockTakeSection = String(warehouseItem.stockTakeSection || "").toLowerCase(); | |||
| if (!itemStockTakeSection.includes(stockTakeSection.toLowerCase())) { | |||
| return false; | |||
| } | |||
| } | |||
| if (stockTakeSectionDescription) { | |||
| const itemStockTakeSectionDescription = String(warehouseItem.stockTakeSectionDescription || "").toLowerCase(); | |||
| if (!itemStockTakeSectionDescription.includes(stockTakeSectionDescription.toLowerCase())) { | |||
| return false; | |||
| } | |||
| } | |||
| if (storeId || warehouse || area || slot) { | |||
| if (!warehouseItem.code) { | |||
| return false; | |||
| } | |||
| const codeValue = String(warehouseItem.code).toLowerCase(); | |||
| const codeParts = codeValue.split("-"); | |||
| if (codeParts.length >= 4) { | |||
| const codeStoreId = codeParts[0] || ""; | |||
| const codeWarehouse = codeParts[1] || ""; | |||
| const codeArea = codeParts[2] || ""; | |||
| const codeSlot = codeParts[3] || ""; | |||
| const storeIdMatch = !storeId || codeStoreId.includes(storeId.toLowerCase()); | |||
| const warehouseMatch = !warehouse || codeWarehouse.includes(warehouse.toLowerCase()); | |||
| const areaMatch = !area || codeArea.includes(area.toLowerCase()); | |||
| const slotMatch = !slot || codeSlot.includes(slot.toLowerCase()); | |||
| return storeIdMatch && warehouseMatch && areaMatch && slotMatch; | |||
| } | |||
| const storeIdMatch = !storeId || codeValue.includes(storeId.toLowerCase()); | |||
| const warehouseMatch = !warehouse || codeValue.includes(warehouse.toLowerCase()); | |||
| const areaMatch = !area || codeValue.includes(area.toLowerCase()); | |||
| const slotMatch = !slot || codeValue.includes(slot.toLowerCase()); | |||
| return storeIdMatch && warehouseMatch && areaMatch && slotMatch; | |||
| } | |||
| return true; | |||
| }); | |||
| } else { | |||
| results = warehouses; | |||
| } | |||
| setFilteredWarehouse(results); | |||
| setPagingController({ pageNum: 1, pageSize: pagingController.pageSize }); | |||
| } catch (error) { | |||
| console.error("Error searching warehouses:", error); | |||
| const storeId = searchInputs.store_id?.trim().toLowerCase() || ""; | |||
| const warehouse = searchInputs.warehouse?.trim().toLowerCase() || ""; | |||
| const area = searchInputs.area?.trim().toLowerCase() || ""; | |||
| const slot = searchInputs.slot?.trim().toLowerCase() || ""; | |||
| const stockTakeSection = searchInputs.stockTakeSection?.trim().toLowerCase() || ""; | |||
| const stockTakeSectionDescription = searchInputs.stockTakeSectionDescription?.trim().toLowerCase() || ""; | |||
| setFilteredWarehouse( | |||
| warehouses.filter((warehouseItem) => { | |||
| if (stockTakeSection) { | |||
| const itemStockTakeSection = String(warehouseItem.stockTakeSection || "").toLowerCase(); | |||
| if (!itemStockTakeSection.includes(stockTakeSection)) { | |||
| return false; | |||
| } | |||
| } | |||
| if (stockTakeSectionDescription) { | |||
| const itemStockTakeSectionDescription = String(warehouseItem.stockTakeSectionDescription || "").toLowerCase(); | |||
| if (!itemStockTakeSectionDescription.includes(stockTakeSectionDescription)) { | |||
| return false; | |||
| } | |||
| } | |||
| if (storeId || warehouse || area || slot) { | |||
| if (!warehouseItem.code) { | |||
| return false; | |||
| } | |||
| const codeValue = String(warehouseItem.code).toLowerCase(); | |||
| const codeParts = codeValue.split("-"); | |||
| if (codeParts.length >= 4) { | |||
| const storeIdMatch = !storeId || codeParts[0].includes(storeId); | |||
| const warehouseMatch = !warehouse || codeParts[1].includes(warehouse); | |||
| const areaMatch = !area || codeParts[2].includes(area); | |||
| const slotMatch = !slot || codeParts[3].includes(slot); | |||
| return storeIdMatch && warehouseMatch && areaMatch && slotMatch; | |||
| } | |||
| return (!storeId || codeValue.includes(storeId)) && | |||
| (!warehouse || codeValue.includes(warehouse)) && | |||
| (!area || codeValue.includes(area)) && | |||
| (!slot || codeValue.includes(slot)); | |||
| } | |||
| return true; | |||
| }) | |||
| ); | |||
| } finally { | |||
| setIsSearching(false); | |||
| } | |||
| }, [searchInputs, warehouses, pagingController.pageSize]); | |||
| const columns = useMemo<Column<WarehouseResult>[]>( | |||
| () => [ | |||
| { | |||
| name: "action", | |||
| label: t("Edit"), | |||
| onClick: onEditClick, | |||
| buttonIcon: <EditIcon />, | |||
| color: "primary", | |||
| sx: { width: "10%", minWidth: "80px" }, | |||
| }, | |||
| { | |||
| name: "code", | |||
| label: t("code"), | |||
| align: "left", | |||
| headerAlign: "left", | |||
| sx: { width: "15%", minWidth: "120px" }, | |||
| }, | |||
| { | |||
| name: "store_id", | |||
| label: t("store_id"), | |||
| align: "left", | |||
| headerAlign: "left", | |||
| sx: { width: "15%", minWidth: "120px" }, | |||
| }, | |||
| { | |||
| name: "warehouse", | |||
| label: t("warehouse"), | |||
| align: "left", | |||
| headerAlign: "left", | |||
| sx: { width: "15%", minWidth: "120px" }, | |||
| }, | |||
| { | |||
| name: "area", | |||
| label: t("area"), | |||
| align: "left", | |||
| headerAlign: "left", | |||
| sx: { width: "15%", minWidth: "120px" }, | |||
| }, | |||
| { | |||
| name: "slot", | |||
| label: t("slot"), | |||
| align: "left", | |||
| headerAlign: "left", | |||
| sx: { width: "15%", minWidth: "120px" }, | |||
| }, | |||
| { | |||
| name: "order", | |||
| label: t("order"), | |||
| align: "left", | |||
| headerAlign: "left", | |||
| sx: { width: "15%", minWidth: "120px" }, | |||
| }, | |||
| { | |||
| name: "stockTakeSection", | |||
| label: t("stockTakeSection"), | |||
| align: "left", | |||
| headerAlign: "left", | |||
| sx: { width: "15%", minWidth: "120px" }, | |||
| }, | |||
| { | |||
| name: "stockTakeSectionDescription", | |||
| label: t("stockTakeSectionDescription"), | |||
| align: "left", | |||
| headerAlign: "left", | |||
| sx: { width: "15%", minWidth: "120px" }, | |||
| }, | |||
| { | |||
| name: "action", | |||
| label: t("Delete"), | |||
| onClick: onDeleteClick, | |||
| buttonIcon: <DeleteIcon />, | |||
| color: "error", | |||
| sx: { width: "10%", minWidth: "80px" }, | |||
| }, | |||
| ], | |||
| [t, onDeleteClick], | |||
| ); | |||
| return ( | |||
| <> | |||
| <Card> | |||
| <CardContent sx={{ display: "flex", flexDirection: "column", gap: 1 }}> | |||
| <Typography variant="overline">{t("Search Criteria")}</Typography> | |||
| <Box | |||
| sx={{ | |||
| display: "flex", | |||
| alignItems: "center", | |||
| gap: 1, | |||
| flexWrap: "nowrap", | |||
| justifyContent: "flex-start", | |||
| }} | |||
| > | |||
| <TextField | |||
| label={t("store_id")} | |||
| value={searchInputs.store_id} | |||
| onChange={(e) => | |||
| setSearchInputs((prev) => ({ ...prev, store_id: e.target.value })) | |||
| } | |||
| size="small" | |||
| sx={{ width: "150px", minWidth: "120px" }} | |||
| InputProps={{ | |||
| endAdornment: ( | |||
| <InputAdornment position="end">F</InputAdornment> | |||
| ), | |||
| }} | |||
| /> | |||
| <Typography variant="body1" sx={{ mx: 0.5 }}> | |||
| - | |||
| </Typography> | |||
| <TextField | |||
| label={t("warehouse")} | |||
| value={searchInputs.warehouse} | |||
| onChange={(e) => | |||
| setSearchInputs((prev) => ({ ...prev, warehouse: e.target.value })) | |||
| } | |||
| size="small" | |||
| sx={{ width: "150px", minWidth: "120px" }} | |||
| /> | |||
| <Typography variant="body1" sx={{ mx: 0.5 }}> | |||
| - | |||
| </Typography> | |||
| <TextField | |||
| label={t("area")} | |||
| value={searchInputs.area} | |||
| onChange={(e) => | |||
| setSearchInputs((prev) => ({ ...prev, area: e.target.value })) | |||
| } | |||
| size="small" | |||
| sx={{ width: "150px", minWidth: "120px" }} | |||
| /> | |||
| <Typography variant="body1" sx={{ mx: 0.5 }}> | |||
| - | |||
| </Typography> | |||
| <TextField | |||
| label={t("slot")} | |||
| value={searchInputs.slot} | |||
| onChange={(e) => | |||
| setSearchInputs((prev) => ({ ...prev, slot: e.target.value })) | |||
| } | |||
| size="small" | |||
| sx={{ width: "150px", minWidth: "120px" }} | |||
| /> | |||
| <Box sx={{ flex: 1, minWidth: "150px", ml: 2 }}> | |||
| <TextField | |||
| label={t("stockTakeSection")} | |||
| value={searchInputs.stockTakeSection} | |||
| onChange={(e) => | |||
| setSearchInputs((prev) => ({ ...prev, stockTakeSection: e.target.value })) | |||
| } | |||
| size="small" | |||
| fullWidth | |||
| /> | |||
| </Box> | |||
| <Box sx={{ flex: 1, minWidth: "150px", ml: 2 }}> | |||
| <TextField | |||
| label={t("stockTakeSectionDescription")} | |||
| value={searchInputs.stockTakeSectionDescription} | |||
| onChange={(e) => | |||
| setSearchInputs((prev) => ({ ...prev, stockTakeSectionDescription: e.target.value })) | |||
| } | |||
| size="small" | |||
| fullWidth | |||
| /> | |||
| </Box> | |||
| </Box> | |||
| <CardActions sx={{ justifyContent: "flex-start", px: 0, pt: 1 }}> | |||
| <Button | |||
| variant="text" | |||
| startIcon={<RestartAlt />} | |||
| onClick={handleReset} | |||
| > | |||
| {t("Reset")} | |||
| </Button> | |||
| <Button | |||
| variant="outlined" | |||
| startIcon={<Search />} | |||
| onClick={handleSearch} | |||
| > | |||
| {t("Search")} | |||
| </Button> | |||
| </CardActions> | |||
| </CardContent> | |||
| </Card> | |||
| <SearchResults<WarehouseResult> | |||
| items={filteredWarehouse} | |||
| columns={columns} | |||
| pagingController={pagingController} | |||
| setPagingController={setPagingController} | |||
| /> | |||
| <Dialog | |||
| open={Boolean(editingWarehouse)} | |||
| onClose={handleEditClose} | |||
| fullWidth | |||
| maxWidth="sm" | |||
| > | |||
| <DialogTitle>{t("Edit")}</DialogTitle> | |||
| <DialogContent sx={{ pt: 2, display: "flex", flexDirection: "column", gap: 2 }}> | |||
| {editError && ( | |||
| <Typography variant="body2" color="error"> | |||
| {editError} | |||
| </Typography> | |||
| )} | |||
| <TextField | |||
| label={t("order")} | |||
| value={editValues.order} | |||
| onChange={(e) => | |||
| setEditValues((prev) => ({ ...prev, order: e.target.value })) | |||
| } | |||
| size="small" | |||
| fullWidth | |||
| /> | |||
| <TextField | |||
| label={t("stockTakeSection")} | |||
| value={editValues.stockTakeSection} | |||
| onChange={(e) => | |||
| setEditValues((prev) => ({ ...prev, stockTakeSection: e.target.value })) | |||
| } | |||
| size="small" | |||
| fullWidth | |||
| /> | |||
| <TextField | |||
| label={t("stockTakeSectionDescription")} | |||
| value={editValues.stockTakeSectionDescription} | |||
| onChange={(e) => | |||
| setEditValues((prev) => ({ ...prev, stockTakeSectionDescription: e.target.value })) | |||
| } | |||
| size="small" | |||
| fullWidth | |||
| /> | |||
| </DialogContent> | |||
| <DialogActions> | |||
| <Button onClick={handleEditClose} disabled={isSavingEdit}> | |||
| {t("Cancel")} | |||
| </Button> | |||
| <Button | |||
| onClick={handleEditSave} | |||
| disabled={isSavingEdit} | |||
| variant="contained" | |||
| > | |||
| {t("Save", { ns: "common" })} | |||
| </Button> | |||
| </DialogActions> | |||
| </Dialog> | |||
| </> | |||
| ); | |||
| }; | |||
| export default WarehouseHandle; | |||
| @@ -0,0 +1,40 @@ | |||
| import Card from "@mui/material/Card"; | |||
| import CardContent from "@mui/material/CardContent"; | |||
| import Skeleton from "@mui/material/Skeleton"; | |||
| import Stack from "@mui/material/Stack"; | |||
| import React from "react"; | |||
| // Can make this nicer | |||
| export const WarehouseHandleLoading: React.FC = () => { | |||
| return ( | |||
| <> | |||
| <Card> | |||
| <CardContent> | |||
| <Stack spacing={2}> | |||
| <Skeleton variant="rounded" height={60} /> | |||
| <Skeleton variant="rounded" height={60} /> | |||
| <Skeleton variant="rounded" height={60} /> | |||
| <Skeleton | |||
| variant="rounded" | |||
| height={50} | |||
| width={100} | |||
| sx={{ alignSelf: "flex-end" }} | |||
| /> | |||
| </Stack> | |||
| </CardContent> | |||
| </Card> | |||
| <Card> | |||
| <CardContent> | |||
| <Stack spacing={2}> | |||
| <Skeleton variant="rounded" height={40} /> | |||
| <Skeleton variant="rounded" height={40} /> | |||
| <Skeleton variant="rounded" height={40} /> | |||
| <Skeleton variant="rounded" height={40} /> | |||
| </Stack> | |||
| </CardContent> | |||
| </Card> | |||
| </> | |||
| ); | |||
| }; | |||
| export default WarehouseHandleLoading; | |||
| @@ -0,0 +1,19 @@ | |||
| import React from "react"; | |||
| import WarehouseHandle from "./WarehouseHandle"; | |||
| import WarehouseHandleLoading from "./WarehouseHandleLoading"; | |||
| import { WarehouseResult, fetchWarehouseList } from "@/app/api/warehouse"; | |||
| interface SubComponents { | |||
| Loading: typeof WarehouseHandleLoading; | |||
| } | |||
| const WarehouseHandleWrapper: React.FC & SubComponents = async () => { | |||
| const warehouses = await fetchWarehouseList(); | |||
| console.log(warehouses); | |||
| return <WarehouseHandle warehouses={warehouses} />; | |||
| }; | |||
| WarehouseHandleWrapper.Loading = WarehouseHandleLoading; | |||
| export default WarehouseHandleWrapper; | |||
| @@ -0,0 +1,67 @@ | |||
| "use client"; | |||
| import { useState, ReactNode, useEffect } from "react"; | |||
| import { Box, Tabs, Tab } from "@mui/material"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import { useSearchParams, useRouter } from "next/navigation"; | |||
| interface WarehouseTabsProps { | |||
| tab0Content: ReactNode; | |||
| tab1Content: ReactNode; | |||
| } | |||
| function TabPanel({ | |||
| children, | |||
| value, | |||
| index, | |||
| }: { | |||
| children?: ReactNode; | |||
| value: number; | |||
| index: number; | |||
| }) { | |||
| return ( | |||
| <div role="tabpanel" hidden={value !== index}> | |||
| {value === index && <Box sx={{ py: 3 }}>{children}</Box>} | |||
| </div> | |||
| ); | |||
| } | |||
| export default function WarehouseTabs({ tab0Content, tab1Content }: WarehouseTabsProps) { | |||
| const { t } = useTranslation("warehouse"); | |||
| const searchParams = useSearchParams(); | |||
| const router = useRouter(); | |||
| const [currentTab, setCurrentTab] = useState(() => { | |||
| const tab = searchParams.get("tab"); | |||
| return tab === "1" ? 1 : 0; | |||
| }); | |||
| useEffect(() => { | |||
| const tab = searchParams.get("tab"); | |||
| setCurrentTab(tab === "1" ? 1 : 0); | |||
| }, [searchParams]); | |||
| const handleTabChange = (_e: React.SyntheticEvent, newValue: number) => { | |||
| setCurrentTab(newValue); | |||
| const params = new URLSearchParams(searchParams.toString()); | |||
| if (newValue === 0) params.delete("tab"); | |||
| else params.set("tab", String(newValue)); | |||
| router.push(`?${params.toString()}`, { scroll: false }); | |||
| }; | |||
| return ( | |||
| <Box sx={{ width: "100%" }}> | |||
| <Box sx={{ borderBottom: 1, borderColor: "divider" }}> | |||
| <Tabs value={currentTab} onChange={handleTabChange}> | |||
| <Tab label={t("Warehouse List")} /> | |||
| <Tab label={t("Stock Take Section & Warehouse Mapping")} /> | |||
| </Tabs> | |||
| </Box> | |||
| <TabPanel value={currentTab} index={0}> | |||
| {tab0Content} | |||
| </TabPanel> | |||
| <TabPanel value={currentTab} index={1}> | |||
| {tab1Content} | |||
| </TabPanel> | |||
| </Box> | |||
| ); | |||
| } | |||
| @@ -0,0 +1 @@ | |||
| export { default } from "./WarehouseHandleWrapper"; | |||
| @@ -46,6 +46,7 @@ const WarehouseHandle: React.FC<Props> = ({ warehouses }) => { | |||
| const [editValues, setEditValues] = useState({ | |||
| order: "", | |||
| stockTakeSection: "", | |||
| }); | |||
| const [isSavingEdit, setIsSavingEdit] = useState(false); | |||
| const [editError, setEditError] = useState(""); | |||
| @@ -56,6 +57,7 @@ const WarehouseHandle: React.FC<Props> = ({ warehouses }) => { | |||
| area: "", | |||
| slot: "", | |||
| stockTakeSection: "", | |||
| stockTakeSectionDescription: "", | |||
| }); | |||
| const onDeleteClick = useCallback((warehouse: WarehouseResult) => { | |||
| @@ -78,6 +80,7 @@ const WarehouseHandle: React.FC<Props> = ({ warehouses }) => { | |||
| area: "", | |||
| slot: "", | |||
| stockTakeSection: "", | |||
| stockTakeSectionDescription: "", | |||
| }); | |||
| setFilteredWarehouse(warehouses); | |||
| setPagingController({ pageNum: 1, pageSize: pagingController.pageSize }); | |||
| @@ -103,7 +106,6 @@ const WarehouseHandle: React.FC<Props> = ({ warehouses }) => { | |||
| const trimmedOrder = editValues.order.trim(); | |||
| const trimmedStockTakeSection = editValues.stockTakeSection.trim(); | |||
| const orderPattern = /^[A-Za-z0-9]{2}-[A-Za-z0-9]{3}$/; | |||
| const sectionPattern = /^[A-Za-z0-9]{2}-[A-Za-z0-9]{3}$/; | |||
| @@ -140,9 +142,10 @@ const WarehouseHandle: React.FC<Props> = ({ warehouses }) => { | |||
| router.refresh(); | |||
| setEditingWarehouse(null); | |||
| } catch (error) { | |||
| } catch (error: unknown) { | |||
| console.error("Failed to edit warehouse:", error); | |||
| setEditError(t("An error has occurred. Please try again later.")); | |||
| const message = error instanceof Error ? error.message : t("An error has occurred. Please try again later."); | |||
| setEditError(message); | |||
| } finally { | |||
| setIsSavingEdit(false); | |||
| } | |||
| @@ -158,8 +161,8 @@ const WarehouseHandle: React.FC<Props> = ({ warehouses }) => { | |||
| const area = searchInputs.area?.trim() || ""; | |||
| const slot = searchInputs.slot?.trim() || ""; | |||
| const stockTakeSection = searchInputs.stockTakeSection?.trim() || ""; | |||
| if (storeId || warehouse || area || slot || stockTakeSection) { | |||
| const stockTakeSectionDescription = searchInputs.stockTakeSectionDescription?.trim() || ""; | |||
| if (storeId || warehouse || area || slot || stockTakeSection || stockTakeSectionDescription) { | |||
| results = warehouses.filter((warehouseItem) => { | |||
| if (stockTakeSection) { | |||
| const itemStockTakeSection = String(warehouseItem.stockTakeSection || "").toLowerCase(); | |||
| @@ -167,7 +170,12 @@ const WarehouseHandle: React.FC<Props> = ({ warehouses }) => { | |||
| return false; | |||
| } | |||
| } | |||
| if (stockTakeSectionDescription) { | |||
| const itemStockTakeSectionDescription = String(warehouseItem.stockTakeSectionDescription || "").toLowerCase(); | |||
| if (!itemStockTakeSectionDescription.includes(stockTakeSectionDescription.toLowerCase())) { | |||
| return false; | |||
| } | |||
| } | |||
| if (storeId || warehouse || area || slot) { | |||
| if (!warehouseItem.code) { | |||
| return false; | |||
| @@ -214,7 +222,7 @@ const WarehouseHandle: React.FC<Props> = ({ warehouses }) => { | |||
| const area = searchInputs.area?.trim().toLowerCase() || ""; | |||
| const slot = searchInputs.slot?.trim().toLowerCase() || ""; | |||
| const stockTakeSection = searchInputs.stockTakeSection?.trim().toLowerCase() || ""; | |||
| const stockTakeSectionDescription = searchInputs.stockTakeSectionDescription?.trim().toLowerCase() || ""; | |||
| setFilteredWarehouse( | |||
| warehouses.filter((warehouseItem) => { | |||
| if (stockTakeSection) { | |||
| @@ -223,7 +231,12 @@ const WarehouseHandle: React.FC<Props> = ({ warehouses }) => { | |||
| return false; | |||
| } | |||
| } | |||
| if (stockTakeSectionDescription) { | |||
| const itemStockTakeSectionDescription = String(warehouseItem.stockTakeSectionDescription || "").toLowerCase(); | |||
| if (!itemStockTakeSectionDescription.includes(stockTakeSectionDescription)) { | |||
| return false; | |||
| } | |||
| } | |||
| if (storeId || warehouse || area || slot) { | |||
| if (!warehouseItem.code) { | |||
| return false; | |||
| @@ -313,7 +326,13 @@ const WarehouseHandle: React.FC<Props> = ({ warehouses }) => { | |||
| headerAlign: "left", | |||
| sx: { width: "15%", minWidth: "120px" }, | |||
| }, | |||
| { | |||
| name: "stockTakeSectionDescription", | |||
| label: t("stockTakeSectionDescription"), | |||
| align: "left", | |||
| headerAlign: "left", | |||
| sx: { width: "15%", minWidth: "120px" }, | |||
| }, | |||
| { | |||
| name: "action", | |||
| label: t("Delete"), | |||
| @@ -401,6 +420,17 @@ const WarehouseHandle: React.FC<Props> = ({ warehouses }) => { | |||
| fullWidth | |||
| /> | |||
| </Box> | |||
| <Box sx={{ flex: 1, minWidth: "150px", ml: 2 }}> | |||
| <TextField | |||
| label={t("stockTakeSectionDescription")} | |||
| value={searchInputs.stockTakeSectionDescription} | |||
| onChange={(e) => | |||
| setSearchInputs((prev) => ({ ...prev, stockTakeSectionDescription: e.target.value })) | |||
| } | |||
| size="small" | |||
| fullWidth | |||
| /> | |||
| </Box> | |||
| </Box> | |||
| <CardActions sx={{ justifyContent: "flex-start", px: 0, pt: 1 }}> | |||
| <Button | |||
| @@ -41,6 +41,8 @@ | |||
| "Sales Qty": "銷售數量", | |||
| "Sales UOM": "銷售單位", | |||
| "Bom Material" : "BOM 材料", | |||
| "Stock Take Section": "盤點區域", | |||
| "Stock Take Section Description": "盤點區域描述", | |||
| "Depth": "顔色深淺度 深1淺5", | |||
| "Search": "搜索", | |||
| @@ -8,6 +8,24 @@ | |||
| "Edit": "編輯", | |||
| "Delete": "刪除", | |||
| "Delete Success": "刪除成功", | |||
| "Actions": "操作", | |||
| "Add": "新增", | |||
| "Store ID": "樓層", | |||
| "Saved": "已儲存", | |||
| "Add Success": "新增成功", | |||
| "Saved Successfully": "儲存成功", | |||
| "Stock Take Section": "盤點區域", | |||
| "Add Warehouse": "新增倉庫", | |||
| "Save": "儲存", | |||
| "Stock Take Section Description": "盤點區域描述", | |||
| "Mapping Details": "對應詳細資料", | |||
| "Warehouses in this section": "此區域內的倉庫", | |||
| "No warehouses": "此區域內沒有倉庫", | |||
| "Remove": "移除", | |||
| "stockTakeSectionDescription": "盤點區域描述", | |||
| "Warehouse List": "倉庫列表", | |||
| "Stock Take Section & Warehouse Mapping": "盤點區域 & 倉庫對應", | |||
| "Warehouse": "倉庫", | |||
| "warehouse": "倉庫", | |||
| "Rows per page": "每頁行數", | |||