diff --git a/src/app/(main)/po/edit/page.tsx b/src/app/(main)/po/edit/page.tsx index e7f0667d..5eeabac3 100644 --- a/src/app/(main)/po/edit/page.tsx +++ b/src/app/(main)/po/edit/page.tsx @@ -16,11 +16,11 @@ type Props = {} & SearchParams; const PoEdit: React.FC = 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(); } diff --git a/src/app/api/inventory/actions.ts b/src/app/api/inventory/actions.ts index 5da0eeb6..fb7d8643 100644 --- a/src/app/api/inventory/actions.ts +++ b/src/app/api/inventory/actions.ts @@ -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); diff --git a/src/app/api/settings/item/actions.ts b/src/app/api/settings/item/actions.ts index f6b91807..2b241dee 100644 --- a/src/app/api/settings/item/actions.ts +++ b/src/app/api/settings/item/actions.ts @@ -138,6 +138,28 @@ export const fetchItemsWithDetails = cache(async (searchParams?: Record) => { + 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>( + queryString + ? `${BASE_API_URL}/items/getRecordByPage?${queryString}` + : `${BASE_API_URL}/items/getRecordByPage`, + { + method: "GET", + next: { tags: ["items"] }, + }, + ); +}); + export const fetchAllItemsInClient = cache(async () => { return serverFetchJson(`${BASE_API_URL}/items/consumables`, { next: { tags: ["items"] }, diff --git a/src/app/api/stockAdjustment/actions.ts b/src/app/api/stockAdjustment/actions.ts index cbcb04d5..c42e0c09 100644 --- a/src/app/api/stockAdjustment/actions.ts +++ b/src/app/api/stockAdjustment/actions.ts @@ -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( + `${BASE_API_URL}/stockAdjustment/latestRemarks?itemId=${itemId}`, + { method: "GET" }, + ); +}; + export const submitStockAdjustment = async (data: StockAdjustmentRequest) => { const result = await serverFetchJson( `${BASE_API_URL}/stockAdjustment/submit`, diff --git a/src/app/api/stockIssue/actions.ts b/src/app/api/stockIssue/actions.ts index 5461e1ec..5b30c36c 100644 --- a/src/app/api/stockIssue/actions.ts +++ b/src/app/api/stockIssue/actions.ts @@ -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(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); diff --git a/src/app/api/stockIssue/client.ts b/src/app/api/stockIssue/client.ts new file mode 100644 index 00000000..264d5963 --- /dev/null +++ b/src/app/api/stockIssue/client.ts @@ -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 { + 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); +} diff --git a/src/components/BagPrint/BagPrintSearch.tsx b/src/components/BagPrint/BagPrintSearch.tsx index 64ed9fe6..3f9cd88b 100644 --- a/src/components/BagPrint/BagPrintSearch.tsx +++ b/src/components/BagPrint/BagPrintSearch.tsx @@ -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([]); const [loading, setLoading] = useState(true); diff --git a/src/components/DoDetail/DoDetail.tsx b/src/components/DoDetail/DoDetail.tsx index 496d8ec4..8b5bf3e3 100644 --- a/src/components/DoDetail/DoDetail.tsx +++ b/src/components/DoDetail/DoDetail.tsx @@ -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 = ({ defaultValues, id, @@ -57,22 +58,13 @@ const DoDetail: React.FC = ({ 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 = ({ } 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) => { diff --git a/src/components/DoSearch/DoSearch.tsx b/src/components/DoSearch/DoSearch.tsx index d29a4e4a..e4da4268 100644 --- a/src/components/DoSearch/DoSearch.tsx +++ b/src/components/DoSearch/DoSearch.tsx @@ -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 = ({ filterArgs, searchQuery, onDeliveryOrderSearch }) => { const apiRef = useGridApiRef(); @@ -509,6 +510,16 @@ const DoSearch: React.FC = ({ 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 = ({ 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; diff --git a/src/components/DoSearchWorkbench/DoSearchWorkbench.tsx b/src/components/DoSearchWorkbench/DoSearchWorkbench.tsx index b799ad50..39aafb1d 100644 --- a/src/components/DoSearchWorkbench/DoSearchWorkbench.tsx +++ b/src/components/DoSearchWorkbench/DoSearchWorkbench.tsx @@ -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 = ({ 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; diff --git a/src/components/InventorySearch/InventoryLotLineTable.tsx b/src/components/InventorySearch/InventoryLotLineTable.tsx index d9a2f448..9e3a041f 100644 --- a/src/components/InventorySearch/InventoryLotLineTable.tsx +++ b/src/components/InventorySearch/InventoryLotLineTable.tsx @@ -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; } -/** 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 = ({ inventoryLotLines, pagingController, setPagingController, totalCount, inventory, filterLotNo, @@ -68,8 +68,8 @@ const InventoryLotLineTable: React.FC = ({ }) => { 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 = ({ remarks: '', }); const originalAdjustmentLinesRef = useRef([]); + const adjustSaveInFlightRef = useRef(false); + const loadedRemarksByLotRef = useRef>(new Map()); + const remarksFetchGenRef = useRef(0); const [adjustmentEntries, setAdjustmentEntries] = useState([]); + 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(); + 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={} onClick={handleAdjustmentSave} - disabled={!hasAdjustmentChange} + disabled={!hasAdjustmentChange || isAdjustSaving} > {t("Save")} @@ -1004,7 +1049,9 @@ const prevAdjustmentModalOpenRef = useRef(false); }, }} /> - ) : null} + ) : ( + line.remarks || null + )} {pendingRemovalLineId === line.id ? ( diff --git a/src/components/InventorySearch/InventorySearch.tsx b/src/components/InventorySearch/InventorySearch.tsx index 778e6d66..a59ff224 100644 --- a/src/components/InventorySearch/InventorySearch.tsx +++ b/src/components/InventorySearch/InventorySearch.tsx @@ -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 = ({ 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 = { 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([]); @@ -104,6 +117,20 @@ const InventorySearch: React.FC = ({ 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 = ({ inventories, printerCombo }) => { const [lotNoFilter, setLotNoFilter] = useState(''); const [scannedItemId, setScannedItemId] = useState(null); - // Opening inventory (pure opening stock for items without existing inventory) - const [openingItems, setOpeningItems] = useState([]); - const [openingModalOpen, setOpeningModalOpen] = useState(false); - const [openingSelectedItem, setOpeningSelectedItem] = useState(null); - const [openingLoading, setOpeningLoading] = useState(false); - const [openingSearchText, setOpeningSearchText] = useState(''); - const defaultInputs = useMemo( () => ({ itemId: '', @@ -297,46 +317,55 @@ const InventorySearch: React.FC = ({ inventories, printerCombo }) => { // On Search const onSearch = useCallback( async (query: Record) => { - 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 = ({ 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 = ({ 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 = { - 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 ( <> = ({ inventories, printerCombo }) => { )} - - } /> @@ -600,98 +518,6 @@ const InventorySearch: React.FC = ({ inventories, printerCombo }) => { } }} /> - - setOpeningModalOpen(false)} - fullWidth - maxWidth="md" - > - {t('Add entry for items without inventory')} - - - setOpeningSearchText(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault(); - handleOpeningSearch(); - } - }} - sx={{ flex: 2 }} - /> - - - - {openingItems.length === 0 && !openingLoading ? ( - - {openingSearchText - ? t('No data') - : t('Enter item code or name to search')} - - ) : ( - - - - - {t('Code')} - {t('Name')} - {t('UoM')} - {t('Current Stock')} - - - - {openingItems.map((it) => { - const [code, ...nameParts] = (it.label ?? '').split(' - '); - const name = nameParts.join(' - '); - const selected = openingSelectedItem?.id === it.id; - return ( - setOpeningSelectedItem(it)} - sx={{ cursor: 'pointer' }} - > - - - - {code} - {name} - {it.uomDesc || it.uom} - - {it.currentStockBalance != null ? it.currentStockBalance : '-'} - - - ); - })} - -
- )} -
- - - - -
); }; diff --git a/src/components/PoDetail/PoDetail.tsx b/src/components/PoDetail/PoDetail.tsx index 97c32260..0853d6d5 100644 --- a/src/components/PoDetail/PoDetail.tsx +++ b/src/components/PoDetail/PoDetail.tsx @@ -207,7 +207,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.3 | 2026-09-10 */ const PoDetail: React.FC = ({ po, warehouse, printerCombo }) => { const cameras = useContext(CameraContext); const { data: session } = useSession(); @@ -578,8 +578,6 @@ const PoDetail: React.FC = ({ po, warehouse, printerCombo }) => { // setStockInLine([]) // }, []); - - const [tabIndex, setTabIndex] = useState(0); const handleTabChange = useCallback>( (_e, newValue) => { @@ -701,9 +699,9 @@ const PoDetail: React.FC = ({ po, warehouse, printerCombo }) => { return ( <> - + {/* Area1: title */} - + {purchaseOrder.code} -{" "} @@ -713,7 +711,7 @@ const PoDetail: React.FC = ({ po, warehouse, printerCombo }) => { {/* area2: dn info */} - + {/* left side select po */} @@ -727,8 +725,8 @@ const PoDetail: React.FC = ({ po, warehouse, printerCombo }) => { {/* right side po info */} - - + + @@ -858,10 +856,10 @@ const PoDetail: React.FC = ({ po, warehouse, printerCombo }) => { {/* Area4: Main Table */} - - - - + + + +
@@ -905,13 +903,13 @@ const PoDetail: React.FC = ({ po, warehouse, printerCombo }) => { {/* area5: selected item info */} - + {selectedRow ? `已選擇貨品: ${selectedRow?.itemNo ? selectedRow.itemNo : 'N/A'} - ${selectedRow?.itemName ? selectedRow?.itemName : 'N/A'}` : "未選擇貨品"} - + {selectedRow && ( void; }; +/** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.3 | 2026-09-10 */ export const PoDetailRow = memo(function PoDetailRow({ row, selected, @@ -97,17 +90,17 @@ export const PoDetailRow = memo(function PoDetailRow({ useEffect(() => { setLotNoInput(savedLotNo); - setDnQtyInput(savedDnQty); + setDnQtyInput(savedDnQty.replace(/[^\d]/g, "")); }, [savedLotNo, savedDnQty]); const handleStart = useCallback( - (roundMode?: StockQtyRoundMode) => { + () => { if (submitInFlightRef.current || isStarting) return; const orderQty = Number(row?.qty) ?? 0; const acceptedQty = Number(dnQtyInput.trim()); - if (isNaN(acceptedQty) || acceptedQty <= 0) { - alert("來貨數量必須大於0!"); + if (!Number.isInteger(acceptedQty) || acceptedQty <= 0) { + alert("來貨數量必須為大於0的整數!"); return; } @@ -116,18 +109,6 @@ export const PoDetailRow = memo(function PoDetailRow({ Number(row.stockUom?.stockQty ?? 0), acceptedQty, ); - const needsRound = isNotIntegerQty(previewStockQty); - if (needsRound && roundMode !== "CEILING" && roundMode !== "FLOOR") { - return; - } - const round: StockQtyRoundChoice | undefined = - needsRound && roundMode - ? { - mode: roundMode, - before: previewStockQty, - after: roundStockQty(previewStockQty, roundMode), - } - : undefined; const doSubmit = () => { if (submitInFlightRef.current) return; @@ -145,12 +126,6 @@ export const PoDetailRow = memo(function PoDetailRow({ purchaseOrderLineId: row.id, acceptedQty: acceptedQty, productLotNo: lotNoInput || "", - ...(round - ? { - stockQtyRoundMode: round.mode, - stockQtyRoundSource: "CREATE" as const, - } - : {}), }; const res = await createStockInLine(postData); if (res) { @@ -166,11 +141,25 @@ export const PoDetailRow = memo(function PoDetailRow({ })(); }; - 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 = previewStockQty; + 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 { @@ -189,19 +178,6 @@ export const PoDetailRow = memo(function PoDetailRow({ ], ); - const batchPurchaseQty = Number(dnQtyInput.trim()) || 0; - const previewStockQty = previewPoBatchStockQty( - Number(row?.qty) ?? 0, - Number(row.stockUom?.stockQty ?? 0), - batchPurchaseQty, - ); - const needsStockQtyRound = batchPurchaseQty > 0 && isNotIntegerQty(previewStockQty); - const roundUpQty = roundStockQty(previewStockQty, "CEILING"); - const roundDownQty = roundStockQty(previewStockQty, "FLOOR"); - const stockUomLabel = row.stockUom?.stockUomDesc?.trim() - ? ` ${row.stockUom.stockUomDesc.trim()}` - : ""; - const totalStockReceived = row.stockInLine .filter((sil) => sil.purchaseOrderLineId === row.id) .reduce((acc, cur) => acc + (cur.acceptedQty ?? 0), 0); @@ -299,61 +275,35 @@ export const PoDetailRow = memo(function PoDetailRow({ type="text" variant="outlined" value={dnQtyInput} - onChange={(e) => setDnQtyInput(e.target.value)} + onChange={(e) => setDnQtyInput(e.target.value.replace(/[^\d]/g, ""))} onBlur={() => onInputBlur(row.id, lotNoInput, dnQtyInput)} onClick={(e) => e.stopPropagation()} InputProps={{ inputProps: { - min: 0, - step: "any", - inputMode: "decimal", + min: 1, + step: 1, + inputMode: "numeric", + pattern: "[0-9]*", }, }} /> ) : null} - {needsStockQtyRound ? ( - e.stopPropagation()}> - - {t("Converted stock qty is")} {decimalFormatter.format(previewStockQty)} - {row.stockUom?.stockUomDesc ? ` (${row.stockUom.stockUomDesc})` : ""} - - - - - ) : ( - - )} + ); diff --git a/src/components/PoDetail/PoInputGrid.tsx b/src/components/PoDetail/PoInputGrid.tsx index bd67ae59..8e848e2d 100644 --- a/src/components/PoDetail/PoInputGrid.tsx +++ b/src/components/PoDetail/PoInputGrid.tsx @@ -35,7 +35,7 @@ import ShoppingCartIcon from "@mui/icons-material/ShoppingCart"; import PlayArrowIcon from "@mui/icons-material/PlayArrow"; import { PurchaseOrderLine } from "@/app/api/po"; import { StockInLine } from "@/app/api/stockIn"; -import { createStockInLine, deleteStockInLine, QcResult } from "@/app/api/stockIn/actions"; +import { createStockInLine, deleteStockInLine, updateStockInLine, QcResult } from "@/app/api/stockIn/actions"; import { usePathname, useSearchParams } from "next/navigation"; import { returnWeightUnit, @@ -69,39 +69,22 @@ import { SessionWithTokens } from "@/config/authConfig"; import { EscalationCombo } from "@/app/api/user"; import { deleteDialog } from "../Swal/CustomAlerts"; import StockInLineRowActions from "./StockInLineRowActions"; +import { StockQtyRoundMode, needsPoQcStockQtyRound } from "./stockQtyRound"; -/** Sum of fixed column widths (desktop) so the grid can scroll horizontally without squeezing cells. */ -const STOCK_IN_GRID_MIN_WIDTH_DESKTOP = 1062; +const ACTIONS_COLUMN_WIDTH = 380; +const PURCHASE_QTY_COLUMN_WIDTH = 72; +const UOM_COLUMN_WIDTH = 124; +const STOCK_QTY_COLUMN_WIDTH = 110; +const STOCK_IN_ROW_HEIGHT = 58; -const ACTIONS_COLUMN_WIDTH = 168; - -const ACTION_BUTTON_HEIGHT = 38; -const ACTION_BUTTON_GAP = 6; -/** Extra space for cell padding + outlined button borders */ -const ACTION_ROW_EXTRA_PADDING = 36; - -function getActionRowHeight(buttonCount: number): number { - return ( - buttonCount * ACTION_BUTTON_HEIGHT + - Math.max(0, buttonCount - 1) * ACTION_BUTTON_GAP + - ACTION_ROW_EXTRA_PADDING - ); -} - -function countActionButtonsForRow(row: StockInLineRow): number { - let count = 1; - const status = (row.status ?? "").toLowerCase(); - if (status === "rejected" || status === "partially_completed") { - count += 1; - } - if (status === "received") { - count += 1; - } - if (canDeleteStockInLine(row)) { - count += 1; - } - return count; -} +/** Extra table width is shared by text columns; qty / status / actions stay tight. */ +const COLUMN_GROW: Record = { + dnNo: 1, + productLotNo: 1, + uom: 1.5, + stockQty: 1, + stockUom: 1.5, +}; /** Tighter horizontal padding for narrow data columns (headers unchanged). */ const COMPACT_STOCK_IN_CELL_FIELDS = [ @@ -170,7 +153,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.3 | 2026-09-10 */ function PoInputGrid({ // qc, setRows, @@ -188,8 +171,6 @@ function PoInputGrid({ const theme = useTheme(); /** Narrow phones: hide low-priority columns. */ const isCompact = useMediaQuery(theme.breakpoints.down("md"), { noSsr: true }); - /** Tablet / sub-desktop (< xl): flex columns to fill available width. Desktop (≥ xl) keeps fixed widths. */ - const isTablet = useMediaQuery(theme.breakpoints.down("xl"), { noSsr: true }); const apiRef = useGridApiRef(); const [rowModesModel, setRowModesModel] = useState({}); const getRowId = useCallback>( @@ -215,6 +196,8 @@ function PoInputGrid({ const [btnIsLoading, setBtnIsLoading] = useState(false); const [isDeleting, setIsDeleting] = useState(false); const deleteInFlightRef = useRef(false); + const roundInFlightRef = useRef(false); + const [roundingSilId, setRoundingSilId] = useState(null); const [currQty, setCurrQty] = useState(() => { const total = entries.reduce( // remaining qty (M18 unit) @@ -248,6 +231,16 @@ function PoInputGrid({ const handleSoftDelete = useCallback( (row: StockInLineRow) => { if (deleteInFlightRef.current || isDeleting) return; + if ( + needsPoQcStockQtyRound( + row.purchaseOrderLineId, + row.status, + row.acceptedQty, + ) + ) { + alert("請先在換算庫存數量選擇向上或向下取整"); + return; + } const rowId = row.id as number; const isDraft = row._isNew || row.status === "draft"; @@ -280,6 +273,50 @@ function PoInputGrid({ }, [fetchPoDetail, handleDelete, isDeleting, itemDetail.id, itemDetail.purchaseOrderId, t], ); + + const handleRoundStockQty = useCallback( + (row: StockInLineRow, mode: StockQtyRoundMode) => { + if (roundInFlightRef.current) return; + const silId = row.id; + const itemId = row.itemId; + const purchaseQty = Number(row.purchaseAcceptedQty ?? 0); + if (!silId || !itemId || purchaseQty <= 0) return; + + const doRound = async () => { + if (roundInFlightRef.current) return; + roundInFlightRef.current = true; + setRoundingSilId(silId); + try { + const res = await updateStockInLine({ + id: silId, + itemId, + purchaseOrderLineId: row.purchaseOrderLineId, + acceptedQty: purchaseQty, + dnNo: row.dnNo, + productLotNo: row.productLotNo, + stockQtyRoundMode: mode, + stockQtyRoundSource: "CREATE", + }); + if (res) { + await fetchPoDetail( + String(itemDetail.purchaseOrderId), + true, + itemDetail.id, + ); + } + } catch (error) { + console.error("Failed to round stock qty:", error); + alert(t("Please choose a rounding method")); + } finally { + setRoundingSilId(null); + roundInFlightRef.current = false; + } + }; + + void doRound(); + }, + [fetchPoDetail, itemDetail.id, itemDetail.purchaseOrderId, t], + ); const closeQcModal = useCallback(() => { setQcOpen(false); @@ -434,6 +471,16 @@ function PoInputGrid({ const handleNewQC = useCallback( (id: GridRowId, params: any) => async() => { if (!params?.row) return; + if ( + needsPoQcStockQtyRound( + params.row.purchaseOrderLineId, + params.row.status, + params.row.acceptedQty, + ) + ) { + alert("請先在換算庫存數量選擇向上或向下取整"); + return; + } setRowModesModel((prev) => ({ ...prev, [id]: { mode: GridRowModes.View }, @@ -645,7 +692,8 @@ function PoInputGrid({ { field: "dnNo", headerName: t("dnNo"), - width: 92, + width: 100, + minWidth: 100, }, { field: "receiptDate", @@ -656,12 +704,15 @@ function PoInputGrid({ { field: "productLotNo", headerName: t("productLotNo"), - width: 100, + width: 110, + minWidth: 110, }, { field: "purchaseAcceptedQty", headerName: t("acceptedQty"), - width: 84, + width: PURCHASE_QTY_COLUMN_WIDTH, + minWidth: PURCHASE_QTY_COLUMN_WIDTH, + flex: 0, align: "right", headerAlign: "right", type: "number", @@ -673,7 +724,9 @@ function PoInputGrid({ { field: "uom", headerName: t("uom"), - width: 156, + width: UOM_COLUMN_WIDTH, + minWidth: UOM_COLUMN_WIDTH, + flex: 0, renderCell: () => { const text = itemDetail.uom?.udfudesc ?? "-"; return ( @@ -694,17 +747,23 @@ function PoInputGrid({ { field: "stockQty", headerName: t("Stock In Qty"), - width: 125, + width: STOCK_QTY_COLUMN_WIDTH, + minWidth: STOCK_QTY_COLUMN_WIDTH, + flex: 0, type: "number", + align: "left", + headerAlign: "left", renderCell: (params) => { - const stockQty = params.row.acceptedQty ?? 0; + const stockQty = Number(params.row.acceptedQty ?? 0); return decimalFormatter.format(stockQty); }, }, { field: "stockUom", headerName: t("Stock UoM"), - width: 124, + width: UOM_COLUMN_WIDTH, + minWidth: UOM_COLUMN_WIDTH, + flex: 0, renderCell: () => { const text = itemDetail.stockUom.stockUomDesc ?? "-"; return ( @@ -748,6 +807,8 @@ function PoInputGrid({ field: "actions", headerName: "操作", width: ACTIONS_COLUMN_WIDTH, + minWidth: ACTIONS_COLUMN_WIDTH, + flex: 0, sortable: false, filterable: false, disableColumnMenu: true, @@ -760,6 +821,11 @@ function PoInputGrid({ status === "rejected" || status === "partially_completed"; const canPrint = status === "received"; const canDelete = canDeleteStockInLine(data); + const needsStockQtyRound = needsPoQcStockQtyRound( + data.purchaseOrderLineId, + data.status, + data.acceptedQty, + ); return ( handleSoftDelete(data)} btnIsLoading={btnIsLoading} isDeleting={isDeleting} + needsStockQtyRound={needsStockQtyRound} + stockQty={Number(data.acceptedQty ?? 0)} + isRounding={roundingSilId === data.id} + onRound={(mode) => handleRoundStockQty(data, mode)} /> ); }, }, ]; - if (!isTablet) { - return baseColumns; - } - return baseColumns.map((col) => { - if (col.field === "actions") { - return { ...col, flex: 0, width: ACTIONS_COLUMN_WIDTH }; + const grow = COLUMN_GROW[col.field] ?? 0; + if (grow > 0) { + return { + ...col, + flex: grow, + minWidth: col.minWidth ?? col.width, + width: undefined, + }; } - const minWidth = col.width ?? 80; - return { ...col, flex: 1, minWidth, width: undefined }; + return { + ...col, + flex: 0, + width: col.width, + minWidth: col.minWidth ?? col.width, + }; }); }, [ t, - isTablet, itemDetail, handleNewQC, handleMailTemplateForStockInLine, printQrcode, handleSoftDelete, + handleRoundStockQty, + roundingSilId, btnIsLoading, isDeleting, sessionToken?.id, @@ -917,13 +994,7 @@ function PoInputGrid({ ); - const getRowHeight = useCallback( - (params: { model: StockInLineRow }) => { - const count = countActionButtonsForRow(params.model); - return getActionRowHeight(count); - }, - [], - ); + const getRowHeight = useCallback(() => STOCK_IN_ROW_HEIGHT, []); return ( <> @@ -945,7 +1016,7 @@ function PoInputGrid({ columnVisibilityModel={columnVisibilityModel} sx={{ width: "100%", - minWidth: isTablet ? undefined : STOCK_IN_GRID_MIN_WIDTH_DESKTOP, + minWidth: 0, "--DataGrid-overlayHeight": "100px", ".MuiDataGrid-row .MuiDataGrid-cell.hasError": { border: "1px solid", @@ -957,14 +1028,39 @@ function PoInputGrid({ }, "& .MuiDataGrid-cell.actions": { overflow: "visible", - alignItems: "flex-start", - py: 0.75, + alignItems: "center", + py: 0.5, lineHeight: "normal", }, + "& .MuiDataGrid-cell[data-field='stockQty']": { + overflow: "visible", + alignItems: "center", + justifyContent: "flex-start", + py: 0.5, + px: 0.75, + }, "& .MuiDataGrid-cell[data-field='actions']": { - py: 0.75, + py: 0.5, px: 1, }, + "& .MuiDataGrid-columnHeader[data-field='purchaseAcceptedQty']": { + whiteSpace: "normal", + lineHeight: 1.2, + px: 0.5, + }, + "& .MuiDataGrid-cell[data-field='purchaseAcceptedQty']": { + px: 0.5, + }, + "& .MuiDataGrid-columnHeader[data-field='uom']": { + whiteSpace: "nowrap", + px: 0.75, + }, + "& .MuiDataGrid-cell[data-field='uom']": { + px: 0.75, + }, + "& .MuiDataGrid-columnHeader[data-field='stockQty']": { + px: 0.75, + }, ...Object.fromEntries( COMPACT_STOCK_IN_CELL_FIELDS.flatMap((field) => [ [ diff --git a/src/components/PoDetail/QcStockInModal.tsx b/src/components/PoDetail/QcStockInModal.tsx index d817a603..013f4315 100644 --- a/src/components/PoDetail/QcStockInModal.tsx +++ b/src/components/PoDetail/QcStockInModal.tsx @@ -71,7 +71,7 @@ interface CommonProps extends Omit { 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.3 | 2026-09-10 */ const PoQcStockInModalVer2: React.FC = ({ open, onClose, diff --git a/src/components/PoDetail/StockInLineRowActions.tsx b/src/components/PoDetail/StockInLineRowActions.tsx index f422cba4..5d9d32e8 100644 --- a/src/components/PoDetail/StockInLineRowActions.tsx +++ b/src/components/PoDetail/StockInLineRowActions.tsx @@ -2,6 +2,7 @@ import { Box, Button } from "@mui/material"; import { useTranslation } from "react-i18next"; +import { roundStockQty, StockQtyRoundMode } from "./stockQtyRound"; export type StockInLineActionStyle = { label: string; @@ -19,8 +20,13 @@ type Props = { onDelete: () => void; btnIsLoading: boolean; isDeleting: boolean; + needsStockQtyRound?: boolean; + stockQty?: number; + isRounding?: boolean; + onRound?: (mode: StockQtyRoundMode) => void; }; +/** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.3 | 2026-09-10 */ export default function StockInLineRowActions({ btnSx, onPrimaryClick, @@ -32,83 +38,119 @@ export default function StockInLineRowActions({ onDelete, btnIsLoading, isDeleting, + needsStockQtyRound = false, + stockQty = 0, + isRounding = false, + onRound, }: Props) { const { t } = useTranslation("purchaseOrder"); const buttonSx = { whiteSpace: "nowrap" as const, - fontSize: 14, + flexShrink: 0, + width: 176, + minWidth: 176, + maxWidth: 176, px: 1.5, - py: 0.75, - minHeight: 34, - width: "100%", + py: 1, + minHeight: 44, + height: 44, + fontSize: 16, + fontWeight: 700, justifyContent: "center", + boxSizing: "border-box" as const, }; return ( e.stopPropagation()} > - - {canEmail && ( - - )} - {canPrint && ( - - )} - {canDelete && ( - + {needsStockQtyRound ? ( + <> + + + + ) : ( + <> + + {canEmail && ( + + )} + {canPrint && ( + + )} + {canDelete && ( + + )} + )} ); diff --git a/src/components/PoDetail/stockQtyRound.ts b/src/components/PoDetail/stockQtyRound.ts index ae2c6bf0..148193c0 100644 --- a/src/components/PoDetail/stockQtyRound.ts +++ b/src/components/PoDetail/stockQtyRound.ts @@ -7,6 +7,7 @@ export type StockQtyRoundChoice = { after: number; }; +/** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.3 | 2026-09-10 */ export function previewPoBatchStockQty( orderM18Qty: number, orderStockQty: number, @@ -23,7 +24,10 @@ export function isNotIntegerQty(qty: number): boolean { return Math.abs(qty - Math.round(qty)) > 1e-9; } -/** PO 收貨/品檢:pending 或 escalated 且庫存數量非整數時需選擇 CEILING / FLOOR */ +/** + * FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.3 | 2026-09-10 + * PO 收貨/品檢:pending 或 escalated 且庫存數量非整數時需選擇 CEILING / FLOOR + */ export function needsPoQcStockQtyRound( purchaseOrderLineId: number | null | undefined, status: string | null | undefined, @@ -35,6 +39,7 @@ export function needsPoQcStockQtyRound( return isNotIntegerQty(Number(acceptedQty ?? 0)); } +/** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.3 | 2026-09-10 */ export function roundStockQty(before: number, mode: StockQtyRoundMode): number { if (mode === "CEILING") return Math.ceil(before); return Math.floor(before); diff --git a/src/components/PoSearch/PoSearch.tsx b/src/components/PoSearch/PoSearch.tsx index a4465dc0..45294d56 100644 --- a/src/components/PoSearch/PoSearch.tsx +++ b/src/components/PoSearch/PoSearch.tsx @@ -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 = ({ po, totalCount: initTotalCount, @@ -150,7 +151,7 @@ const PoSearch: React.FC = ({ return "N/A" } const items = value.split(",") - return items.map((item) => {item}) + return items.map((item, index) => {item}) }, []) const columns = useMemo[]>( @@ -279,8 +280,8 @@ const PoSearch: React.FC = ({ pagingController: Record, filterArgs: Record, ) => { - console.log(pagingController); - console.log(filterArgs); + // console.log(pagingController); + //console.log(filterArgs); const params = { ...pagingController, ...filterArgs, @@ -390,7 +391,7 @@ const PoSearch: React.FC = ({ ); useEffect(() => { - console.log(filteredPo) + //console.log(filteredPo) }, [filteredPo]) useEffect(() => { @@ -415,7 +416,7 @@ const PoSearch: React.FC = ({ 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). diff --git a/src/components/Qc/QcStockInModal.tsx b/src/components/Qc/QcStockInModal.tsx index 0161f4f9..e74e1cb2 100644 --- a/src/components/Qc/QcStockInModal.tsx +++ b/src/components/Qc/QcStockInModal.tsx @@ -23,7 +23,7 @@ import QcComponent from "./QcComponent"; import PutAwayForm from "../PoDetail/PutAwayForm"; import { GridRowModes, GridRowSelectionModel, useGridApiRef } from "@mui/x-data-grid"; import {msg, submitDialogWithWarning} from "../Swal/CustomAlerts"; -import { needsPoQcStockQtyRound, roundStockQty } from "../PoDetail/stockQtyRound"; +import { needsPoQcStockQtyRound } from "../PoDetail/stockQtyRound"; import { INPUT_DATE_FORMAT, arrayToDateString, dayjsToDateTimeString } from "@/app/utils/formatUtil"; import dayjs from "dayjs"; import { fetchPoQrcode } from "@/app/api/pdf/actions"; @@ -73,7 +73,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.3 | 2026-09-10 */ const QcStockInModal: React.FC = ({ open, onClose, @@ -243,7 +243,6 @@ const QcStockInModal: React.FC = ({ ...defaultNewValue, }, }); - const stockQtyRoundMode = formProps.watch("stockQtyRoundMode"); const qcDecision = formProps.watch("qcDecision"); const roundChoiceRequired = useMemo(() => { const needsRound = needsPoQcStockQtyRound( @@ -252,19 +251,13 @@ const QcStockInModal: React.FC = ({ stockInLineInfo?.acceptedQty, ); const willAcceptStock = Boolean(skipQc) || qcDecision == 1; - return ( - needsRound && - willAcceptStock && - stockQtyRoundMode !== "CEILING" && - stockQtyRoundMode !== "FLOOR" - ); + return needsRound && willAcceptStock; }, [ stockInLineInfo?.purchaseOrderLineId, stockInLineInfo?.status, stockInLineInfo?.acceptedQty, skipQc, qcDecision, - stockQtyRoundMode, ]); const closeWithResult = useCallback( @@ -444,23 +437,9 @@ const QcStockInModal: React.FC = ({ storedStockQty, ); const willAcceptStock = Boolean(skipQc) || qcAcceptLocal; - let roundPayload: - | { - stockQtyRoundMode: "CEILING" | "FLOOR"; - stockQtyRoundSource: "QC"; - } - | undefined; if (willAcceptStock && needsStockQtyRound) { - const selectedMode = data.stockQtyRoundMode; - if (selectedMode !== "CEILING" && selectedMode !== "FLOOR") { - alert(t("Please choose a rounding method")); - return; - } - acceptQtyLocal = roundStockQty(storedStockQty, selectedMode); - roundPayload = { - stockQtyRoundMode: selectedMode, - stockQtyRoundSource: "QC", - }; + alert("請先在換算庫存數量選擇向上或向下取整"); + return; } const isJobOrderSource = Boolean(stockInLineInfo?.jobOrderId) || printSource === "productionProcess"; @@ -476,7 +455,6 @@ const QcStockInModal: React.FC = ({ // For Job Order QC, allow updating received qty beyond demand/accepted. // Backend uses request.acceptedQty in QC flow, so we must send it explicitly. acceptedQty: (qcAcceptLocal && isJobOrderSource) ? (acceptQtyLocal ? acceptQtyLocal : 0) : stockInLineInfo?.acceptedQty, - ...roundPayload, // qcResult: itemDetail.status != "escalated" ? qcResults.map(item => ({ qcResult: qcResultsLocal.map(item => ({ // id: item.id, diff --git a/src/components/StockIn/StockInForm.tsx b/src/components/StockIn/StockInForm.tsx index 632b0ae7..3de51d0d 100644 --- a/src/components/StockIn/StockInForm.tsx +++ b/src/components/StockIn/StockInForm.tsx @@ -10,24 +10,17 @@ import { CardContent, Grid, InputAdornment, - Stack, TextField, Tooltip, - Typography, } from "@mui/material"; import { Controller, useFormContext } from "react-hook-form"; import { useTranslation } from "react-i18next"; import StyledDataGrid from "../StyledDataGrid"; -import { useCallback, useEffect, useState, useMemo } from "react"; +import { useCallback, useEffect, useState } from "react"; import { useGridApiRef, } from "@mui/x-data-grid"; import { StockInLine } from "@/app/api/stockIn"; -import { - needsPoQcStockQtyRound, - roundStockQty, - StockQtyRoundMode, -} from "@/components/PoDetail/stockQtyRound"; import { DatePicker, LocalizationProvider } from "@mui/x-date-pickers"; import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; import { dayjsToDateString, INPUT_DATE_FORMAT, OUTPUT_DATE_FORMAT } from "@/app/utils/formatUtil"; @@ -119,36 +112,8 @@ const StockInForm: React.FC = ({ const productionDate = watch("productionDate"); const expiryDate = watch("expiryDate"); const uom = watch("uom"); - const stockQtyRoundMode = watch("stockQtyRoundMode"); const displayedAcceptedQty = watch("acceptedQty"); const originalAcceptedQty = Number(itemDetail.acceptedQty ?? 0); - const showStockQtyRoundButtons = - !putawayMode && - !disabled && - needsPoQcStockQtyRound( - itemDetail.purchaseOrderLineId, - itemDetail.status, - originalAcceptedQty, - ); - const stockUomLabel = itemDetail.stockUomDesc ? ` ${itemDetail.stockUomDesc}` : ""; - const roundUpQty = roundStockQty(originalAcceptedQty, "CEILING"); - const roundDownQty = roundStockQty(originalAcceptedQty, "FLOOR"); - - const selectStockQtyRound = useCallback( - (mode: StockQtyRoundMode) => { - const after = roundStockQty(Number(itemDetail.acceptedQty ?? 0), mode); - setValue("stockQtyRoundMode", mode, { shouldDirty: true, shouldTouch: true }); - setValue("acceptedQty", after, { shouldDirty: true, shouldTouch: true }); - (setValue as (name: string, value: unknown, options?: object) => void)( - "acceptQty", - after, - { shouldDirty: true, shouldTouch: true }, - ); - const accInput = document.getElementById("accQty") as HTMLInputElement | null; - if (accInput) accInput.value = String(after); - }, - [itemDetail.acceptedQty, setValue], - ); const [openModal, setOpenModal] = useState(false); const [openExpDatePicker, setOpenExpDatePicker] = useState(false); @@ -449,33 +414,6 @@ const StockInForm: React.FC = ({ })} /> - {showStockQtyRoundButtons && ( - - - - {t("Please choose a rounding method")} - - - - - - - - )} )} {/* diff --git a/src/components/StockIssue/ExpiryHandleTab.tsx b/src/components/StockIssue/ExpiryHandleTab.tsx index a582c4e0..fbb12ea2 100644 --- a/src/components/StockIssue/ExpiryHandleTab.tsx +++ b/src/components/StockIssue/ExpiryHandleTab.tsx @@ -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([]); + const [lastFilters, setLastFilters] = useState({ + daysAhead: DEFAULT_DAYS_AHEAD, + }); + const [hasSearched, setHasSearched] = useState(false); + const [resultTab, setResultTab] = useState("expired"); const [submittingIds, setSubmittingIds] = useState>(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>(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[] = 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[]>( () => [ @@ -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) => ( - - ), + renderCell: (item) => { + const canHandle = canHandleExpiryItem(item); + const disposing = submittingIds.has(item.id); + const button = ( + + ); + if (canHandle) return button; + return ( + + {button} + + ); + }, }, ], [t, handleSubmitSingle, submittingIds, currentUserId], @@ -179,49 +297,238 @@ const ExpiryHandleTab: React.FC = () => { const handleSearch = useCallback( async (query: Record) => { + 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) => { + 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 ( - + + + + + + + + {resultTab === "upcoming" && ( + + {t("Days ahead")} + + + )} + + + + + - items={pagedItems} + items={tabItems} columns={expiryColumns} pagingController={paging} setPagingController={setPaging} - totalCount={expiryItems.length} + totalCount={tabItems.length} /> + { + if (!batchSubmitting) setBatchConfirmOpen(false); + }} + fullWidth + maxWidth="xs" + > + {t("Confirm batch dispose")} + + + {t("Confirm batch dispose message", { count: handleableIds.length })} + + + + + + + ); }; diff --git a/src/components/StockIssue/StockIssueRecordTab.tsx b/src/components/StockIssue/StockIssueRecordTab.tsx index 145441a4..461b2a39 100644 --- a/src/components/StockIssue/StockIssueRecordTab.tsx +++ b/src/components/StockIssue/StockIssueRecordTab.tsx @@ -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 = ({ kind }) => { const { t } = useTranslation("stockIssue"); const [items, setItems] = useState([]); @@ -40,27 +43,47 @@ const StockIssueRecordTab: React.FC = ({ kind }) => { lotNo: "", startDate: "", endDate: "", + handledStartDate: "", + handledEndDate: "", }); const hasSearchedRef = useRef(false); const prevPagingRef = useRef(paging); const searchFields: StockIssueSearchField[] = 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[] = [ + { 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 = ({ 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 = ({ kind }) => { }, { name: "uomDesc", - label: t("UOM"), + label: t("UoM"), renderCell: (row) => ( <> {row.uomDesc ?? ""} @@ -179,8 +204,10 @@ const StockIssueRecordTab: React.FC = ({ 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]); diff --git a/src/components/StockIssue/StockIssueSearchPanel.tsx b/src/components/StockIssue/StockIssueSearchPanel.tsx index c0a29983..7c888291 100644 --- a/src/components/StockIssue/StockIssueSearchPanel.tsx +++ b/src/components/StockIssue/StockIssueSearchPanel.tsx @@ -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 { name: K; @@ -36,6 +36,9 @@ export interface StockIssueSearchField { 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 { @@ -46,6 +49,7 @@ interface Props { disabled?: boolean; } +/** FP-MTMS Version Checklist | Functions Ref. No. 74 | v1.0.2 | 2026-09-07 */ function StockIssueSearchPanel({ fields, onSearch, @@ -60,7 +64,8 @@ function StockIssueSearchPanel({ return fields.reduce( (acc, field) => { acc[field.name] = - field.type === "select" ? "All" : ""; + field.defaultValue ?? + (field.type === "select" ? "All" : ""); return acc; }, {} as Record, @@ -127,6 +132,21 @@ function StockIssueSearchPanel({ disabled={disabled} /> )} + {field.type === "number" && ( + + )} {field.type === "select" && ( {field.label} diff --git a/src/i18n/en/do.json b/src/i18n/en/do.json index b21c9bb6..ecae8209 100644 --- a/src/i18n/en/do.json +++ b/src/i18n/en/do.json @@ -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", diff --git a/src/i18n/en/purchaseOrder.json b/src/i18n/en/purchaseOrder.json index 48d1d8ba..dd920e44 100644 --- a/src/i18n/en/purchaseOrder.json +++ b/src/i18n/en/purchaseOrder.json @@ -53,6 +53,7 @@ "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?", "Stock qty is not an integer": "Stock quantity is not an integer", "Converted stock qty is": "Converted stock qty is", "Choose rounding method": ". Please choose a rounding method:", diff --git a/src/i18n/en/stockIssue.json b/src/i18n/en/stockIssue.json index 5e6583f8..45590bce 100644 --- a/src/i18n/en/stockIssue.json +++ b/src/i18n/en/stockIssue.json @@ -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", diff --git a/src/i18n/zh/do.json b/src/i18n/zh/do.json index e498ca30..a108e5fb 100644 --- a/src/i18n/zh/do.json +++ b/src/i18n/zh/do.json @@ -221,5 +221,6 @@ "Replenishment demo note": "此為前端假資料回應;正式環境將呼叫後端 API。", "Search Delivery Order": "搜尋送貨單", "DO Replenishment": "送貨單補貨", - "Error": "錯誤" + "Error": "錯誤", + "User session not found": "找不到登入使用者,請重新登入。" } diff --git a/src/i18n/zh/purchaseOrder.json b/src/i18n/zh/purchaseOrder.json index d53c36e0..0cac29af 100644 --- a/src/i18n/zh/purchaseOrder.json +++ b/src/i18n/zh/purchaseOrder.json @@ -53,6 +53,7 @@ "putawayQty": "上架數量", "Confirm submit": "確定提交", "This batch quantity exceeds order quantity. Do you still want to submit?": "本批收貨數量超出訂單數量。仍要提交嗎?", + "qtyExceedsOrderConfirm": "累計收貨數量超出訂單數量。仍要提交嗎?", "Stock qty is not an integer": "換算庫存數量不是整數", "Converted stock qty is": "換算庫存數量為", "Choose rounding method": "。請選擇進位方式:", diff --git a/src/i18n/zh/stockIssue.json b/src/i18n/zh/stockIssue.json index ca778989..f9bec61e 100644 --- a/src/i18n/zh/stockIssue.json +++ b/src/i18n/zh/stockIssue.json @@ -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": "可用",