| @@ -16,11 +16,11 @@ type Props = {} & SearchParams; | |||
| const PoEdit: React.FC<Props> = async ({ searchParams }) => { | |||
| const type = "purchaseOrder"; | |||
| const { t } = await getServerI18n(type); | |||
| console.log(searchParams["id"]); | |||
| //console.log(searchParams["id"]); | |||
| const id = isString(searchParams["id"]) | |||
| ? parseInt(searchParams["id"]) | |||
| : undefined; | |||
| console.log(id); | |||
| //console.log(id); | |||
| if (!id) { | |||
| notFound(); | |||
| } | |||
| @@ -172,7 +172,7 @@ async function fetchInventoriesLatestImpl(data: SearchInventory) { | |||
| export const fetchInventories = cache(fetchInventoriesImpl); | |||
| /** | |||
| * FP-MTMS Version Checklist | Functions Ref. No. 18 | v1.0.0 | 2026-07-17 | |||
| * FP-MTMS Version Checklist | Functions Ref. No. 18 | v1.0.3 | 2026-09-07 | |||
| * Inventory search page: latest inventory row per item (no baseUnit/uomId filter). | |||
| */ | |||
| export const fetchInventoriesLatest = cache(fetchInventoriesLatestImpl); | |||
| @@ -138,6 +138,28 @@ export const fetchItemsWithDetails = cache(async (searchParams?: Record<string, | |||
| } | |||
| }); | |||
| /** Paged item lookup for Inventory Search (code / name). */ | |||
| export const fetchItemsByPage = cache(async (queryParams?: Record<string, string | number>) => { | |||
| const searchParams = new URLSearchParams(); | |||
| if (queryParams) { | |||
| Object.entries(queryParams).forEach(([key, value]) => { | |||
| if (value !== undefined && value !== null && `${value}` !== "") { | |||
| searchParams.set(key, String(value)); | |||
| } | |||
| }); | |||
| } | |||
| const queryString = searchParams.toString(); | |||
| return serverFetchJson<RecordsRes<ItemsResult>>( | |||
| queryString | |||
| ? `${BASE_API_URL}/items/getRecordByPage?${queryString}` | |||
| : `${BASE_API_URL}/items/getRecordByPage`, | |||
| { | |||
| method: "GET", | |||
| next: { tags: ["items"] }, | |||
| }, | |||
| ); | |||
| }); | |||
| export const fetchAllItemsInClient = cache(async () => { | |||
| return serverFetchJson<ItemCombo[]>(`${BASE_API_URL}/items/consumables`, { | |||
| next: { tags: ["items"] }, | |||
| @@ -16,6 +16,7 @@ export interface StockAdjustmentLineRequest { | |||
| expiryDate: string; | |||
| warehouseId: number; | |||
| uom?: string | null; | |||
| remarks?: string | null; | |||
| } | |||
| export interface StockAdjustmentRequest { | |||
| @@ -33,6 +34,19 @@ export interface MessageResponse { | |||
| errorPosition: string | null; | |||
| } | |||
| export interface StockAdjustmentRemarksResponse { | |||
| lotNo: string | null; | |||
| remarks: string; | |||
| } | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 3 | v1.0.7 | 2026-09-08 */ | |||
| export const fetchLatestAdjustmentRemarks = async (itemId: number) => { | |||
| return serverFetchJson<StockAdjustmentRemarksResponse[]>( | |||
| `${BASE_API_URL}/stockAdjustment/latestRemarks?itemId=${itemId}`, | |||
| { method: "GET" }, | |||
| ); | |||
| }; | |||
| export const submitStockAdjustment = async (data: StockAdjustmentRequest) => { | |||
| const result = await serverFetchJson<MessageResponse>( | |||
| `${BASE_API_URL}/stockAdjustment/submit`, | |||
| @@ -17,12 +17,17 @@ export interface ExpiryItemResult { | |||
| storeLocation: string | null; | |||
| expiryDate: string | null; | |||
| remainingQty: number; | |||
| uomDesc?: string | null; | |||
| /** True when expiryDate is today or earlier. */ | |||
| canHandle?: boolean; | |||
| } | |||
| export interface ExpiryItemFilter { | |||
| expiryDate?: string; | |||
| itemCode?: string; | |||
| itemName?: string; | |||
| lotNo?: string; | |||
| /** Inclusive lookahead from today; default 7. */ | |||
| daysAhead?: number; | |||
| } | |||
| export interface HandleBadItemRequest { | |||
| @@ -54,6 +59,8 @@ export interface StockIssueHandleRecord { | |||
| export interface SearchStockIssueRecordParams { | |||
| startDate?: string; | |||
| endDate?: string; | |||
| handledStartDate?: string; | |||
| handledEndDate?: string; | |||
| itemCode?: string; | |||
| itemName?: string; | |||
| lotNo?: string; | |||
| @@ -61,11 +68,13 @@ export interface SearchStockIssueRecordParams { | |||
| pageSize?: number; | |||
| } | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 74 | v1.0.2 | 2026-09-07 */ | |||
| export const fetchExpiryItemList = cache(async (filters?: ExpiryItemFilter) => { | |||
| const params = new URLSearchParams(); | |||
| if (filters?.expiryDate) params.set("expiryDate", filters.expiryDate); | |||
| if (filters?.itemCode) params.set("itemCode", filters.itemCode); | |||
| if (filters?.itemName) params.set("itemName", filters.itemName); | |||
| if (filters?.lotNo) params.set("lotNo", filters.lotNo); | |||
| if (filters?.daysAhead != null) params.set("daysAhead", String(filters.daysAhead)); | |||
| const queryString = params.toString(); | |||
| const url = `${BASE_API_URL}/pickExecution/issues/expiryItem${queryString ? `?${queryString}` : ""}`; | |||
| return serverFetchJson<ExpiryItemResult[]>(url, { | |||
| @@ -107,6 +116,8 @@ export async function fetchExpiryItemRecords(params: SearchStockIssueRecordParam | |||
| const qs = new URLSearchParams(); | |||
| if (params.startDate) qs.set("startDate", params.startDate); | |||
| if (params.endDate) qs.set("endDate", params.endDate); | |||
| if (params.handledStartDate) qs.set("handledStartDate", params.handledStartDate); | |||
| if (params.handledEndDate) qs.set("handledEndDate", params.handledEndDate); | |||
| if (params.itemCode) qs.set("itemCode", params.itemCode); | |||
| if (params.itemName) qs.set("itemName", params.itemName); | |||
| if (params.lotNo) qs.set("lotNo", params.lotNo); | |||
| @@ -0,0 +1,67 @@ | |||
| "use client"; | |||
| import { NEXT_PUBLIC_API_URL } from "@/config/api"; | |||
| import { clientAuthFetch } from "@/app/utils/clientAuthFetch"; | |||
| import type { ExpiryItemFilter } from "@/app/api/stockIssue/actions"; | |||
| /** Result tab currently shown; backend may filter the workbook by this bucket. */ | |||
| export type ExpiryExportBucket = "expired" | "today" | "upcoming"; | |||
| export interface ExportExpiryItemExcelParams extends ExpiryItemFilter { | |||
| bucket?: ExpiryExportBucket; | |||
| } | |||
| /** | |||
| * FP-MTMS Version Checklist | Functions Ref. No. 74 | v1.0.2 | 2026-09-07 | |||
| * GET /pickExecution/issues/expiryItem/excel | |||
| * Query: itemCode, itemName, lotNo, daysAhead, bucket (expired|today|upcoming; omit for all categories) | |||
| * Response: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet | |||
| * Optional Content-Disposition filename. | |||
| */ | |||
| export async function exportExpiryItemExcel( | |||
| filters: ExportExpiryItemExcelParams, | |||
| ): Promise<void> { | |||
| const params = new URLSearchParams(); | |||
| if (filters.itemCode) params.set("itemCode", filters.itemCode); | |||
| if (filters.itemName) params.set("itemName", filters.itemName); | |||
| if (filters.lotNo) params.set("lotNo", filters.lotNo); | |||
| if (filters.daysAhead != null) params.set("daysAhead", String(filters.daysAhead)); | |||
| if (filters.bucket) params.set("bucket", filters.bucket); | |||
| const queryString = params.toString(); | |||
| const url = `${NEXT_PUBLIC_API_URL}/pickExecution/issues/expiryItem/excel${queryString ? `?${queryString}` : ""}`; | |||
| const response = await clientAuthFetch(url, { | |||
| method: "GET", | |||
| headers: { | |||
| Accept: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", | |||
| }, | |||
| }); | |||
| if (response.status === 401 || response.status === 403) { | |||
| throw new Error("Unauthorized"); | |||
| } | |||
| if (!response.ok) { | |||
| throw new Error(`HTTP error! status: ${response.status}`); | |||
| } | |||
| const blob = await response.blob(); | |||
| const downloadUrl = window.URL.createObjectURL(blob); | |||
| const link = document.createElement("a"); | |||
| link.href = downloadUrl; | |||
| const contentDisposition = response.headers.get("Content-Disposition"); | |||
| let fileName = "expiry-items.xlsx"; | |||
| if (contentDisposition?.includes("filename=")) { | |||
| fileName = contentDisposition | |||
| .split("filename=")[1] | |||
| .split(";")[0] | |||
| .replace(/"/g, ""); | |||
| } | |||
| link.setAttribute("download", fileName); | |||
| document.body.appendChild(link); | |||
| link.click(); | |||
| link.remove(); | |||
| window.URL.revokeObjectURL(downloadUrl); | |||
| } | |||
| @@ -55,6 +55,7 @@ import { | |||
| import dayjs from "dayjs"; | |||
| import { useSession } from "next-auth/react"; | |||
| import { SessionWithTokens } from "@/config/authConfig"; | |||
| import { AUTH, hasAbility } from "@/authorities"; | |||
| import { NEXT_PUBLIC_API_URL } from "@/config/api"; | |||
| import { clientAuthFetch } from "@/app/utils/clientAuthFetch"; | |||
| @@ -77,28 +78,6 @@ const REFRESH_MS = 60 * 1000; | |||
| const PRINTER_CHECK_MS = 60 * 1000; | |||
| const PRINTER_RETRY_MS = 30 * 1000; | |||
| const SETTINGS_KEY = "bagPrint_settings"; | |||
| const ONPACK_ADMIN_USERNAME = "2fi"; | |||
| /** Login username from backend JWT `sub` (UserDetails.username). */ | |||
| function loginUsernameFromSession(session: SessionWithTokens | null | undefined): string { | |||
| const token = session?.accessToken?.trim(); | |||
| if (token) { | |||
| try { | |||
| const parts = token.split("."); | |||
| if (parts.length >= 2) { | |||
| const b64 = parts[1].replace(/-/g, "+").replace(/_/g, "/"); | |||
| const padded = b64 + "=".repeat((4 - (b64.length % 4)) % 4); | |||
| const payload = JSON.parse(atob(padded)) as { sub?: unknown }; | |||
| if (typeof payload.sub === "string" && payload.sub.trim()) { | |||
| return payload.sub.trim(); | |||
| } | |||
| } | |||
| } catch { | |||
| // fall through to display name | |||
| } | |||
| } | |||
| return (session?.user?.name ?? "").trim(); | |||
| } | |||
| const DEFAULT_SETTINGS = { | |||
| dabag_ip: "", | |||
| @@ -221,8 +200,8 @@ function sortExpiryRows( | |||
| const BagPrintSearch: React.FC = () => { | |||
| const { data: session } = useSession() as { data: SessionWithTokens | null }; | |||
| const canSeeOnPackAdmin = | |||
| loginUsernameFromSession(session).toLowerCase() === ONPACK_ADMIN_USERNAME; | |||
| const abilities = session?.abilities ?? session?.user?.abilities ?? []; | |||
| const canSeeOnPackAdmin = hasAbility(abilities, AUTH.ADMIN); | |||
| const [planDate, setPlanDate] = useState(() => dayjs().format("YYYY-MM-DD")); | |||
| const [jobOrders, setJobOrders] = useState<JobOrderListItem[]>([]); | |||
| const [loading, setLoading] = useState(true); | |||
| @@ -26,6 +26,7 @@ type Props = { | |||
| workbenchRelease?: boolean; | |||
| } | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 78 | v1.0.0 | 2026-09-08 */ | |||
| const DoDetail: React.FC<Props> = ({ | |||
| defaultValues, | |||
| id, | |||
| @@ -57,22 +58,13 @@ const DoDetail: React.FC<Props> = ({ | |||
| setSuccessMessage("") | |||
| if (id) { | |||
| // Get current user ID from session | |||
| //const currentUserId = session?.id ? parseInt(session.id) : undefined; | |||
| //if (!currentUserId) { | |||
| // setServerError("User session not found. Please login again."); | |||
| // return; | |||
| //} | |||
| /* | |||
| const response = await releaseDo({ | |||
| id: id, | |||
| //userId: currentUserId // Pass user ID from session | |||
| }) | |||
| */ | |||
| if (!currentUserId) { | |||
| setServerError(t("User session not found")); | |||
| return; | |||
| } | |||
| const response = await startWorkbenchBatchReleaseAsyncSingleV2({ | |||
| doId: id, | |||
| userId: currentUserId ?? 0 | |||
| userId: currentUserId | |||
| }) | |||
| if (response?.code === "STARTED") { | |||
| setSuccessMessage(t("DO released successfully! Pick orders created.")); | |||
| @@ -91,7 +83,7 @@ const DoDetail: React.FC<Props> = ({ | |||
| } finally { | |||
| setIsUploading(false) | |||
| } | |||
| }, [id, formProps, t, setIsUploading, session]) // Add session to dependencies | |||
| }, [id, formProps, t, setIsUploading, session, currentUserId, router]) | |||
| // UPDATE STORE-BASED ASSIGNMENT HANDLERS | |||
| const handleAssignByStore = useCallback(async (storeId: string) => { | |||
| @@ -81,6 +81,7 @@ function isTruckLaneSearchMissingEta(truckLanceCode: string, estimatedArrivalDat | |||
| return truckLanceCode.trim() !== "" && estimatedArrivalDate.trim() === ""; | |||
| } | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 77 | v1.0.0 | 2026-09-08 */ | |||
| const DoSearch: React.FC<Props> = ({ filterArgs, searchQuery, onDeliveryOrderSearch }) => { | |||
| const apiRef = useGridApiRef(); | |||
| @@ -509,6 +510,16 @@ const DoSearch: React.FC<Props> = ({ filterArgs, searchQuery, onDeliveryOrderSea | |||
| const handleBatchRelease = useCallback(async (isWorkbench: boolean) => { | |||
| try { | |||
| if (!currentUserId) { | |||
| await Swal.fire({ | |||
| icon: "error", | |||
| title: t("Error"), | |||
| text: t("User session not found"), | |||
| confirmButtonText: t("OK"), | |||
| didOpen: (popup) => applyMainContentAreaSwalOffset(popup), | |||
| }); | |||
| return; | |||
| } | |||
| const tabFilter = resolveTabFilter(activeTab); | |||
| const tabTruckKeyword = tabFilter.forceTruckKeyword ?? ""; | |||
| const effectiveTruckLanceCode = tabTruckKeyword || currentSearchParams.truckLanceCode || ""; | |||
| @@ -659,12 +670,12 @@ const DoSearch: React.FC<Props> = ({ filterArgs, searchQuery, onDeliveryOrderSea | |||
| if(isWorkbench){ | |||
| startRes = await startWorkbenchBatchReleaseAsyncV2({ | |||
| ids: idsToRelease, | |||
| userId: currentUserId ?? 1, | |||
| userId: currentUserId, | |||
| mergeExtraIntoLaneTicket, | |||
| }); | |||
| } | |||
| else{ | |||
| startRes = await startBatchReleaseAsync({ ids: idsToRelease, userId: currentUserId ?? 1 }); | |||
| startRes = await startBatchReleaseAsync({ ids: idsToRelease, userId: currentUserId }); | |||
| } | |||
| //await startBatchReleaseAsync({ ids: idsToRelease, userId: currentUserId ?? 1 }); | |||
| const jobId = startRes?.entity?.jobId; | |||
| @@ -61,6 +61,7 @@ function isTruckLaneSearchMissingEta(truckLanceCode: string, estimatedArrivalDat | |||
| return truckLanceCode.trim() !== "" && estimatedArrivalDate.trim() === ""; | |||
| } | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 78 | v1.0.0 | 2026-09-08 */ | |||
| const DoSearchWorkbench: React.FC<Props> = ({ | |||
| filterArgs, | |||
| searchQuery, | |||
| @@ -538,6 +539,15 @@ const handleSearch = useCallback(async (query: SearchBoxInputs) => { | |||
| const handleBatchRelease = useCallback(async () => { | |||
| try { | |||
| if (!currentUserId) { | |||
| await Swal.fire({ | |||
| icon: "error", | |||
| title: t("Error"), | |||
| text: t("User session not found"), | |||
| confirmButtonText: t("OK"), | |||
| }); | |||
| return; | |||
| } | |||
| if ( | |||
| isTruckLaneSearchMissingEta( | |||
| currentSearchParams.truckLanceCode ?? "", | |||
| @@ -657,7 +667,7 @@ const handleSearch = useCallback(async (query: SearchBoxInputs) => { | |||
| (result.value as { mergeExtraIntoLaneTicket?: boolean } | undefined)?.mergeExtraIntoLaneTicket ?? false; | |||
| const startRes = await startWorkbenchBatchReleaseAsyncV2({ | |||
| ids: idsToRelease, | |||
| userId: currentUserId ?? 1, | |||
| userId: currentUserId, | |||
| mergeExtraIntoLaneTicket, | |||
| }); | |||
| const startEntity = startRes?.entity as { jobId?: string } | undefined; | |||
| @@ -32,9 +32,9 @@ import { DatePicker } from "@mui/x-date-pickers/DatePicker"; | |||
| import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; | |||
| import dayjs from "dayjs"; | |||
| import CheckIcon from "@mui/icons-material/Check"; | |||
| import { submitStockAdjustment, StockAdjustmentLineRequest } from "@/app/api/stockAdjustment/actions"; | |||
| import { submitStockAdjustment, StockAdjustmentLineRequest, fetchLatestAdjustmentRemarks } from "@/app/api/stockAdjustment/actions"; | |||
| import { useSession } from "next-auth/react"; | |||
| import { AUTH } from "@/authorities"; | |||
| import { AUTH, hasAbility } from "@/authorities"; | |||
| type AdjustmentEntry = InventoryLotLineResult & { | |||
| adjustedQty: number; | |||
| @@ -59,7 +59,7 @@ interface Props { | |||
| onStockAdjustmentSuccess?: () => void | Promise<void>; | |||
| } | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 3 | v1.0.5 | 2026-08-05 */ | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 3 | v1.0.7 | 2026-09-08 */ | |||
| const InventoryLotLineTable: React.FC<Props> = ({ | |||
| inventoryLotLines, pagingController, setPagingController, totalCount, inventory, | |||
| filterLotNo, | |||
| @@ -68,8 +68,8 @@ const InventoryLotLineTable: React.FC<Props> = ({ | |||
| }) => { | |||
| const { t } = useTranslation(["inventory"]); | |||
| const { data: session } = useSession(); | |||
| const abilities = session?.user?.abilities ?? []; | |||
| const canStockAdjust = abilities.includes(AUTH.INVENTORY_ADJUST); | |||
| const abilities = session?.abilities ?? session?.user?.abilities ?? []; | |||
| const canStockAdjust = hasAbility(abilities, AUTH.INVENTORY_ADJUST); | |||
| const PRINT_PRINTER_ID_KEY = 'inventoryLotLinePrintPrinterId'; | |||
| const { setIsUploading } = useUploadContext(); | |||
| const [stockTransferModalOpen, setStockTransferModalOpen] = useState(false); | |||
| @@ -99,7 +99,11 @@ const InventoryLotLineTable: React.FC<Props> = ({ | |||
| remarks: '', | |||
| }); | |||
| const originalAdjustmentLinesRef = useRef<AdjustmentEntry[]>([]); | |||
| const adjustSaveInFlightRef = useRef(false); | |||
| const loadedRemarksByLotRef = useRef<Map<string, string>>(new Map()); | |||
| const remarksFetchGenRef = useRef(0); | |||
| const [adjustmentEntries, setAdjustmentEntries] = useState<AdjustmentEntry[]>([]); | |||
| const [isAdjustSaving, setIsAdjustSaving] = useState(false); | |||
| useEffect(() => { | |||
| if (stockTransferModalOpen) { | |||
| fetchWarehouseListClient() | |||
| @@ -153,9 +157,34 @@ const prevAdjustmentModalOpenRef = useRef(false); | |||
| })); | |||
| setAdjustmentEntries(initial); | |||
| originalAdjustmentLinesRef.current = initial; | |||
| loadedRemarksByLotRef.current = new Map(); | |||
| const fetchGen = ++remarksFetchGenRef.current; | |||
| const itemId = inventory.itemId; | |||
| fetchLatestAdjustmentRemarks(itemId) | |||
| .then((rows) => { | |||
| if (fetchGen !== remarksFetchGenRef.current) return; | |||
| const byLot = new Map<string, string>(); | |||
| for (const row of rows ?? []) { | |||
| const lot = row.lotNo?.trim(); | |||
| const remarks = row.remarks?.trim(); | |||
| if (!lot || !remarks || byLot.has(lot)) continue; | |||
| byLot.set(lot, remarks); | |||
| } | |||
| loadedRemarksByLotRef.current = byLot; | |||
| const apply = (line: AdjustmentEntry): AdjustmentEntry => { | |||
| const lot = line.lotNo?.trim(); | |||
| const remarks = (lot && byLot.get(lot)) || line.remarks || ''; | |||
| return { ...line, remarks }; | |||
| }; | |||
| setAdjustmentEntries((prev) => prev.map(apply)); | |||
| originalAdjustmentLinesRef.current = originalAdjustmentLinesRef.current.map(apply); | |||
| }) | |||
| .catch(console.error); | |||
| } | |||
| setPendingRemovalLineId(null); | |||
| setRemovalReasons({}); | |||
| } else if (!stockAdjustmentModalOpen) { | |||
| remarksFetchGenRef.current += 1; | |||
| } | |||
| }, [stockAdjustmentModalOpen, inventory, availableLotLines]); | |||
| @@ -164,12 +193,15 @@ const prevAdjustmentModalOpenRef = useRef(false); | |||
| setPendingRemovalLineId(null); | |||
| setRemovalReasons({}); | |||
| setAdjustmentEntries( | |||
| (availableLotLines ?? []).map((line) => ({ | |||
| ...line, | |||
| adjustedQty: line.availableQty ?? 0, | |||
| originalQty: line.availableQty ?? 0, | |||
| remarks: '', | |||
| })) | |||
| (availableLotLines ?? []).map((line) => { | |||
| const lot = line.lotNo?.trim(); | |||
| return { | |||
| ...line, | |||
| adjustedQty: line.availableQty ?? 0, | |||
| originalQty: line.availableQty ?? 0, | |||
| remarks: (lot && loadedRemarksByLotRef.current.get(lot)) || '', | |||
| }; | |||
| }) | |||
| ); | |||
| }, [availableLotLines]); | |||
| @@ -241,15 +273,26 @@ const prevAdjustmentModalOpenRef = useRef(false); | |||
| expiryDate, | |||
| warehouseId: line.warehouse?.id ?? 0, | |||
| uom: line.uom ?? null, | |||
| remarks: line.remarks?.trim() || null, | |||
| }; | |||
| }, []); | |||
| const handleAdjustmentSave = useCallback(async () => { | |||
| if (!inventory) return; | |||
| const itemCode = inventory.itemCode; | |||
| const originalLines = originalAdjustmentLinesRef.current.map((line) => toApiLine(line, itemCode)); | |||
| const currentLines = adjustmentEntries.map((line) => toApiLine(line, itemCode)); | |||
| if (adjustSaveInFlightRef.current) return; | |||
| adjustSaveInFlightRef.current = true; | |||
| setIsAdjustSaving(true); | |||
| try { | |||
| const itemCode = inventory.itemCode; | |||
| const currentIds = new Set(adjustmentEntries.map((line) => line.id)); | |||
| const originalLines = originalAdjustmentLinesRef.current.map((line) => { | |||
| const api = toApiLine(line, itemCode); | |||
| if (!currentIds.has(line.id)) { | |||
| api.remarks = removalReasons[line.id]?.trim() || null; | |||
| } | |||
| return api; | |||
| }); | |||
| const currentLines = adjustmentEntries.map((line) => toApiLine(line, itemCode)); | |||
| setIsUploading(true); | |||
| await submitStockAdjustment({ | |||
| itemId: inventory.itemId, | |||
| @@ -264,8 +307,10 @@ const prevAdjustmentModalOpenRef = useRef(false); | |||
| msgError(message || t("Save failed")); | |||
| } finally { | |||
| setIsUploading(false); | |||
| setIsAdjustSaving(false); | |||
| adjustSaveInFlightRef.current = false; | |||
| } | |||
| }, [adjustmentEntries, inventory, t, toApiLine, onStockAdjustmentSuccess]); | |||
| }, [adjustmentEntries, inventory, t, toApiLine, onStockAdjustmentSuccess, removalReasons]); | |||
| const handleOpenAddEntry = useCallback(() => { | |||
| setAddEntryForm({ | |||
| @@ -857,7 +902,7 @@ const prevAdjustmentModalOpenRef = useRef(false); | |||
| color="primary" | |||
| startIcon={<SaveIcon />} | |||
| onClick={handleAdjustmentSave} | |||
| disabled={!hasAdjustmentChange} | |||
| disabled={!hasAdjustmentChange || isAdjustSaving} | |||
| > | |||
| {t("Save")} | |||
| </Button> | |||
| @@ -1004,7 +1049,9 @@ const prevAdjustmentModalOpenRef = useRef(false); | |||
| }, | |||
| }} | |||
| /> | |||
| ) : null} | |||
| ) : ( | |||
| line.remarks || null | |||
| )} | |||
| </TableCell> | |||
| <TableCell align="center"> | |||
| {pendingRemovalLineId === line.id ? ( | |||
| @@ -2,7 +2,7 @@ | |||
| import { InventoryLotLineResult, InventoryResult } from '@/app/api/inventory'; | |||
| import { useTranslation } from 'react-i18next'; | |||
| import SearchBox, { Criterion } from '../SearchBox'; | |||
| import { useCallback, useEffect, useMemo, useState } from 'react'; | |||
| import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; | |||
| import { uniq, uniqBy } from 'lodash'; | |||
| import InventoryTable from './InventoryTable'; | |||
| import { defaultPagingController } from '../SearchResults/SearchResults'; | |||
| @@ -16,23 +16,10 @@ import { | |||
| fetchInventoryLotLines, | |||
| } from '@/app/api/inventory/actions'; | |||
| import { PrinterCombo } from '@/app/api/settings/printer'; | |||
| import { ItemCombo, fetchItemsWithDetails, ItemWithDetails } from '@/app/api/settings/item/actions'; | |||
| import { | |||
| Button, | |||
| Dialog, | |||
| DialogActions, | |||
| DialogContent, | |||
| DialogTitle, | |||
| TextField, | |||
| Box, | |||
| CircularProgress, | |||
| Table, | |||
| TableBody, | |||
| TableCell, | |||
| TableHead, | |||
| TableRow, | |||
| Radio, | |||
| } from '@mui/material'; | |||
| import { fetchItemsByPage } from '@/app/api/settings/item/actions'; | |||
| import { useSession } from 'next-auth/react'; | |||
| import { AUTH, hasAbility } from '@/authorities'; | |||
| import { Button, Box } from '@mui/material'; | |||
| interface Props { | |||
| inventories: InventoryResult[]; | |||
| @@ -56,42 +43,68 @@ type SearchQuery = Partial< | |||
| >; | |||
| type SearchParamNames = keyof SearchQuery; | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 18 | v1.0.0 | 2026-07-17 */ | |||
| type ItemLookupRow = { | |||
| id: number; | |||
| code: string; | |||
| name: string; | |||
| type?: string; | |||
| uom?: string; | |||
| uomDesc?: string; | |||
| purchaseUnit?: string; | |||
| }; | |||
| const extractItemRecords = (res: unknown): ItemLookupRow[] => { | |||
| if (!res) return []; | |||
| if (Array.isArray(res)) return res as ItemLookupRow[]; | |||
| if (typeof res === 'object' && Array.isArray((res as { records?: unknown }).records)) { | |||
| return (res as { records: ItemLookupRow[] }).records; | |||
| } | |||
| return []; | |||
| }; | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 18 | v1.0.3 | 2026-09-07 */ | |||
| const InventorySearch: React.FC<Props> = ({ inventories, printerCombo }) => { | |||
| const { t } = useTranslation(['inventory', 'common', 'item']); | |||
| const buildSyntheticInventory = useCallback( | |||
| (item: ItemWithDetails): InventoryResult => ({ | |||
| const { data: session } = useSession(); | |||
| const abilities = session?.abilities ?? session?.user?.abilities ?? []; | |||
| const canStockAdjust = hasAbility(abilities, AUTH.INVENTORY_ADJUST); | |||
| const searchInFlightRef = useRef(false); | |||
| const buildSyntheticInventory = useCallback((item: ItemLookupRow): InventoryResult => { | |||
| const uom = item.uomDesc || item.uom || item.purchaseUnit || ''; | |||
| return { | |||
| id: 0, | |||
| itemId: item.id, | |||
| itemId: Number(item.id), | |||
| itemCode: item.code, | |||
| itemName: item.name, | |||
| itemType: 'Material', | |||
| itemType: item.type || 'mat', | |||
| onHandQty: 0, | |||
| onHoldQty: 0, | |||
| unavailableQty: 0, | |||
| availableQty: 0, | |||
| uomCode: item.uom, | |||
| uomUdfudesc: item.uomDesc, | |||
| uomShortDesc: item.uom, | |||
| uomCode: item.uom || uom, | |||
| uomUdfudesc: uom, | |||
| uomShortDesc: item.uom || uom, | |||
| qtyPerSmallestUnit: 1, | |||
| baseUom: item.uom, | |||
| baseUom: uom, | |||
| price: 0, | |||
| currencyName: '', | |||
| status: 'active', | |||
| latestMarketUnitPrice: undefined, | |||
| latestMupUpdatedDate: undefined, | |||
| }), | |||
| [], | |||
| ); | |||
| const getFirstItemRecord = useCallback((res: any): ItemWithDetails | null => { | |||
| if (!res) return null; | |||
| if (Array.isArray(res)) return (res[0] as ItemWithDetails) ?? null; | |||
| if (Array.isArray(res?.records)) return (res.records[0] as ItemWithDetails) ?? null; | |||
| return null; | |||
| }; | |||
| }, []); | |||
| const lookupItemsByCodeOrName = useCallback(async (code?: string, name?: string) => { | |||
| const trimmedCode = code?.trim(); | |||
| const trimmedName = name?.trim(); | |||
| if (!trimmedCode && !trimmedName) return []; | |||
| const params: Record<string, string | number> = { pageSize: 50, pageNum: 1 }; | |||
| if (trimmedCode) params.code = trimmedCode; | |||
| else params.name = trimmedName as string; | |||
| const itemRes = await fetchItemsByPage(params); | |||
| return extractItemRecords(itemRes); | |||
| }, []); | |||
| // Inventory | |||
| const [filteredInventories, setFilteredInventories] = useState<InventoryResult[]>([]); | |||
| @@ -104,6 +117,20 @@ const InventorySearch: React.FC<Props> = ({ inventories, printerCombo }) => { | |||
| const [inventoryLotLinesPagingController, setInventoryLotLinesPagingController] = useState(defaultPagingController) | |||
| const [inventoryLotLinesTotalCount, setInventoryLotLinesTotalCount] = useState(0) | |||
| const applyItemsAsSyntheticInventories = useCallback( | |||
| (items: ItemLookupRow[]) => { | |||
| if (!items.length) return false; | |||
| const synthetics = items.map(buildSyntheticInventory); | |||
| setFilteredInventories(synthetics); | |||
| setInventoriesTotalCount(synthetics.length); | |||
| setSelectedInventory(synthetics[0]); | |||
| setFilteredInventoryLotLines([]); | |||
| setInventoryLotLinesPagingController(() => defaultPagingController); | |||
| return true; | |||
| }, | |||
| [buildSyntheticInventory], | |||
| ); | |||
| // Scan-mode UI (hardware QR scanner via QrCodeScannerProvider) | |||
| const qrScanner = useQrCodeScannerContext(); | |||
| const [scanUiMode, setScanUiMode] = useState<'idle' | 'scanning'>('idle'); | |||
| @@ -113,13 +140,6 @@ const InventorySearch: React.FC<Props> = ({ inventories, printerCombo }) => { | |||
| const [lotNoFilter, setLotNoFilter] = useState(''); | |||
| const [scannedItemId, setScannedItemId] = useState<number | null>(null); | |||
| // Opening inventory (pure opening stock for items without existing inventory) | |||
| const [openingItems, setOpeningItems] = useState<ItemCombo[]>([]); | |||
| const [openingModalOpen, setOpeningModalOpen] = useState(false); | |||
| const [openingSelectedItem, setOpeningSelectedItem] = useState<ItemCombo | null>(null); | |||
| const [openingLoading, setOpeningLoading] = useState(false); | |||
| const [openingSearchText, setOpeningSearchText] = useState(''); | |||
| const defaultInputs = useMemo( | |||
| () => ({ | |||
| itemId: '', | |||
| @@ -297,46 +317,55 @@ const InventorySearch: React.FC<Props> = ({ inventories, printerCombo }) => { | |||
| // On Search | |||
| const onSearch = useCallback( | |||
| async (query: Record<SearchParamNames, string>) => { | |||
| setLotNoFilter(''); | |||
| setScannedItemId(null); | |||
| setScanUiMode('idle'); | |||
| setScanHoverCancel(false); | |||
| qrScanner.stopScan(); | |||
| qrScanner.resetScan(); | |||
| const invRes = await refetchInventoryData(query, 'search', defaultPagingController, ''); | |||
| await refetchInventoryLotLineData(null, 'search', defaultPagingController); | |||
| setInputs(() => query); | |||
| setInventoriesPagingController(() => defaultPagingController); | |||
| setInventoryLotLinesPagingController(() => defaultPagingController); | |||
| if (searchInFlightRef.current) return; | |||
| searchInFlightRef.current = true; | |||
| try { | |||
| setLotNoFilter(''); | |||
| setScannedItemId(null); | |||
| setScanUiMode('idle'); | |||
| setScanHoverCancel(false); | |||
| qrScanner.stopScan(); | |||
| qrScanner.resetScan(); | |||
| const invRes = await refetchInventoryData(query, 'search', defaultPagingController, ''); | |||
| await refetchInventoryLotLineData(null, 'search', defaultPagingController); | |||
| setInputs(() => query); | |||
| setInventoriesPagingController(() => defaultPagingController); | |||
| setInventoryLotLinesPagingController(() => defaultPagingController); | |||
| // If there are no inventory rows, render a synthetic inventory so the "Stock Adjustment" chip can be used. | |||
| if (invRes?.records?.length === 0) { | |||
| try { | |||
| const code = query.itemCode?.trim?.(); | |||
| const name = query.itemName?.trim?.(); | |||
| const lookupParams = code ? { code } : name ? { name } : null; | |||
| if (lookupParams) { | |||
| const itemRes = await fetchItemsWithDetails(lookupParams); | |||
| const firstItem = getFirstItemRecord(itemRes); | |||
| if (firstItem) { | |||
| setSelectedInventory(buildSyntheticInventory(firstItem)); | |||
| setFilteredInventoryLotLines([]); | |||
| setInventoryLotLinesPagingController(() => defaultPagingController); | |||
| // No inventory rows: look up item master so 0-qty rows can be selected for stock adjustment. | |||
| if (canStockAdjust && invRes?.records?.length === 0) { | |||
| try { | |||
| const items = await lookupItemsByCodeOrName(query.itemCode, query.itemName); | |||
| const typeFilter = query.itemType?.trim(); | |||
| let filtered = | |||
| typeFilter && typeFilter.toLowerCase() !== 'all' | |||
| ? items.filter((it) => (it.type ?? '').toLowerCase() === typeFilter.toLowerCase()) | |||
| : items; | |||
| const exactCode = query.itemCode?.trim().toLowerCase(); | |||
| if (exactCode) { | |||
| filtered = [...filtered].sort((a, b) => { | |||
| const aExact = a.code?.toLowerCase() === exactCode ? 0 : 1; | |||
| const bExact = b.code?.toLowerCase() === exactCode ? 0 : 1; | |||
| return aExact - bExact; | |||
| }); | |||
| } | |||
| applyItemsAsSyntheticInventories(filtered); | |||
| } catch (e) { | |||
| console.error('Failed to build synthetic inventory:', e); | |||
| } | |||
| } catch (e) { | |||
| console.error('Failed to build synthetic inventory:', e); | |||
| } | |||
| } finally { | |||
| searchInFlightRef.current = false; | |||
| } | |||
| }, | |||
| [ | |||
| qrScanner, | |||
| refetchInventoryData, | |||
| refetchInventoryLotLineData, | |||
| buildSyntheticInventory, | |||
| getFirstItemRecord, | |||
| lookupItemsByCodeOrName, | |||
| applyItemsAsSyntheticInventories, | |||
| canStockAdjust, | |||
| ], | |||
| ); | |||
| @@ -382,13 +411,11 @@ const InventorySearch: React.FC<Props> = ({ inventories, printerCombo }) => { | |||
| onInventoryRowClick(target); | |||
| } else { | |||
| refetchInventoryLotLineData(null, 'search', defaultPagingController); | |||
| // No inventory rows for this scanned item => show synthetic inventory with the existing chip workflow. | |||
| const itemRes = await fetchItemsWithDetails({ code: res?.itemCode }); | |||
| const firstItem = getFirstItemRecord(itemRes); | |||
| if (firstItem) { | |||
| setSelectedInventory(buildSyntheticInventory(firstItem)); | |||
| setFilteredInventoryLotLines([]); | |||
| setInventoryLotLinesPagingController(() => defaultPagingController); | |||
| if (canStockAdjust) { | |||
| const items = await lookupItemsByCodeOrName(res?.itemCode); | |||
| if (!applyItemsAsSyntheticInventories(items)) { | |||
| setSelectedInventory(null); | |||
| } | |||
| } else { | |||
| setSelectedInventory(null); | |||
| } | |||
| @@ -410,112 +437,12 @@ const InventorySearch: React.FC<Props> = ({ inventories, printerCombo }) => { | |||
| qrScanner.result, | |||
| refetchInventoryData, | |||
| refetchInventoryLotLineData, | |||
| buildSyntheticInventory, | |||
| getFirstItemRecord, | |||
| lookupItemsByCodeOrName, | |||
| applyItemsAsSyntheticInventories, | |||
| canStockAdjust, | |||
| scanUiMode, | |||
| ]); | |||
| //console.log('', 'color: #666', inventoriesPagingController); | |||
| const handleOpenOpeningInventoryModal = useCallback(() => { | |||
| setOpeningSelectedItem(null); | |||
| setOpeningItems([]); | |||
| setOpeningSearchText(''); | |||
| setOpeningModalOpen(true); | |||
| }, []); | |||
| const handleOpeningSearch = useCallback(async () => { | |||
| const trimmed = openingSearchText.trim(); | |||
| if (!trimmed) { | |||
| setOpeningItems([]); | |||
| return; | |||
| } | |||
| setOpeningLoading(true); | |||
| try { | |||
| const searchParams: Record<string, any> = { | |||
| pageSize: 50, | |||
| pageNum: 1, | |||
| }; | |||
| // Heuristic: if input contains space, treat as name; otherwise treat as code. | |||
| if (trimmed.includes(' ')) { | |||
| searchParams.name = trimmed; | |||
| } else { | |||
| searchParams.code = trimmed; | |||
| } | |||
| const response = await fetchItemsWithDetails(searchParams); | |||
| let records: any[] = []; | |||
| if (response && typeof response === 'object') { | |||
| const anyRes = response as any; | |||
| if (Array.isArray(anyRes.records)) { | |||
| records = anyRes.records; | |||
| } else if (Array.isArray(anyRes)) { | |||
| records = anyRes; | |||
| } | |||
| } | |||
| const combos: ItemCombo[] = records.map((item: any) => ({ | |||
| id: item.id, | |||
| label: `${item.code} - ${item.name}`, | |||
| uomId: item.uomId, | |||
| uom: item.uom, | |||
| uomDesc: item.uomDesc, | |||
| group: item.group, | |||
| currentStockBalance: item.currentStockBalance, | |||
| })); | |||
| setOpeningItems(combos); | |||
| } catch (e) { | |||
| console.error('Failed to search items for opening inventory:', e); | |||
| setOpeningItems([]); | |||
| } finally { | |||
| setOpeningLoading(false); | |||
| } | |||
| }, [openingSearchText]); | |||
| const handleConfirmOpeningInventory = useCallback(() => { | |||
| if (!openingSelectedItem) { | |||
| setOpeningModalOpen(false); | |||
| return; | |||
| } | |||
| const rawLabel = openingSelectedItem.label ?? ''; | |||
| const [codePart, ...nameParts] = rawLabel.split(' - '); | |||
| const itemCode = codePart?.trim() || rawLabel; | |||
| const itemName = nameParts.join(' - ').trim() || itemCode; | |||
| const syntheticInventory: InventoryResult = { | |||
| id: 0, | |||
| itemId: Number(openingSelectedItem.id), | |||
| itemCode, | |||
| itemName, | |||
| itemType: 'Material', | |||
| onHandQty: 0, | |||
| onHoldQty: 0, | |||
| unavailableQty: 0, | |||
| availableQty: 0, | |||
| uomCode: openingSelectedItem.uom, | |||
| uomUdfudesc: openingSelectedItem.uomDesc, | |||
| uomShortDesc: openingSelectedItem.uom, | |||
| qtyPerSmallestUnit: 1, | |||
| baseUom: openingSelectedItem.uom, | |||
| price: 0, | |||
| currencyName: '', | |||
| status: 'active', | |||
| latestMarketUnitPrice: undefined, | |||
| latestMupUpdatedDate: undefined, | |||
| }; | |||
| // Use this synthetic inventory to drive the stock adjustment UI | |||
| setSelectedInventory(syntheticInventory); | |||
| setFilteredInventoryLotLines([]); | |||
| setInventoryLotLinesPagingController(() => defaultPagingController); | |||
| setOpeningModalOpen(false); | |||
| }, [openingSelectedItem]); | |||
| return ( | |||
| <> | |||
| <SearchBox | |||
| @@ -540,15 +467,6 @@ const InventorySearch: React.FC<Props> = ({ inventories, printerCombo }) => { | |||
| </Button> | |||
| </> | |||
| )} | |||
| <Button | |||
| variant="outlined" | |||
| color="secondary" | |||
| onClick={handleOpenOpeningInventoryModal} | |||
| sx={{ display: 'none' }} | |||
| > | |||
| {t('Add entry for items without inventory')} | |||
| </Button> | |||
| </Box> | |||
| } | |||
| /> | |||
| @@ -600,98 +518,6 @@ const InventorySearch: React.FC<Props> = ({ inventories, printerCombo }) => { | |||
| } | |||
| }} | |||
| /> | |||
| <Dialog | |||
| open={openingModalOpen} | |||
| onClose={() => setOpeningModalOpen(false)} | |||
| fullWidth | |||
| maxWidth="md" | |||
| > | |||
| <DialogTitle>{t('Add entry for items without inventory')}</DialogTitle> | |||
| <DialogContent sx={{ pt: 2 }}> | |||
| <Box sx={{ display: 'flex', gap: 1, mb: 2 }}> | |||
| <TextField | |||
| label={t('Item')} | |||
| fullWidth | |||
| value={openingSearchText} | |||
| onChange={(e) => setOpeningSearchText(e.target.value)} | |||
| onKeyDown={(e) => { | |||
| if (e.key === 'Enter') { | |||
| e.preventDefault(); | |||
| handleOpeningSearch(); | |||
| } | |||
| }} | |||
| sx={{ flex: 2 }} | |||
| /> | |||
| <Button | |||
| variant="contained" | |||
| onClick={handleOpeningSearch} | |||
| disabled={openingLoading} | |||
| sx={{ flex: 1 }} | |||
| > | |||
| {openingLoading ? <CircularProgress size={20} /> : t('common:Search')} | |||
| </Button> | |||
| </Box> | |||
| {openingItems.length === 0 && !openingLoading ? ( | |||
| <Box sx={{ py: 1, color: 'text.secondary', fontSize: 14 }}> | |||
| {openingSearchText | |||
| ? t('No data') | |||
| : t('Enter item code or name to search')} | |||
| </Box> | |||
| ) : ( | |||
| <Table size="small"> | |||
| <TableHead> | |||
| <TableRow> | |||
| <TableCell /> | |||
| <TableCell>{t('Code')}</TableCell> | |||
| <TableCell>{t('Name')}</TableCell> | |||
| <TableCell>{t('UoM')}</TableCell> | |||
| <TableCell align="right">{t('Current Stock')}</TableCell> | |||
| </TableRow> | |||
| </TableHead> | |||
| <TableBody> | |||
| {openingItems.map((it) => { | |||
| const [code, ...nameParts] = (it.label ?? '').split(' - '); | |||
| const name = nameParts.join(' - '); | |||
| const selected = openingSelectedItem?.id === it.id; | |||
| return ( | |||
| <TableRow | |||
| key={it.id} | |||
| hover | |||
| selected={selected} | |||
| onClick={() => setOpeningSelectedItem(it)} | |||
| sx={{ cursor: 'pointer' }} | |||
| > | |||
| <TableCell padding="checkbox"> | |||
| <Radio checked={selected} /> | |||
| </TableCell> | |||
| <TableCell>{code}</TableCell> | |||
| <TableCell>{name}</TableCell> | |||
| <TableCell>{it.uomDesc || it.uom}</TableCell> | |||
| <TableCell align="right"> | |||
| {it.currentStockBalance != null ? it.currentStockBalance : '-'} | |||
| </TableCell> | |||
| </TableRow> | |||
| ); | |||
| })} | |||
| </TableBody> | |||
| </Table> | |||
| )} | |||
| </DialogContent> | |||
| <DialogActions> | |||
| <Button onClick={() => setOpeningModalOpen(false)}> | |||
| {t('common:Cancel')} | |||
| </Button> | |||
| <Button | |||
| variant="contained" | |||
| onClick={handleConfirmOpeningInventory} | |||
| disabled={!openingSelectedItem} | |||
| > | |||
| {t('common:Confirm')} | |||
| </Button> | |||
| </DialogActions> | |||
| </Dialog> | |||
| </> | |||
| ); | |||
| }; | |||
| @@ -253,7 +253,7 @@ interface PolInputResult { | |||
| dnQty: string, | |||
| } | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.1 | 2026-08-12 */ | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.2 | 2026-09-07 */ | |||
| const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => { | |||
| const cameras = useContext(CameraContext); | |||
| const { data: session } = useSession(); | |||
| @@ -678,11 +678,29 @@ const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => { | |||
| }, 200); | |||
| }; | |||
| const exceedOrderBy10Percent = orderQty > 0 && acceptedQty > orderQty * 1.1; | |||
| if (exceedOrderBy10Percent) { | |||
| const sils = row.stockInLine ?? []; | |||
| const alreadyM18 = sils.reduce( | |||
| (acc, sil) => acc + Number(sil.purchaseAcceptedQty ?? 0), | |||
| 0, | |||
| ); | |||
| const alreadyStock = sils.reduce( | |||
| (acc, sil) => acc + Number(sil.acceptedQty ?? 0), | |||
| 0, | |||
| ); | |||
| const stockDemand = Number(row.stockUom?.stockQty ?? 0); | |||
| const thisBatchStock = | |||
| orderQty > 0 && stockDemand > 0 | |||
| ? acceptedQty * (stockDemand / orderQty) | |||
| : acceptedQty; | |||
| const exceedByOrderUnit = | |||
| orderQty > 0 && alreadyM18 + acceptedQty > orderQty * 1.1; | |||
| const exceedByStockUnit = | |||
| stockDemand > 0 && | |||
| alreadyStock + thisBatchStock > stockDemand * 1.1; | |||
| if (exceedByOrderUnit || exceedByStockUnit) { | |||
| submitDialogWithWarning(doSubmit, t, { | |||
| title: t("Confirm submit"), | |||
| html: t("This batch quantity exceeds order quantity. Do you still want to submit?"), | |||
| html: t("qtyExceedsOrderConfirm"), | |||
| confirmButtonText: t("Submit"), | |||
| }); | |||
| } else { | |||
| @@ -834,6 +852,11 @@ const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => { | |||
| <TableCell align="center"> | |||
| <Button | |||
| variant="contained" | |||
| onMouseDown={(e) => { | |||
| // Keep input focused so onBlur does not remount this row and swallow the click. | |||
| e.preventDefault(); | |||
| e.stopPropagation(); | |||
| }} | |||
| onClick={(e) => { | |||
| e.stopPropagation(); | |||
| handleStart(); | |||
| @@ -998,7 +1021,7 @@ const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => { | |||
| <> | |||
| <Stack spacing={2}> | |||
| {/* Area1: title */} | |||
| <Grid container xs={12} justifyContent="start"> | |||
| <Grid container justifyContent="start"> | |||
| <Grid item> | |||
| <Typography mb={2} variant="h4"> | |||
| {purchaseOrder.code} -{" "} | |||
| @@ -1153,7 +1176,7 @@ const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => { | |||
| {/* Area4: Main Table */} | |||
| <Grid container xs={12} justifyContent="start"> | |||
| <Grid container justifyContent="start"> | |||
| <Grid item xs={12}> | |||
| <TableContainer component={Paper} sx={{ width: 'fit-content', overflow: 'auto' }}> | |||
| <Table aria-label="collapsible table" stickyHeader> | |||
| @@ -1187,7 +1210,7 @@ const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => { | |||
| </Grid> | |||
| {/* area5: selected item info */} | |||
| <Grid container xs={12} justifyContent="start"> | |||
| <Grid container justifyContent="start"> | |||
| <Grid item xs={12}> | |||
| <Typography variant="h6"> | |||
| {selectedRow ? `已選擇貨品: ${selectedRow?.itemNo ? selectedRow.itemNo : 'N/A'} - ${selectedRow?.itemName ? selectedRow?.itemName : 'N/A'}` : "未選擇貨品"} | |||
| @@ -170,7 +170,7 @@ class ProcessRowUpdateError extends Error { | |||
| } | |||
| } | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.1 | 2026-08-12 */ | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.2 | 2026-09-07 */ | |||
| function PoInputGrid({ | |||
| // qc, | |||
| setRows, | |||
| @@ -71,7 +71,7 @@ interface CommonProps extends Omit<ModalProps, "children"> { | |||
| interface Props extends CommonProps { | |||
| // itemDetail: StockInLine & { qcResult?: PurchaseQcResult[] } & { escResult?: EscalationResult[] }; | |||
| } | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.1 | 2026-08-12 */ | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.2 | 2026-09-07 */ | |||
| const PoQcStockInModalVer2: React.FC<Props> = ({ | |||
| open, | |||
| onClose, | |||
| @@ -29,6 +29,7 @@ type SearchParamNames = keyof SearchQuery; | |||
| // cal offset (pageSize) | |||
| // cal limit (pageSize) | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 76 | v1.0.0 | 2026-09-07 */ | |||
| const PoSearch: React.FC<Props> = ({ | |||
| po, | |||
| totalCount: initTotalCount, | |||
| @@ -150,7 +151,7 @@ const PoSearch: React.FC<Props> = ({ | |||
| return <Grid>"N/A"</Grid> | |||
| } | |||
| const items = value.split(",") | |||
| return items.map((item) => <Grid key={item}>{item}</Grid>) | |||
| return items.map((item, index) => <Grid key={`${index}-${item}`}>{item}</Grid>) | |||
| }, []) | |||
| const columns = useMemo<Column<PoResult>[]>( | |||
| @@ -272,8 +273,8 @@ const PoSearch: React.FC<Props> = ({ | |||
| pagingController: Record<string, number>, | |||
| filterArgs: Record<string, number>, | |||
| ) => { | |||
| console.log(pagingController); | |||
| console.log(filterArgs); | |||
| // console.log(pagingController); | |||
| //console.log(filterArgs); | |||
| const params = { | |||
| ...pagingController, | |||
| ...filterArgs, | |||
| @@ -379,7 +380,7 @@ const PoSearch: React.FC<Props> = ({ | |||
| ); | |||
| useEffect(() => { | |||
| console.log(filteredPo) | |||
| //console.log(filteredPo) | |||
| }, [filteredPo]) | |||
| useEffect(() => { | |||
| @@ -404,7 +405,7 @@ const PoSearch: React.FC<Props> = ({ | |||
| disabled={isM18LookupLoading} | |||
| onSearch={(query) => { | |||
| if (isM18LookupLoading) return; | |||
| console.log(query); | |||
| //console.log(query); | |||
| const code = typeof query.code === "string" ? query.code.trim() : ""; | |||
| if (code) { | |||
| // When PO code is provided, ignore other search criteria (especially date ranges). | |||
| @@ -72,7 +72,7 @@ interface Props extends CommonProps { | |||
| // itemDetail: StockInLine & { qcResult?: PurchaseQcResult[] } & { escResult?: EscalationResult[] }; | |||
| } | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.1 | 2026-08-12 */ | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.2 | 2026-09-07 */ | |||
| const QcStockInModal: React.FC<Props> = ({ | |||
| open, | |||
| onClose, | |||
| @@ -11,42 +11,168 @@ import SearchResults, { Column } from "@/components/SearchResults/index"; | |||
| import { SessionWithTokens } from "@/config/authConfig"; | |||
| import { | |||
| batchSubmitExpiryItem, | |||
| ExpiryItemFilter, | |||
| ExpiryItemResult, | |||
| fetchExpiryItemList, | |||
| submitExpiryItem, | |||
| } from "@/app/api/stockIssue/actions"; | |||
| import { Box, Button } from "@mui/material"; | |||
| import { exportExpiryItemExcel } from "@/app/api/stockIssue/client"; | |||
| import { | |||
| Box, | |||
| Button, | |||
| Dialog, | |||
| DialogActions, | |||
| DialogContent, | |||
| DialogTitle, | |||
| FormControl, | |||
| InputLabel, | |||
| MenuItem, | |||
| Select, | |||
| SelectChangeEvent, | |||
| Tab, | |||
| Tabs, | |||
| Tooltip, | |||
| Typography, | |||
| } from "@mui/material"; | |||
| import FileDownload from "@mui/icons-material/FileDownload"; | |||
| import { useSession } from "next-auth/react"; | |||
| type SearchQuery = { | |||
| itemCode: string; | |||
| itemName: string; | |||
| expiryDate: string; | |||
| lotNo: string; | |||
| }; | |||
| type SearchParamNames = keyof SearchQuery; | |||
| type ResultBucket = "expired" | "today" | "upcoming"; | |||
| const DEFAULT_DAYS_AHEAD = 7; | |||
| const MIN_DAYS_AHEAD = 1; | |||
| const MAX_DAYS_AHEAD = 14; | |||
| const DAYS_AHEAD_OPTIONS = Array.from( | |||
| { length: MAX_DAYS_AHEAD - MIN_DAYS_AHEAD + 1 }, | |||
| (_, i) => MIN_DAYS_AHEAD + i, | |||
| ); | |||
| function parseDaysAhead(raw: string | number | undefined): number { | |||
| const n = typeof raw === "number" ? raw : Number.parseInt(String(raw ?? "").trim(), 10); | |||
| if (!Number.isFinite(n) || n < MIN_DAYS_AHEAD) return DEFAULT_DAYS_AHEAD; | |||
| return Math.min(Math.floor(n), MAX_DAYS_AHEAD); | |||
| } | |||
| function parseExpiryDayjs(rawValue: unknown): dayjs.Dayjs | null { | |||
| const raw = String(rawValue ?? "").trim(); | |||
| if (!raw) return null; | |||
| let d: dayjs.Dayjs; | |||
| if (raw.includes(",")) { | |||
| const parts = raw.split(",").map((s) => parseInt(s.trim(), 10)); | |||
| const [y, m, d_] = parts; | |||
| if ( | |||
| parts.length >= 3 && | |||
| y != null && | |||
| m != null && | |||
| d_ != null && | |||
| !Number.isNaN(y) && | |||
| !Number.isNaN(m) && | |||
| !Number.isNaN(d_) | |||
| ) { | |||
| d = dayjs(new Date(y, m - 1, d_)); | |||
| } else { | |||
| d = dayjs(""); | |||
| } | |||
| } else if (/^\d{4}-\d{2}-\d{2}/.test(raw)) { | |||
| d = dayjs(raw.slice(0, 10)); | |||
| } else { | |||
| let normalized = raw; | |||
| if (raw.length === 7) { | |||
| normalized = raw.slice(0, 4) + "0" + raw.slice(4, 5) + raw.slice(5, 7); | |||
| } else if (raw.length === 6) { | |||
| normalized = raw.slice(0, 4) + "0" + raw.slice(4, 5) + "0" + raw.slice(5, 6); | |||
| } | |||
| d = dayjs(normalized, "YYYYMMDD", true); | |||
| } | |||
| return d.isValid() ? d : null; | |||
| } | |||
| function getExpiryBucket( | |||
| item: ExpiryItemResult, | |||
| daysAhead: number, | |||
| ): ResultBucket | null { | |||
| const d = parseExpiryDayjs(item.expiryDate); | |||
| if (!d) return null; | |||
| const today = dayjs().startOf("day"); | |||
| if (d.isBefore(today, "day")) return "expired"; | |||
| if (d.isSame(today, "day")) return "today"; | |||
| if (daysAhead > 0 && !d.isAfter(today.add(daysAhead, "day"), "day")) { | |||
| return "upcoming"; | |||
| } | |||
| return null; | |||
| } | |||
| function canHandleExpiryItem(item: ExpiryItemResult): boolean { | |||
| if (typeof item.canHandle === "boolean") return item.canHandle; | |||
| const d = parseExpiryDayjs(item.expiryDate); | |||
| return d != null && !d.isAfter(dayjs(), "day"); | |||
| } | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 74 | v1.0.3 | 2026-09-08 */ | |||
| const ExpiryHandleTab: React.FC = () => { | |||
| const BATCH_CHUNK_SIZE = 20; | |||
| const { t } = useTranslation("stockIssue"); | |||
| const { t: tCommon } = useTranslation("common"); | |||
| const { data: session } = useSession() as { data: SessionWithTokens | null }; | |||
| const currentUserId = session?.id ? parseInt(session.id) : undefined; | |||
| const [expiryItems, setExpiryItems] = useState<ExpiryItemResult[]>([]); | |||
| const [lastFilters, setLastFilters] = useState<ExpiryItemFilter>({ | |||
| daysAhead: DEFAULT_DAYS_AHEAD, | |||
| }); | |||
| const [hasSearched, setHasSearched] = useState(false); | |||
| const [resultTab, setResultTab] = useState<ResultBucket>("expired"); | |||
| const [submittingIds, setSubmittingIds] = useState<Set<number>>(new Set()); | |||
| const [batchSubmitting, setBatchSubmitting] = useState(false); | |||
| const [batchConfirmOpen, setBatchConfirmOpen] = useState(false); | |||
| const [batchProgress, setBatchProgress] = useState<{ | |||
| done: number; | |||
| total: number; | |||
| } | null>(null); | |||
| const expirySubmitInFlightRef = useRef<Set<number>>(new Set()); | |||
| const batchSubmitInFlightRef = useRef(false); | |||
| const exportInFlightRef = useRef(false); | |||
| const searchInFlightRef = useRef(false); | |||
| const [exporting, setExporting] = useState<"filtered" | "all" | null>(null); | |||
| const [paging, setPaging] = useState({ pageNum: 1, pageSize: 10 }); | |||
| const [daysAheadDraft, setDaysAheadDraft] = useState(String(DEFAULT_DAYS_AHEAD)); | |||
| const daysAhead = lastFilters.daysAhead ?? DEFAULT_DAYS_AHEAD; | |||
| const itemsByBucket = useMemo(() => { | |||
| const expired: ExpiryItemResult[] = []; | |||
| const today: ExpiryItemResult[] = []; | |||
| const upcoming: ExpiryItemResult[] = []; | |||
| for (const item of expiryItems) { | |||
| const bucket = getExpiryBucket(item, daysAhead); | |||
| if (bucket === "expired") expired.push(item); | |||
| else if (bucket === "today") today.push(item); | |||
| else if (bucket === "upcoming") upcoming.push(item); | |||
| } | |||
| return { | |||
| expired, | |||
| today, | |||
| upcoming, | |||
| }; | |||
| }, [expiryItems, daysAhead]); | |||
| const tabItems = itemsByBucket[resultTab]; | |||
| const handleableIds = useMemo( | |||
| () => tabItems.filter(canHandleExpiryItem).map((item) => item.id), | |||
| [tabItems], | |||
| ); | |||
| const searchFields: StockIssueSearchField<SearchParamNames>[] = useMemo( | |||
| () => [ | |||
| { name: "itemCode", label: t("Item Code"), type: "text" }, | |||
| { name: "itemName", label: t("Item"), type: "text" }, | |||
| { name: "expiryDate", label: t("Expiry Date"), type: "date" }, | |||
| { name: "lotNo", label: t("Lot No."), type: "text" }, | |||
| ], | |||
| [t], | |||
| ); | |||
| @@ -62,6 +188,10 @@ const ExpiryHandleTab: React.FC = () => { | |||
| alert(t("Item not found")); | |||
| return; | |||
| } | |||
| if (!canHandleExpiryItem(item)) { | |||
| alert(t("Not yet due; cannot dispose until the expiry date")); | |||
| return; | |||
| } | |||
| if (expirySubmitInFlightRef.current.has(id)) return; | |||
| try { | |||
| @@ -88,7 +218,7 @@ const ExpiryHandleTab: React.FC = () => { | |||
| const handleSubmitAll = useCallback(async () => { | |||
| if (!currentUserId) return; | |||
| if (batchSubmitInFlightRef.current) return; | |||
| const allIds = expiryItems.map((item) => item.id); | |||
| const allIds = tabItems.filter(canHandleExpiryItem).map((item) => item.id); | |||
| if (allIds.length === 0) return; | |||
| batchSubmitInFlightRef.current = true; | |||
| @@ -114,7 +244,7 @@ const ExpiryHandleTab: React.FC = () => { | |||
| setBatchProgress(null); | |||
| batchSubmitInFlightRef.current = false; | |||
| } | |||
| }, [currentUserId, expiryItems, t]); | |||
| }, [currentUserId, tabItems, t]); | |||
| const expiryColumns = useMemo<Column<ExpiryItemResult>[]>( | |||
| () => [ | |||
| @@ -126,52 +256,40 @@ const ExpiryHandleTab: React.FC = () => { | |||
| name: "expiryDate", | |||
| label: t("Expiry Date"), | |||
| renderCell: (item) => { | |||
| const raw = String(item.expiryDate ?? "").trim(); | |||
| if (!raw) return "—"; | |||
| let d; | |||
| if (raw.includes(",")) { | |||
| const parts = raw.split(",").map((s) => parseInt(s.trim(), 10)); | |||
| const [y, m, d_] = parts; | |||
| if ( | |||
| parts.length >= 3 && | |||
| y != null && | |||
| m != null && | |||
| d_ != null && | |||
| !Number.isNaN(y) && | |||
| !Number.isNaN(m) && | |||
| !Number.isNaN(d_) | |||
| ) { | |||
| d = dayjs(new Date(y, m - 1, d_)); | |||
| } else { | |||
| d = dayjs(""); | |||
| } | |||
| } else { | |||
| let normalized = raw; | |||
| if (raw.length === 7) { | |||
| normalized = raw.slice(0, 4) + "0" + raw.slice(4, 5) + raw.slice(5, 7); | |||
| } else if (raw.length === 6) { | |||
| normalized = raw.slice(0, 4) + "0" + raw.slice(4, 5) + "0" + raw.slice(5, 6); | |||
| } | |||
| d = dayjs(normalized, "YYYYMMDD", true); | |||
| } | |||
| return d.isValid() ? d.format(OUTPUT_DATE_FORMAT) : raw; | |||
| const d = parseExpiryDayjs(item.expiryDate); | |||
| return d ? d.format(OUTPUT_DATE_FORMAT) : String(item.expiryDate ?? "").trim() || "—"; | |||
| }, | |||
| }, | |||
| { name: "remainingQty", label: t("Remaining Qty") }, | |||
| { | |||
| name: "uomDesc", | |||
| label: t("UoM"), | |||
| renderCell: (item) => item.uomDesc?.trim() || "—", | |||
| }, | |||
| { | |||
| name: "id", | |||
| label: t("Action"), | |||
| renderCell: (item) => ( | |||
| <Button | |||
| size="small" | |||
| variant="contained" | |||
| color="primary" | |||
| onClick={() => handleSubmitSingle(item.id)} | |||
| disabled={submittingIds.has(item.id) || !currentUserId} | |||
| > | |||
| {submittingIds.has(item.id) ? t("Disposing...") : t("Disposed")} | |||
| </Button> | |||
| ), | |||
| renderCell: (item) => { | |||
| const canHandle = canHandleExpiryItem(item); | |||
| const disposing = submittingIds.has(item.id); | |||
| const button = ( | |||
| <Button | |||
| size="small" | |||
| variant="contained" | |||
| color="primary" | |||
| onClick={() => handleSubmitSingle(item.id)} | |||
| disabled={disposing || !currentUserId || !canHandle} | |||
| > | |||
| {disposing ? t("Disposing...") : t("Disposed")} | |||
| </Button> | |||
| ); | |||
| if (canHandle) return button; | |||
| return ( | |||
| <Tooltip title={t("Not yet due; cannot dispose until the expiry date")}> | |||
| <span>{button}</span> | |||
| </Tooltip> | |||
| ); | |||
| }, | |||
| }, | |||
| ], | |||
| [t, handleSubmitSingle, submittingIds, currentUserId], | |||
| @@ -179,49 +297,238 @@ const ExpiryHandleTab: React.FC = () => { | |||
| const handleSearch = useCallback( | |||
| async (query: Record<SearchParamNames, string>) => { | |||
| if (searchInFlightRef.current) return; | |||
| const parsedDays = parseDaysAhead(daysAheadDraft); | |||
| setDaysAheadDraft(String(parsedDays)); | |||
| setPaging((prev) => ({ ...prev, pageNum: 1 })); | |||
| const filters: ExpiryItemFilter = { | |||
| itemCode: query.itemCode?.trim() || undefined, | |||
| itemName: query.itemName?.trim() || undefined, | |||
| lotNo: query.lotNo?.trim() || undefined, | |||
| daysAhead: parsedDays, | |||
| }; | |||
| searchInFlightRef.current = true; | |||
| try { | |||
| const result = await fetchExpiryItemList({ | |||
| itemCode: query.itemCode?.trim() || undefined, | |||
| itemName: query.itemName?.trim() || undefined, | |||
| expiryDate: query.expiryDate || undefined, | |||
| }); | |||
| const result = await fetchExpiryItemList(filters); | |||
| setLastFilters(filters); | |||
| setHasSearched(true); | |||
| setExpiryItems(result); | |||
| } catch (error) { | |||
| console.error("Failed to search expiry items:", error); | |||
| alert(t("Failed to load expiry items")); | |||
| } finally { | |||
| searchInFlightRef.current = false; | |||
| } | |||
| }, | |||
| [t], | |||
| [t, daysAheadDraft], | |||
| ); | |||
| const pagedItems = useMemo(() => { | |||
| const start = (paging.pageNum - 1) * paging.pageSize; | |||
| return expiryItems.slice(start, start + paging.pageSize); | |||
| }, [expiryItems, paging]); | |||
| const applyDaysAhead = useCallback( | |||
| async (nextDays: number) => { | |||
| const parsedDays = parseDaysAhead(nextDays); | |||
| setDaysAheadDraft(String(parsedDays)); | |||
| if (parsedDays === daysAhead) return; | |||
| if (!hasSearched) { | |||
| setLastFilters((prev) => ({ ...prev, daysAhead: parsedDays })); | |||
| return; | |||
| } | |||
| if (searchInFlightRef.current) return; | |||
| searchInFlightRef.current = true; | |||
| try { | |||
| const filters: ExpiryItemFilter = { | |||
| ...lastFilters, | |||
| daysAhead: parsedDays, | |||
| }; | |||
| const result = await fetchExpiryItemList(filters); | |||
| setLastFilters(filters); | |||
| setExpiryItems(result); | |||
| setPaging((prev) => ({ ...prev, pageNum: 1 })); | |||
| } catch (error) { | |||
| console.error("Failed to search expiry items:", error); | |||
| alert(t("Failed to load expiry items")); | |||
| } finally { | |||
| searchInFlightRef.current = false; | |||
| } | |||
| }, | |||
| [daysAhead, hasSearched, lastFilters, t], | |||
| ); | |||
| const handleDaysAheadChange = useCallback( | |||
| (event: SelectChangeEvent<string>) => { | |||
| void applyDaysAhead(parseDaysAhead(event.target.value)); | |||
| }, | |||
| [applyDaysAhead], | |||
| ); | |||
| const handleExportExcel = useCallback( | |||
| async (mode: "filtered" | "all") => { | |||
| if (!hasSearched) return; | |||
| if (exportInFlightRef.current) return; | |||
| exportInFlightRef.current = true; | |||
| setExporting(mode); | |||
| try { | |||
| await exportExpiryItemExcel( | |||
| mode === "all" | |||
| ? { | |||
| daysAhead, | |||
| } | |||
| : { | |||
| ...lastFilters, | |||
| bucket: resultTab, | |||
| }, | |||
| ); | |||
| } catch (error) { | |||
| console.error("Failed to export expiry items:", error); | |||
| alert(t("Failed to export Excel")); | |||
| } finally { | |||
| setExporting(null); | |||
| exportInFlightRef.current = false; | |||
| } | |||
| }, | |||
| [hasSearched, lastFilters, resultTab, daysAhead, t], | |||
| ); | |||
| const handleResultTabChange = useCallback( | |||
| (_: React.SyntheticEvent, value: string) => { | |||
| setResultTab(value as ResultBucket); | |||
| setPaging((prev) => ({ ...prev, pageNum: 1 })); | |||
| }, | |||
| [], | |||
| ); | |||
| return ( | |||
| <Box> | |||
| <StockIssueSearchPanel fields={searchFields} onSearch={handleSearch} /> | |||
| <Box sx={{ display: "flex", justifyContent: "flex-end", mb: 1 }}> | |||
| <Box | |||
| sx={{ | |||
| display: "flex", | |||
| alignItems: "center", | |||
| justifyContent: "flex-start", | |||
| gap: 1.5, | |||
| mb: 2, | |||
| flexWrap: "wrap", | |||
| }} | |||
| > | |||
| <Tabs | |||
| value={resultTab} | |||
| onChange={handleResultTabChange} | |||
| sx={{ | |||
| minHeight: 48, | |||
| "& .MuiTab-root": { minHeight: 48, minWidth: 0, px: 2 }, | |||
| }} | |||
| > | |||
| <Tab | |||
| value="expired" | |||
| label={`${t("Already expired")} (${itemsByBucket.expired.length})`} | |||
| /> | |||
| <Tab | |||
| value="today" | |||
| label={`${t("Expires today")} (${itemsByBucket.today.length})`} | |||
| /> | |||
| <Tab | |||
| value="upcoming" | |||
| label={`${t("Expires within X days")} (${itemsByBucket.upcoming.length})`} | |||
| /> | |||
| </Tabs> | |||
| <Button | |||
| variant="contained" | |||
| color="primary" | |||
| onClick={handleSubmitAll} | |||
| disabled={batchSubmitting || !currentUserId || expiryItems.length === 0} | |||
| variant="outlined" | |||
| startIcon={<FileDownload />} | |||
| onClick={() => handleExportExcel("all")} | |||
| disabled={!hasSearched || exporting != null} | |||
| > | |||
| {batchSubmitting | |||
| ? `${t("Disposing...")} ${batchProgress ? `(${batchProgress.done}/${batchProgress.total})` : ""}` | |||
| : t("Batch Disposed All")} | |||
| {exporting === "all" ? t("Exporting...") : t("Export all in tab")} | |||
| </Button> | |||
| </Box> | |||
| <Box | |||
| sx={{ | |||
| display: "flex", | |||
| alignItems: "center", | |||
| gap: 1, | |||
| mb: 1, | |||
| flexWrap: "wrap", | |||
| }} | |||
| > | |||
| {resultTab === "upcoming" && ( | |||
| <FormControl size="small" sx={{ minWidth: 120 }}> | |||
| <InputLabel id="expiry-days-ahead-label">{t("Days ahead")}</InputLabel> | |||
| <Select | |||
| labelId="expiry-days-ahead-label" | |||
| label={t("Days ahead")} | |||
| value={String(parseDaysAhead(daysAheadDraft))} | |||
| onChange={handleDaysAheadChange} | |||
| > | |||
| {DAYS_AHEAD_OPTIONS.map((days) => ( | |||
| <MenuItem key={days} value={String(days)}> | |||
| {days} | |||
| </MenuItem> | |||
| ))} | |||
| </Select> | |||
| </FormControl> | |||
| )} | |||
| <Box sx={{ display: "flex", gap: 1, ml: "auto" }}> | |||
| <Button | |||
| variant="outlined" | |||
| startIcon={<FileDownload />} | |||
| onClick={() => handleExportExcel("filtered")} | |||
| disabled={!hasSearched || exporting != null || tabItems.length === 0} | |||
| > | |||
| {exporting === "filtered" ? t("Exporting...") : t("Export Excel")} | |||
| </Button> | |||
| <Button | |||
| variant="contained" | |||
| color="primary" | |||
| onClick={() => setBatchConfirmOpen(true)} | |||
| disabled={ | |||
| batchSubmitting || !currentUserId || handleableIds.length === 0 | |||
| } | |||
| > | |||
| {batchSubmitting | |||
| ? `${t("Disposing...")} ${batchProgress ? `(${batchProgress.done}/${batchProgress.total})` : ""}` | |||
| : t("Batch Disposed All")} | |||
| </Button> | |||
| </Box> | |||
| </Box> | |||
| <SearchResults<ExpiryItemResult> | |||
| items={pagedItems} | |||
| items={tabItems} | |||
| columns={expiryColumns} | |||
| pagingController={paging} | |||
| setPagingController={setPaging} | |||
| totalCount={expiryItems.length} | |||
| totalCount={tabItems.length} | |||
| /> | |||
| <Dialog | |||
| open={batchConfirmOpen} | |||
| onClose={() => { | |||
| if (!batchSubmitting) setBatchConfirmOpen(false); | |||
| }} | |||
| fullWidth | |||
| maxWidth="xs" | |||
| > | |||
| <DialogTitle>{t("Confirm batch dispose")}</DialogTitle> | |||
| <DialogContent> | |||
| <Typography> | |||
| {t("Confirm batch dispose message", { count: handleableIds.length })} | |||
| </Typography> | |||
| </DialogContent> | |||
| <DialogActions> | |||
| <Button | |||
| onClick={() => setBatchConfirmOpen(false)} | |||
| disabled={batchSubmitting} | |||
| > | |||
| {t("Cancel")} | |||
| </Button> | |||
| <Button | |||
| variant="contained" | |||
| color="primary" | |||
| disabled={batchSubmitting || handleableIds.length === 0} | |||
| onClick={async () => { | |||
| setBatchConfirmOpen(false); | |||
| await handleSubmitAll(); | |||
| }} | |||
| > | |||
| {tCommon("Confirm")} | |||
| </Button> | |||
| </DialogActions> | |||
| </Dialog> | |||
| </Box> | |||
| ); | |||
| }; | |||
| @@ -22,6 +22,8 @@ type SearchQuery = { | |||
| lotNo: string; | |||
| startDate: string; | |||
| endDate: string; | |||
| handledStartDate: string; | |||
| handledEndDate: string; | |||
| }; | |||
| type SearchParamNames = keyof SearchQuery; | |||
| @@ -29,6 +31,7 @@ interface Props { | |||
| kind: RecordKind; | |||
| } | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 74 | v1.0.2 | 2026-09-07 */ | |||
| const StockIssueRecordTab: React.FC<Props> = ({ kind }) => { | |||
| const { t } = useTranslation("stockIssue"); | |||
| const [items, setItems] = useState<StockIssueHandleRecord[]>([]); | |||
| @@ -40,27 +43,47 @@ const StockIssueRecordTab: React.FC<Props> = ({ kind }) => { | |||
| lotNo: "", | |||
| startDate: "", | |||
| endDate: "", | |||
| handledStartDate: "", | |||
| handledEndDate: "", | |||
| }); | |||
| const hasSearchedRef = useRef(false); | |||
| const prevPagingRef = useRef(paging); | |||
| const searchFields: StockIssueSearchField<SearchParamNames>[] = useMemo( | |||
| () => [ | |||
| { name: "itemCode", label: t("Item Code"), type: "text" }, | |||
| { name: "itemName", label: t("Item"), type: "text" }, | |||
| { name: "lotNo", label: t("Lot No."), type: "text" }, | |||
| { | |||
| name: "startDate", | |||
| label: kind === "expiry" ? t("Expiry Start Date") : t("Start Date"), | |||
| type: "date", | |||
| mirrorTo: "endDate", | |||
| }, | |||
| { | |||
| name: "endDate", | |||
| label: kind === "expiry" ? t("Expiry End Date") : t("End Date"), | |||
| type: "date", | |||
| }, | |||
| ], | |||
| () => { | |||
| const fields: StockIssueSearchField<SearchParamNames>[] = [ | |||
| { name: "itemCode", label: t("Item Code"), type: "text" }, | |||
| { name: "itemName", label: t("Item"), type: "text" }, | |||
| { name: "lotNo", label: t("Lot No."), type: "text" }, | |||
| { | |||
| name: "startDate", | |||
| label: kind === "expiry" ? t("Expiry Start Date") : t("Start Date"), | |||
| type: "date", | |||
| mirrorTo: "endDate", | |||
| }, | |||
| { | |||
| name: "endDate", | |||
| label: kind === "expiry" ? t("Expiry End Date") : t("End Date"), | |||
| type: "date", | |||
| }, | |||
| ]; | |||
| if (kind === "expiry") { | |||
| fields.push( | |||
| { | |||
| name: "handledStartDate", | |||
| label: t("Handled Start Date"), | |||
| type: "date", | |||
| mirrorTo: "handledEndDate", | |||
| }, | |||
| { | |||
| name: "handledEndDate", | |||
| label: t("Handled End Date"), | |||
| type: "date", | |||
| }, | |||
| ); | |||
| } | |||
| return fields; | |||
| }, | |||
| [t, kind], | |||
| ); | |||
| @@ -73,6 +96,8 @@ const StockIssueRecordTab: React.FC<Props> = ({ kind }) => { | |||
| lotNo: query.lotNo?.trim() || undefined, | |||
| startDate: query.startDate || undefined, | |||
| endDate: query.endDate || undefined, | |||
| handledStartDate: query.handledStartDate || undefined, | |||
| handledEndDate: query.handledEndDate || undefined, | |||
| pageNum: page.pageNum - 1, | |||
| pageSize: page.pageSize, | |||
| }; | |||
| @@ -166,7 +191,7 @@ const StockIssueRecordTab: React.FC<Props> = ({ kind }) => { | |||
| }, | |||
| { | |||
| name: "uomDesc", | |||
| label: t("UOM"), | |||
| label: t("UoM"), | |||
| renderCell: (row) => ( | |||
| <> | |||
| {row.uomDesc ?? ""} | |||
| @@ -179,8 +204,10 @@ const StockIssueRecordTab: React.FC<Props> = ({ kind }) => { | |||
| renderCell: (row) => | |||
| row.handlerName ?? (row.handlerId != null ? String(row.handlerId) : "—"), | |||
| }, | |||
| { name: "remarks", label: t("Remarks") }, | |||
| ); | |||
| if (kind !== "expiry") { | |||
| base.push({ name: "remarks", label: t("Remarks") }); | |||
| } | |||
| return base; | |||
| }, [t, kind]); | |||
| @@ -25,7 +25,7 @@ import "dayjs/locale/zh-hk"; | |||
| import { useCallback, useMemo, useState } from "react"; | |||
| import { useTranslation } from "react-i18next"; | |||
| export type StockIssueSearchFieldType = "text" | "select" | "date"; | |||
| export type StockIssueSearchFieldType = "text" | "select" | "date" | "number"; | |||
| export interface StockIssueSearchField<K extends string> { | |||
| name: K; | |||
| @@ -36,6 +36,9 @@ export interface StockIssueSearchField<K extends string> { | |||
| getOptionLabel?: (value: string) => string; | |||
| /** When this date is picked, copy the same value to `mirrorTo`. */ | |||
| mirrorTo?: K; | |||
| defaultValue?: string; | |||
| min?: number; | |||
| max?: number; | |||
| } | |||
| interface Props<K extends string> { | |||
| @@ -46,6 +49,7 @@ interface Props<K extends string> { | |||
| disabled?: boolean; | |||
| } | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 74 | v1.0.2 | 2026-09-07 */ | |||
| function StockIssueSearchPanel<K extends string>({ | |||
| fields, | |||
| onSearch, | |||
| @@ -60,7 +64,8 @@ function StockIssueSearchPanel<K extends string>({ | |||
| return fields.reduce( | |||
| (acc, field) => { | |||
| acc[field.name] = | |||
| field.type === "select" ? "All" : ""; | |||
| field.defaultValue ?? | |||
| (field.type === "select" ? "All" : ""); | |||
| return acc; | |||
| }, | |||
| {} as Record<K, string>, | |||
| @@ -127,6 +132,21 @@ function StockIssueSearchPanel<K extends string>({ | |||
| disabled={disabled} | |||
| /> | |||
| )} | |||
| {field.type === "number" && ( | |||
| <TextField | |||
| label={field.label} | |||
| type="number" | |||
| fullWidth | |||
| value={values[field.name] ?? ""} | |||
| onChange={handleTextChange(field.name)} | |||
| disabled={disabled} | |||
| inputProps={{ | |||
| min: field.min, | |||
| max: field.max, | |||
| step: 1, | |||
| }} | |||
| /> | |||
| )} | |||
| {field.type === "select" && ( | |||
| <FormControl fullWidth disabled={disabled}> | |||
| <InputLabel>{field.label}</InputLabel> | |||
| @@ -166,6 +166,9 @@ | |||
| "Truck X": "Truck X", | |||
| "Truck lane search requires date message": "Truck lane search requires date message", | |||
| "Truck lane search requires date title": "Truck lane search requires date title", | |||
| "User session not found": "User session not found. Please login again.", | |||
| "Error": "Error", | |||
| "OK": "OK", | |||
| "Warning: Some delivery orders do not have matching trucks for the target date.": "Warning: Some delivery orders do not have matching trucks for the target date.", | |||
| "Workbench Batch Release": "Workbench Batch Release", | |||
| "code": "code", | |||
| @@ -51,7 +51,7 @@ | |||
| "acceptedPutawayQty": "Put Away Qty (This Batch)", | |||
| "putawayQty": "Put Away Qty", | |||
| "Confirm submit": "Confirm Submit", | |||
| "This batch quantity exceeds order quantity. Do you still want to submit?": "This batch quantity exceeds order quantity. Do you still want to submit?", | |||
| "qtyExceedsOrderConfirm": "Cumulative received quantity exceeds order quantity. Do you still want to submit?", | |||
| "acceptQty": "Accept Qty", | |||
| "printQty": "Print Qty", | |||
| "qcResult": "QC Result", | |||
| @@ -6,15 +6,29 @@ | |||
| "Bad Item Qty": "Bad Item Qty", | |||
| "Bad Item Records": "Bad Item Records", | |||
| "Batch Disposed All": "Batch Disposed All", | |||
| "Export Excel": "Export this category", | |||
| "Exporting...": "Exporting...", | |||
| "Failed to export Excel": "Failed to export Excel", | |||
| "Book Qty": "Book Qty", | |||
| "Cancel": "Cancel", | |||
| "Code": "Code", | |||
| "Defective Qty": "Defective Qty", | |||
| "Disposed": "Disposed", | |||
| "Disposed": "Expiry handle", | |||
| "Disposing...": "Disposing...", | |||
| "DO Order Code": "DO Order Code", | |||
| "End Date": "End Date", | |||
| "Expiry Date": "Expiry Date", | |||
| "Expiry on or before": "Expiry on or before", | |||
| "Already expired": "Expiry not yet handle", | |||
| "Expires today": "Expires today", | |||
| "Expires within 7 days": "Expires within X days", | |||
| "Expires within n days": "Expires within X days", | |||
| "Expires within X days": "Expires within X days", | |||
| "All expiry items": "All", | |||
| "Export all in tab": "Export all", | |||
| "Days ahead": "Days ahead", | |||
| "Confirm batch dispose": "Confirm batch dispose", | |||
| "Confirm batch dispose message": "Dispose {{count}} lot(s)? Remaining quantity will be fully stocked out.", | |||
| "Expiry End Date": "Expiry End Date", | |||
| "Expiry Item": "Expiry Item", | |||
| "Expiry Item Handle": "Expiry Item Handle", | |||
| @@ -24,7 +38,10 @@ | |||
| "Failed to load expiry items": "Failed to load expiry items", | |||
| "Failed to submit": "Failed to submit", | |||
| "Failed to submit expiry item": "Failed to submit expiry item", | |||
| "Not yet due; cannot dispose until the expiry date": "Not yet due; cannot dispose until the expiry date", | |||
| "Handled Date": "Handled Date", | |||
| "Handled Start Date": "Handled Start Date", | |||
| "Handled End Date": "Handled End Date", | |||
| "Handler": "Handler", | |||
| "Issue Qty": "Issue Qty", | |||
| "Item": "Item", | |||
| @@ -221,5 +221,6 @@ | |||
| "Replenishment demo note": "此為前端假資料回應;正式環境將呼叫後端 API。", | |||
| "Search Delivery Order": "搜尋送貨單", | |||
| "DO Replenishment": "送貨單補貨", | |||
| "Error": "錯誤" | |||
| "Error": "錯誤", | |||
| "User session not found": "找不到登入使用者,請重新登入。" | |||
| } | |||
| @@ -51,7 +51,7 @@ | |||
| "acceptedPutawayQty": "本批上架數量", | |||
| "putawayQty": "上架數量", | |||
| "Confirm submit": "確定提交", | |||
| "This batch quantity exceeds order quantity. Do you still want to submit?": "本批收貨數量超出訂單數量。仍要提交嗎?", | |||
| "qtyExceedsOrderConfirm": "累計收貨數量超出訂單數量。仍要提交嗎?", | |||
| "acceptQty": "揀收數量", | |||
| "printQty": "列印數量", | |||
| "qcResult": "品檢結果", | |||
| @@ -6,15 +6,29 @@ | |||
| "Bad Item Qty": "不良品數量", | |||
| "Bad Item Records": "不良品處理紀錄", | |||
| "Batch Disposed All": "批量處理完成", | |||
| "Export Excel": "匯出目前分類", | |||
| "Exporting...": "匯出中...", | |||
| "Failed to export Excel": "匯出 Excel 失敗", | |||
| "Book Qty": "帳面庫存", | |||
| "Cancel": "取消", | |||
| "Code": "編號", | |||
| "Defective Qty": "不良數量", | |||
| "Disposed": "已處置", | |||
| "Disposed": "過期處理", | |||
| "Disposing...": "處理中...", | |||
| "DO Order Code": "送貨單編號", | |||
| "End Date": "結束日期", | |||
| "Expiry Date": "到期日", | |||
| "Expiry on or before": "到期日(含當日及以前)", | |||
| "Already expired": "過期尚未處理", | |||
| "Expires today": "今日到期", | |||
| "Expires within 7 days": "未來 X 日到期", | |||
| "Expires within n days": "未來 X 日到期", | |||
| "Expires within X days": "未來 X 日到期", | |||
| "All expiry items": "全部", | |||
| "Export all in tab": "匯出全部", | |||
| "Days ahead": "未來天數", | |||
| "Confirm batch dispose": "確認批量處置", | |||
| "Confirm batch dispose message": "將處置 {{count}} 筆批號,數量會全部出倉。確定?", | |||
| "Expiry End Date": "到期日(結束)", | |||
| "Expiry Item": "過期", | |||
| "Expiry Item Handle": "過期品處理", | |||
| @@ -24,10 +38,13 @@ | |||
| "Failed to load expiry items": "載入過期品失敗", | |||
| "Failed to submit": "提交失敗", | |||
| "Failed to submit expiry item": "提交過期品失敗", | |||
| "Not yet due; cannot dispose until the expiry date": "尚未到期,到期日當日才可處置", | |||
| "Handled Date": "處理日期", | |||
| "Handled Start Date": "處理日期(開始)", | |||
| "Handled End Date": "處理日期(結束)", | |||
| "Handler": "處理人", | |||
| "Issue Qty": "問題數量", | |||
| "Item": "貨品", | |||
| "Item": "貨品名稱", | |||
| "Item Code": "貨品編號", | |||
| "Item not found": "找不到貨品", | |||
| "Item selected": "已選擇貨品", | |||
| @@ -50,7 +67,7 @@ | |||
| "Processing...": "處理中...", | |||
| "Quantity exceeds available quantity": "數量超過可用數量", | |||
| "Remain available Quantity": "剩餘可用數量", | |||
| "Remaining Qty": "剩餘數量", | |||
| "Remaining Qty": "數量", | |||
| "Remark": "備註", | |||
| "Remarks": "備註", | |||
| "Reset": "重置", | |||
| @@ -69,7 +86,7 @@ | |||
| "Submitting...": "提交中...", | |||
| "Type": "類型", | |||
| "Unknown error": "未知錯誤", | |||
| "UoM": "單位", | |||
| "UoM": "庫存單位", | |||
| "User ID is required": "需要用戶ID", | |||
| "Warehouse": "倉庫", | |||
| "available": "可用", | |||