diff --git a/src/app/(main)/settings/warehouse/page.tsx b/src/app/(main)/settings/warehouse/page.tsx index e008d12..5a57332 100644 --- a/src/app/(main)/settings/warehouse/page.tsx +++ b/src/app/(main)/settings/warehouse/page.tsx @@ -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 ( <> - + {t("Warehouse")} @@ -35,11 +32,14 @@ const Warehouse: React.FC = async () => { - }> - + + } + tab1Content={} + /> ); }; -export default Warehouse; +export default Warehouse; \ No newline at end of file diff --git a/src/app/api/jo/actions.ts b/src/app/api/jo/actions.ts index 7c575de..db9f8a8 100644 --- a/src/app/api/jo/actions.ts +++ b/src/app/api/jo/actions.ts @@ -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( - `${BASE_API_URL}/product-process/Demo/Process/all`, + `${BASE_API_URL}/product-process/Demo/Process/all${query}`, { method: "GET", next: { tags: ["productProcess"] }, diff --git a/src/app/api/warehouse/actions.ts b/src/app/api/warehouse/actions.ts index 2ec4108..0764346 100644 --- a/src/app/api/warehouse/actions.ts +++ b/src/app/api/warehouse/actions.ts @@ -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; -} \ No newline at end of file +} + +export const fetchStockTakeSections = cache(async () => { + return serverFetchJson(`${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( + `${BASE_API_URL}/warehouse/${warehouseId}/clearSection`, + { method: "POST" } + ); + revalidateTag("warehouse"); + return result; +}; +export const getWarehousesBySection = cache(async (stockTakeSection: string) => { + const list = await serverFetchJson(`${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(`${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; + }); +}); \ No newline at end of file diff --git a/src/app/api/warehouse/index.ts b/src/app/api/warehouse/index.ts index dff7588..705e52e 100644 --- a/src/app/api/warehouse/index.ts +++ b/src/app/api/warehouse/index.ts @@ -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; +} \ No newline at end of file diff --git a/src/components/CreateWarehouse/CreateWarehouse.tsx b/src/components/CreateWarehouse/CreateWarehouse.tsx index 3ed461b..4b6434f 100644 --- a/src/components/CreateWarehouse/CreateWarehouse.tsx +++ b/src/components/CreateWarehouse/CreateWarehouse.tsx @@ -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], diff --git a/src/components/CreateWarehouse/WarehouseDetail.tsx b/src/components/CreateWarehouse/WarehouseDetail.tsx index 76912c5..60cb9ef 100644 --- a/src/components/CreateWarehouse/WarehouseDetail.tsx +++ b/src/components/CreateWarehouse/WarehouseDetail.tsx @@ -153,6 +153,14 @@ const WarehouseDetail: React.FC = () => { helperText={errors.stockTakeSection?.message} /> + + + diff --git a/src/components/ProductionProcess/ProductionProcessList.tsx b/src/components/ProductionProcess/ProductionProcessList.tsx index e58ae5d..1eea577 100644 --- a/src/components/ProductionProcess/ProductionProcessList.tsx +++ b/src/components/ProductionProcess/ProductionProcessList.tsx @@ -52,7 +52,8 @@ const ProductProcessList: React.FC = ({ onSelectProcess const [openModal, setOpenModal] = useState(false); const [modalInfo, setModalInfo] = useState(); const currentUserId = session?.id ? parseInt(session.id) : undefined; - + type ProcessFilter = "all" | "drink" | "other"; + const [filter, setFilter] = useState("all"); const [suggestedLocationCode, setSuggestedLocationCode] = useState(null); const handleAssignPickOrder = useCallback(async (pickOrderId: number, jobOrderId?: number, productProcessId?: number) => { if (!currentUserId) { @@ -108,7 +109,10 @@ const ProductProcessList: React.FC = ({ 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 = ({ onSelectProcess } finally { setLoading(false); } - }, []); + }, [filter]); useEffect(() => { fetchProcesses(); @@ -176,6 +180,29 @@ const ProductProcessList: React.FC = ({ onSelectProcess ) : ( + + + + + {t("Total processes")}: {processes.length} diff --git a/src/components/StockIssue/SearchPage.tsx b/src/components/StockIssue/SearchPage.tsx index 5a0836f..d7a9a18 100644 --- a/src/components/StockIssue/SearchPage.tsx +++ b/src/components/StockIssue/SearchPage.tsx @@ -98,6 +98,23 @@ const SearchPage: React.FC = ({ 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 = ({ dataList }) => { alert(t("Item not found")); } }, - [tab, currentUserId, t, missItems, badItems] + [tab, currentUserId, t, missItems, badItems, expiryItems] ); const handleFormSuccess = useCallback(() => { diff --git a/src/components/StockTakeManagement/PickerCardList.tsx b/src/components/StockTakeManagement/PickerCardList.tsx index 29dd7e2..b5423a1 100644 --- a/src/components/StockTakeManagement/PickerCardList.tsx +++ b/src/components/StockTakeManagement/PickerCardList.tsx @@ -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 = ({ onCardClick, onReStockT const [total, setTotal] = useState(0); const [creating, setCreating] = useState(false); const [openConfirmDialog, setOpenConfirmDialog] = useState(false); + const [filterSectionDescription, setFilterSectionDescription] = useState("All"); +const [filterStockTakeSession, setFilterStockTakeSession] = useState(""); +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[] = [ + { + 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) => { + 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 ( + + + criteria={criteria} + onSearch={handleSearch} + onReset={handleResetSearch} + /> + - + {t("Total Sections")}: {stockTakeSessions.length} @@ -209,7 +269,7 @@ const [total, setTotal] = useState(0); - {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); > - - {t("Section")}: {session.stockTakeSession} - - + + {t("Section")}: {session.stockTakeSession} + {session.stockTakeSectionDescription ? ` (${session.stockTakeSectionDescription})` : null} + + diff --git a/src/components/Warehouse/TabStockTakeSectionMapping.tsx b/src/components/Warehouse/TabStockTakeSectionMapping.tsx new file mode 100644 index 0000000..bada9eb --- /dev/null +++ b/src/components/Warehouse/TabStockTakeSectionMapping.tsx @@ -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([]); + const [filteredSections, setFilteredSections] = useState([]); + const [selectedSection, setSelectedSection] = useState(null); + const [warehousesInSection, setWarehousesInSection] = useState([]); + const [loading, setLoading] = useState(true); + const [openDialog, setOpenDialog] = useState(false); + const [editDesc, setEditDesc] = useState(""); + const [savingDesc, setSavingDesc] = useState(false); + const [warehouseList, setWarehouseList] = useState([]); + 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([]); + const [addSearching, setAddSearching] = useState(false); + const [addingWarehouseId, setAddingWarehouseId] = useState(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[] = 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) => { + 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[]>( + () => [ + { 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: , + buttonIcons: {} as Record, + color: "primary", + sx: { width: "20%" }, + }, + ], + [t, handleViewSection] + ); + + if (loading) { + return ( + + + + ); + } + + return ( + + criteria={criteria} onSearch={handleSearch} onReset={handleReset} /> + items={filteredSections} columns={columns} /> + + setOpenDialog(false)} maxWidth="md" fullWidth sx={{ zIndex: 1000 }}> + + {t("Mapping Details")} - {selectedSection?.stockTakeSection} ({selectedSection?.stockTakeSectionDescription ?? ""}) + + + + + {t("stockTakeSectionDescription")} + + setEditDesc(e.target.value)} sx={{ minWidth: 200 }} /> + + + + + + + + + {t("code")} + + {t("Actions")} + + + + {warehousesInSection.length === 0 ? ( + {t("No warehouses")} + ) : ( + warehousesInSection.map((w) => ( + + {w.code} + + handleRemoveWarehouse(w)}> + + + + + )) + )} + +
+
+
+ + + +
+ setOpenAddDialog(false)} maxWidth="sm" fullWidth sx={{ zIndex: 1000 }}> + {t("Add Warehouse")} + + + setAddStoreId(e.target.value)} + fullWidth + /> + setAddWarehouse(e.target.value)} + fullWidth + /> + setAddArea(e.target.value)} + fullWidth + /> + setAddSlot(e.target.value)} + fullWidth + /> + + + + + + {t("code")} + {t("name")} + {t("Actions")} + + + + {addSearchResults + .filter((w) => !warehousesInSection.some((inc) => inc.id === w.id)) + .map((w) => ( + + {w.code} + {w.name} + + + + + ))} + +
+
+
+
+ + + +
+
+ ); +} \ No newline at end of file diff --git a/src/components/Warehouse/WarehouseHandle.tsx b/src/components/Warehouse/WarehouseHandle.tsx new file mode 100644 index 0000000..59b6ed3 --- /dev/null +++ b/src/components/Warehouse/WarehouseHandle.tsx @@ -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>; +type SearchParamNames = keyof SearchQuery; + +const WarehouseHandle: React.FC = ({ 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(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[]>( + () => [ + { + name: "action", + label: t("Edit"), + onClick: onEditClick, + buttonIcon: , + 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: , + color: "error", + sx: { width: "10%", minWidth: "80px" }, + }, + ], + [t, onDeleteClick], + ); + + return ( + <> + + + {t("Search Criteria")} + + + setSearchInputs((prev) => ({ ...prev, store_id: e.target.value })) + } + size="small" + sx={{ width: "150px", minWidth: "120px" }} + InputProps={{ + endAdornment: ( + F + ), + }} + /> + + - + + + setSearchInputs((prev) => ({ ...prev, warehouse: e.target.value })) + } + size="small" + sx={{ width: "150px", minWidth: "120px" }} + /> + + - + + + setSearchInputs((prev) => ({ ...prev, area: e.target.value })) + } + size="small" + sx={{ width: "150px", minWidth: "120px" }} + /> + + - + + + setSearchInputs((prev) => ({ ...prev, slot: e.target.value })) + } + size="small" + sx={{ width: "150px", minWidth: "120px" }} + /> + + + setSearchInputs((prev) => ({ ...prev, stockTakeSection: e.target.value })) + } + size="small" + fullWidth + /> + + + + setSearchInputs((prev) => ({ ...prev, stockTakeSectionDescription: e.target.value })) + } + size="small" + fullWidth + /> + + + + + + + + + + items={filteredWarehouse} + columns={columns} + pagingController={pagingController} + setPagingController={setPagingController} + /> + + {t("Edit")} + + {editError && ( + + {editError} + + )} + + setEditValues((prev) => ({ ...prev, order: e.target.value })) + } + size="small" + fullWidth + /> + + setEditValues((prev) => ({ ...prev, stockTakeSection: e.target.value })) + } + size="small" + fullWidth + /> + + setEditValues((prev) => ({ ...prev, stockTakeSectionDescription: e.target.value })) + } + size="small" + fullWidth + /> + + + + + + + + ); +}; +export default WarehouseHandle; diff --git a/src/components/Warehouse/WarehouseHandleLoading.tsx b/src/components/Warehouse/WarehouseHandleLoading.tsx new file mode 100644 index 0000000..7111407 --- /dev/null +++ b/src/components/Warehouse/WarehouseHandleLoading.tsx @@ -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 ( + <> + + + + + + + + + + + + + + + + + + + + + + ); +}; + +export default WarehouseHandleLoading; diff --git a/src/components/Warehouse/WarehouseHandleWrapper.tsx b/src/components/Warehouse/WarehouseHandleWrapper.tsx new file mode 100644 index 0000000..e33d47e --- /dev/null +++ b/src/components/Warehouse/WarehouseHandleWrapper.tsx @@ -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 ; +}; + +WarehouseHandleWrapper.Loading = WarehouseHandleLoading; + +export default WarehouseHandleWrapper; diff --git a/src/components/Warehouse/WarehouseTabs.tsx b/src/components/Warehouse/WarehouseTabs.tsx new file mode 100644 index 0000000..193062d --- /dev/null +++ b/src/components/Warehouse/WarehouseTabs.tsx @@ -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 ( + + ); +} + +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 ( + + + + + + + + + {tab0Content} + + + {tab1Content} + + + ); +} \ No newline at end of file diff --git a/src/components/Warehouse/index.ts b/src/components/Warehouse/index.ts new file mode 100644 index 0000000..ac4bf97 --- /dev/null +++ b/src/components/Warehouse/index.ts @@ -0,0 +1 @@ +export { default } from "./WarehouseHandleWrapper"; diff --git a/src/components/WarehouseHandle/WarehouseHandle.tsx b/src/components/WarehouseHandle/WarehouseHandle.tsx index 8e4dc1d..fbac9d9 100644 --- a/src/components/WarehouseHandle/WarehouseHandle.tsx +++ b/src/components/WarehouseHandle/WarehouseHandle.tsx @@ -46,6 +46,7 @@ const WarehouseHandle: React.FC = ({ warehouses }) => { const [editValues, setEditValues] = useState({ order: "", stockTakeSection: "", + }); const [isSavingEdit, setIsSavingEdit] = useState(false); const [editError, setEditError] = useState(""); @@ -56,6 +57,7 @@ const WarehouseHandle: React.FC = ({ warehouses }) => { area: "", slot: "", stockTakeSection: "", + stockTakeSectionDescription: "", }); const onDeleteClick = useCallback((warehouse: WarehouseResult) => { @@ -78,6 +80,7 @@ const WarehouseHandle: React.FC = ({ warehouses }) => { area: "", slot: "", stockTakeSection: "", + stockTakeSectionDescription: "", }); setFilteredWarehouse(warehouses); setPagingController({ pageNum: 1, pageSize: pagingController.pageSize }); @@ -103,7 +106,6 @@ const WarehouseHandle: React.FC = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ warehouses }) => { fullWidth />
+ + + setSearchInputs((prev) => ({ ...prev, stockTakeSectionDescription: e.target.value })) + } + size="small" + fullWidth + /> +