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 1b1fb889..36bffeb5 100644 --- a/src/components/PoDetail/PoDetail.tsx +++ b/src/components/PoDetail/PoDetail.tsx @@ -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 = ({ po, warehouse, printerCombo }) => { const cameras = useContext(CameraContext); const { data: session } = useSession(); @@ -678,11 +678,29 @@ const PoDetail: React.FC = ({ 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 = ({ po, warehouse, printerCombo }) => {