# Conflicts: # src/components/PoDetail/PoDetail.tsx # src/i18n/en/purchaseOrder.json # src/i18n/zh/purchaseOrder.jsonproduction
| @@ -16,11 +16,11 @@ type Props = {} & SearchParams; | |||||
| const PoEdit: React.FC<Props> = async ({ searchParams }) => { | const PoEdit: React.FC<Props> = async ({ searchParams }) => { | ||||
| const type = "purchaseOrder"; | const type = "purchaseOrder"; | ||||
| const { t } = await getServerI18n(type); | const { t } = await getServerI18n(type); | ||||
| console.log(searchParams["id"]); | |||||
| //console.log(searchParams["id"]); | |||||
| const id = isString(searchParams["id"]) | const id = isString(searchParams["id"]) | ||||
| ? parseInt(searchParams["id"]) | ? parseInt(searchParams["id"]) | ||||
| : undefined; | : undefined; | ||||
| console.log(id); | |||||
| //console.log(id); | |||||
| if (!id) { | if (!id) { | ||||
| notFound(); | notFound(); | ||||
| } | } | ||||
| @@ -172,7 +172,7 @@ async function fetchInventoriesLatestImpl(data: SearchInventory) { | |||||
| export const fetchInventories = cache(fetchInventoriesImpl); | 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). | * Inventory search page: latest inventory row per item (no baseUnit/uomId filter). | ||||
| */ | */ | ||||
| export const fetchInventoriesLatest = cache(fetchInventoriesLatestImpl); | export const fetchInventoriesLatest = cache(fetchInventoriesLatestImpl); | ||||
| @@ -138,6 +138,28 @@ export const fetchItemsWithDetails = cache(async (searchParams?: Record<string, | |||||
| } | } | ||||
| }); | }); | ||||
| /** Paged item lookup for Inventory Search (code / name). */ | |||||
| export const fetchItemsByPage = cache(async (queryParams?: Record<string, string | number>) => { | |||||
| const searchParams = new URLSearchParams(); | |||||
| if (queryParams) { | |||||
| Object.entries(queryParams).forEach(([key, value]) => { | |||||
| if (value !== undefined && value !== null && `${value}` !== "") { | |||||
| searchParams.set(key, String(value)); | |||||
| } | |||||
| }); | |||||
| } | |||||
| const queryString = searchParams.toString(); | |||||
| return serverFetchJson<RecordsRes<ItemsResult>>( | |||||
| queryString | |||||
| ? `${BASE_API_URL}/items/getRecordByPage?${queryString}` | |||||
| : `${BASE_API_URL}/items/getRecordByPage`, | |||||
| { | |||||
| method: "GET", | |||||
| next: { tags: ["items"] }, | |||||
| }, | |||||
| ); | |||||
| }); | |||||
| export const fetchAllItemsInClient = cache(async () => { | export const fetchAllItemsInClient = cache(async () => { | ||||
| return serverFetchJson<ItemCombo[]>(`${BASE_API_URL}/items/consumables`, { | return serverFetchJson<ItemCombo[]>(`${BASE_API_URL}/items/consumables`, { | ||||
| next: { tags: ["items"] }, | next: { tags: ["items"] }, | ||||
| @@ -16,6 +16,7 @@ export interface StockAdjustmentLineRequest { | |||||
| expiryDate: string; | expiryDate: string; | ||||
| warehouseId: number; | warehouseId: number; | ||||
| uom?: string | null; | uom?: string | null; | ||||
| remarks?: string | null; | |||||
| } | } | ||||
| export interface StockAdjustmentRequest { | export interface StockAdjustmentRequest { | ||||
| @@ -33,6 +34,19 @@ export interface MessageResponse { | |||||
| errorPosition: string | null; | errorPosition: string | null; | ||||
| } | } | ||||
| export interface StockAdjustmentRemarksResponse { | |||||
| lotNo: string | null; | |||||
| remarks: string; | |||||
| } | |||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 3 | v1.0.7 | 2026-09-08 */ | |||||
| export const fetchLatestAdjustmentRemarks = async (itemId: number) => { | |||||
| return serverFetchJson<StockAdjustmentRemarksResponse[]>( | |||||
| `${BASE_API_URL}/stockAdjustment/latestRemarks?itemId=${itemId}`, | |||||
| { method: "GET" }, | |||||
| ); | |||||
| }; | |||||
| export const submitStockAdjustment = async (data: StockAdjustmentRequest) => { | export const submitStockAdjustment = async (data: StockAdjustmentRequest) => { | ||||
| const result = await serverFetchJson<MessageResponse>( | const result = await serverFetchJson<MessageResponse>( | ||||
| `${BASE_API_URL}/stockAdjustment/submit`, | `${BASE_API_URL}/stockAdjustment/submit`, | ||||
| @@ -17,12 +17,17 @@ export interface ExpiryItemResult { | |||||
| storeLocation: string | null; | storeLocation: string | null; | ||||
| expiryDate: string | null; | expiryDate: string | null; | ||||
| remainingQty: number; | remainingQty: number; | ||||
| uomDesc?: string | null; | |||||
| /** True when expiryDate is today or earlier. */ | |||||
| canHandle?: boolean; | |||||
| } | } | ||||
| export interface ExpiryItemFilter { | export interface ExpiryItemFilter { | ||||
| expiryDate?: string; | |||||
| itemCode?: string; | itemCode?: string; | ||||
| itemName?: string; | itemName?: string; | ||||
| lotNo?: string; | |||||
| /** Inclusive lookahead from today; default 7. */ | |||||
| daysAhead?: number; | |||||
| } | } | ||||
| export interface HandleBadItemRequest { | export interface HandleBadItemRequest { | ||||
| @@ -54,6 +59,8 @@ export interface StockIssueHandleRecord { | |||||
| export interface SearchStockIssueRecordParams { | export interface SearchStockIssueRecordParams { | ||||
| startDate?: string; | startDate?: string; | ||||
| endDate?: string; | endDate?: string; | ||||
| handledStartDate?: string; | |||||
| handledEndDate?: string; | |||||
| itemCode?: string; | itemCode?: string; | ||||
| itemName?: string; | itemName?: string; | ||||
| lotNo?: string; | lotNo?: string; | ||||
| @@ -61,11 +68,13 @@ export interface SearchStockIssueRecordParams { | |||||
| pageSize?: number; | pageSize?: number; | ||||
| } | } | ||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 74 | v1.0.2 | 2026-09-07 */ | |||||
| export const fetchExpiryItemList = cache(async (filters?: ExpiryItemFilter) => { | export const fetchExpiryItemList = cache(async (filters?: ExpiryItemFilter) => { | ||||
| const params = new URLSearchParams(); | const params = new URLSearchParams(); | ||||
| if (filters?.expiryDate) params.set("expiryDate", filters.expiryDate); | |||||
| if (filters?.itemCode) params.set("itemCode", filters.itemCode); | if (filters?.itemCode) params.set("itemCode", filters.itemCode); | ||||
| if (filters?.itemName) params.set("itemName", filters.itemName); | 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 queryString = params.toString(); | ||||
| const url = `${BASE_API_URL}/pickExecution/issues/expiryItem${queryString ? `?${queryString}` : ""}`; | const url = `${BASE_API_URL}/pickExecution/issues/expiryItem${queryString ? `?${queryString}` : ""}`; | ||||
| return serverFetchJson<ExpiryItemResult[]>(url, { | return serverFetchJson<ExpiryItemResult[]>(url, { | ||||
| @@ -107,6 +116,8 @@ export async function fetchExpiryItemRecords(params: SearchStockIssueRecordParam | |||||
| const qs = new URLSearchParams(); | const qs = new URLSearchParams(); | ||||
| if (params.startDate) qs.set("startDate", params.startDate); | if (params.startDate) qs.set("startDate", params.startDate); | ||||
| if (params.endDate) qs.set("endDate", params.endDate); | 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.itemCode) qs.set("itemCode", params.itemCode); | ||||
| if (params.itemName) qs.set("itemName", params.itemName); | if (params.itemName) qs.set("itemName", params.itemName); | ||||
| if (params.lotNo) qs.set("lotNo", params.lotNo); | if (params.lotNo) qs.set("lotNo", params.lotNo); | ||||
| @@ -0,0 +1,67 @@ | |||||
| "use client"; | |||||
| import { NEXT_PUBLIC_API_URL } from "@/config/api"; | |||||
| import { clientAuthFetch } from "@/app/utils/clientAuthFetch"; | |||||
| import type { ExpiryItemFilter } from "@/app/api/stockIssue/actions"; | |||||
| /** Result tab currently shown; backend may filter the workbook by this bucket. */ | |||||
| export type ExpiryExportBucket = "expired" | "today" | "upcoming"; | |||||
| export interface ExportExpiryItemExcelParams extends ExpiryItemFilter { | |||||
| bucket?: ExpiryExportBucket; | |||||
| } | |||||
| /** | |||||
| * FP-MTMS Version Checklist | Functions Ref. No. 74 | v1.0.2 | 2026-09-07 | |||||
| * GET /pickExecution/issues/expiryItem/excel | |||||
| * Query: itemCode, itemName, lotNo, daysAhead, bucket (expired|today|upcoming; omit for all categories) | |||||
| * Response: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet | |||||
| * Optional Content-Disposition filename. | |||||
| */ | |||||
| export async function exportExpiryItemExcel( | |||||
| filters: ExportExpiryItemExcelParams, | |||||
| ): Promise<void> { | |||||
| const params = new URLSearchParams(); | |||||
| if (filters.itemCode) params.set("itemCode", filters.itemCode); | |||||
| if (filters.itemName) params.set("itemName", filters.itemName); | |||||
| if (filters.lotNo) params.set("lotNo", filters.lotNo); | |||||
| if (filters.daysAhead != null) params.set("daysAhead", String(filters.daysAhead)); | |||||
| if (filters.bucket) params.set("bucket", filters.bucket); | |||||
| const queryString = params.toString(); | |||||
| const url = `${NEXT_PUBLIC_API_URL}/pickExecution/issues/expiryItem/excel${queryString ? `?${queryString}` : ""}`; | |||||
| const response = await clientAuthFetch(url, { | |||||
| method: "GET", | |||||
| headers: { | |||||
| Accept: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", | |||||
| }, | |||||
| }); | |||||
| if (response.status === 401 || response.status === 403) { | |||||
| throw new Error("Unauthorized"); | |||||
| } | |||||
| if (!response.ok) { | |||||
| throw new Error(`HTTP error! status: ${response.status}`); | |||||
| } | |||||
| const blob = await response.blob(); | |||||
| const downloadUrl = window.URL.createObjectURL(blob); | |||||
| const link = document.createElement("a"); | |||||
| link.href = downloadUrl; | |||||
| const contentDisposition = response.headers.get("Content-Disposition"); | |||||
| let fileName = "expiry-items.xlsx"; | |||||
| if (contentDisposition?.includes("filename=")) { | |||||
| fileName = contentDisposition | |||||
| .split("filename=")[1] | |||||
| .split(";")[0] | |||||
| .replace(/"/g, ""); | |||||
| } | |||||
| link.setAttribute("download", fileName); | |||||
| document.body.appendChild(link); | |||||
| link.click(); | |||||
| link.remove(); | |||||
| window.URL.revokeObjectURL(downloadUrl); | |||||
| } | |||||
| @@ -55,6 +55,7 @@ import { | |||||
| import dayjs from "dayjs"; | import dayjs from "dayjs"; | ||||
| import { useSession } from "next-auth/react"; | import { useSession } from "next-auth/react"; | ||||
| import { SessionWithTokens } from "@/config/authConfig"; | import { SessionWithTokens } from "@/config/authConfig"; | ||||
| import { AUTH, hasAbility } from "@/authorities"; | |||||
| import { NEXT_PUBLIC_API_URL } from "@/config/api"; | import { NEXT_PUBLIC_API_URL } from "@/config/api"; | ||||
| import { clientAuthFetch } from "@/app/utils/clientAuthFetch"; | import { clientAuthFetch } from "@/app/utils/clientAuthFetch"; | ||||
| @@ -77,28 +78,6 @@ const REFRESH_MS = 60 * 1000; | |||||
| const PRINTER_CHECK_MS = 60 * 1000; | const PRINTER_CHECK_MS = 60 * 1000; | ||||
| const PRINTER_RETRY_MS = 30 * 1000; | const PRINTER_RETRY_MS = 30 * 1000; | ||||
| const SETTINGS_KEY = "bagPrint_settings"; | 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 = { | const DEFAULT_SETTINGS = { | ||||
| dabag_ip: "", | dabag_ip: "", | ||||
| @@ -221,8 +200,8 @@ function sortExpiryRows( | |||||
| const BagPrintSearch: React.FC = () => { | const BagPrintSearch: React.FC = () => { | ||||
| const { data: session } = useSession() as { data: SessionWithTokens | null }; | 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 [planDate, setPlanDate] = useState(() => dayjs().format("YYYY-MM-DD")); | ||||
| const [jobOrders, setJobOrders] = useState<JobOrderListItem[]>([]); | const [jobOrders, setJobOrders] = useState<JobOrderListItem[]>([]); | ||||
| const [loading, setLoading] = useState(true); | const [loading, setLoading] = useState(true); | ||||
| @@ -26,6 +26,7 @@ type Props = { | |||||
| workbenchRelease?: boolean; | workbenchRelease?: boolean; | ||||
| } | } | ||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 78 | v1.0.0 | 2026-09-08 */ | |||||
| const DoDetail: React.FC<Props> = ({ | const DoDetail: React.FC<Props> = ({ | ||||
| defaultValues, | defaultValues, | ||||
| id, | id, | ||||
| @@ -57,22 +58,13 @@ const DoDetail: React.FC<Props> = ({ | |||||
| setSuccessMessage("") | setSuccessMessage("") | ||||
| if (id) { | 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({ | const response = await startWorkbenchBatchReleaseAsyncSingleV2({ | ||||
| doId: id, | doId: id, | ||||
| userId: currentUserId ?? 0 | |||||
| userId: currentUserId | |||||
| }) | }) | ||||
| if (response?.code === "STARTED") { | if (response?.code === "STARTED") { | ||||
| setSuccessMessage(t("DO released successfully! Pick orders created.")); | setSuccessMessage(t("DO released successfully! Pick orders created.")); | ||||
| @@ -91,7 +83,7 @@ const DoDetail: React.FC<Props> = ({ | |||||
| } finally { | } finally { | ||||
| setIsUploading(false) | setIsUploading(false) | ||||
| } | } | ||||
| }, [id, formProps, t, setIsUploading, session]) // Add session to dependencies | |||||
| }, [id, formProps, t, setIsUploading, session, currentUserId, router]) | |||||
| // UPDATE STORE-BASED ASSIGNMENT HANDLERS | // UPDATE STORE-BASED ASSIGNMENT HANDLERS | ||||
| const handleAssignByStore = useCallback(async (storeId: string) => { | const handleAssignByStore = useCallback(async (storeId: string) => { | ||||
| @@ -81,6 +81,7 @@ function isTruckLaneSearchMissingEta(truckLanceCode: string, estimatedArrivalDat | |||||
| return truckLanceCode.trim() !== "" && estimatedArrivalDate.trim() === ""; | return truckLanceCode.trim() !== "" && estimatedArrivalDate.trim() === ""; | ||||
| } | } | ||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 77 | v1.0.0 | 2026-09-08 */ | |||||
| const DoSearch: React.FC<Props> = ({ filterArgs, searchQuery, onDeliveryOrderSearch }) => { | const DoSearch: React.FC<Props> = ({ filterArgs, searchQuery, onDeliveryOrderSearch }) => { | ||||
| const apiRef = useGridApiRef(); | const apiRef = useGridApiRef(); | ||||
| @@ -509,6 +510,16 @@ const DoSearch: React.FC<Props> = ({ filterArgs, searchQuery, onDeliveryOrderSea | |||||
| const handleBatchRelease = useCallback(async (isWorkbench: boolean) => { | const handleBatchRelease = useCallback(async (isWorkbench: boolean) => { | ||||
| try { | 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 tabFilter = resolveTabFilter(activeTab); | ||||
| const tabTruckKeyword = tabFilter.forceTruckKeyword ?? ""; | const tabTruckKeyword = tabFilter.forceTruckKeyword ?? ""; | ||||
| const effectiveTruckLanceCode = tabTruckKeyword || currentSearchParams.truckLanceCode || ""; | const effectiveTruckLanceCode = tabTruckKeyword || currentSearchParams.truckLanceCode || ""; | ||||
| @@ -659,12 +670,12 @@ const DoSearch: React.FC<Props> = ({ filterArgs, searchQuery, onDeliveryOrderSea | |||||
| if(isWorkbench){ | if(isWorkbench){ | ||||
| startRes = await startWorkbenchBatchReleaseAsyncV2({ | startRes = await startWorkbenchBatchReleaseAsyncV2({ | ||||
| ids: idsToRelease, | ids: idsToRelease, | ||||
| userId: currentUserId ?? 1, | |||||
| userId: currentUserId, | |||||
| mergeExtraIntoLaneTicket, | mergeExtraIntoLaneTicket, | ||||
| }); | }); | ||||
| } | } | ||||
| else{ | else{ | ||||
| startRes = await startBatchReleaseAsync({ ids: idsToRelease, userId: currentUserId ?? 1 }); | |||||
| startRes = await startBatchReleaseAsync({ ids: idsToRelease, userId: currentUserId }); | |||||
| } | } | ||||
| //await startBatchReleaseAsync({ ids: idsToRelease, userId: currentUserId ?? 1 }); | //await startBatchReleaseAsync({ ids: idsToRelease, userId: currentUserId ?? 1 }); | ||||
| const jobId = startRes?.entity?.jobId; | const jobId = startRes?.entity?.jobId; | ||||
| @@ -61,6 +61,7 @@ function isTruckLaneSearchMissingEta(truckLanceCode: string, estimatedArrivalDat | |||||
| return truckLanceCode.trim() !== "" && estimatedArrivalDate.trim() === ""; | return truckLanceCode.trim() !== "" && estimatedArrivalDate.trim() === ""; | ||||
| } | } | ||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 78 | v1.0.0 | 2026-09-08 */ | |||||
| const DoSearchWorkbench: React.FC<Props> = ({ | const DoSearchWorkbench: React.FC<Props> = ({ | ||||
| filterArgs, | filterArgs, | ||||
| searchQuery, | searchQuery, | ||||
| @@ -538,6 +539,15 @@ const handleSearch = useCallback(async (query: SearchBoxInputs) => { | |||||
| const handleBatchRelease = useCallback(async () => { | const handleBatchRelease = useCallback(async () => { | ||||
| try { | try { | ||||
| if (!currentUserId) { | |||||
| await Swal.fire({ | |||||
| icon: "error", | |||||
| title: t("Error"), | |||||
| text: t("User session not found"), | |||||
| confirmButtonText: t("OK"), | |||||
| }); | |||||
| return; | |||||
| } | |||||
| if ( | if ( | ||||
| isTruckLaneSearchMissingEta( | isTruckLaneSearchMissingEta( | ||||
| currentSearchParams.truckLanceCode ?? "", | currentSearchParams.truckLanceCode ?? "", | ||||
| @@ -657,7 +667,7 @@ const handleSearch = useCallback(async (query: SearchBoxInputs) => { | |||||
| (result.value as { mergeExtraIntoLaneTicket?: boolean } | undefined)?.mergeExtraIntoLaneTicket ?? false; | (result.value as { mergeExtraIntoLaneTicket?: boolean } | undefined)?.mergeExtraIntoLaneTicket ?? false; | ||||
| const startRes = await startWorkbenchBatchReleaseAsyncV2({ | const startRes = await startWorkbenchBatchReleaseAsyncV2({ | ||||
| ids: idsToRelease, | ids: idsToRelease, | ||||
| userId: currentUserId ?? 1, | |||||
| userId: currentUserId, | |||||
| mergeExtraIntoLaneTicket, | mergeExtraIntoLaneTicket, | ||||
| }); | }); | ||||
| const startEntity = startRes?.entity as { jobId?: string } | undefined; | const startEntity = startRes?.entity as { jobId?: string } | undefined; | ||||
| @@ -32,9 +32,9 @@ import { DatePicker } from "@mui/x-date-pickers/DatePicker"; | |||||
| import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; | import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; | ||||
| import dayjs from "dayjs"; | import dayjs from "dayjs"; | ||||
| import CheckIcon from "@mui/icons-material/Check"; | 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 { useSession } from "next-auth/react"; | ||||
| import { AUTH } from "@/authorities"; | |||||
| import { AUTH, hasAbility } from "@/authorities"; | |||||
| type AdjustmentEntry = InventoryLotLineResult & { | type AdjustmentEntry = InventoryLotLineResult & { | ||||
| adjustedQty: number; | adjustedQty: number; | ||||
| @@ -59,7 +59,7 @@ interface Props { | |||||
| onStockAdjustmentSuccess?: () => void | Promise<void>; | onStockAdjustmentSuccess?: () => void | Promise<void>; | ||||
| } | } | ||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 3 | v1.0.5 | 2026-08-05 */ | |||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 3 | v1.0.7 | 2026-09-08 */ | |||||
| const InventoryLotLineTable: React.FC<Props> = ({ | const InventoryLotLineTable: React.FC<Props> = ({ | ||||
| inventoryLotLines, pagingController, setPagingController, totalCount, inventory, | inventoryLotLines, pagingController, setPagingController, totalCount, inventory, | ||||
| filterLotNo, | filterLotNo, | ||||
| @@ -68,8 +68,8 @@ const InventoryLotLineTable: React.FC<Props> = ({ | |||||
| }) => { | }) => { | ||||
| const { t } = useTranslation(["inventory"]); | const { t } = useTranslation(["inventory"]); | ||||
| const { data: session } = useSession(); | 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 PRINT_PRINTER_ID_KEY = 'inventoryLotLinePrintPrinterId'; | ||||
| const { setIsUploading } = useUploadContext(); | const { setIsUploading } = useUploadContext(); | ||||
| const [stockTransferModalOpen, setStockTransferModalOpen] = useState(false); | const [stockTransferModalOpen, setStockTransferModalOpen] = useState(false); | ||||
| @@ -99,7 +99,11 @@ const InventoryLotLineTable: React.FC<Props> = ({ | |||||
| remarks: '', | remarks: '', | ||||
| }); | }); | ||||
| const originalAdjustmentLinesRef = useRef<AdjustmentEntry[]>([]); | const originalAdjustmentLinesRef = useRef<AdjustmentEntry[]>([]); | ||||
| const adjustSaveInFlightRef = useRef(false); | |||||
| const loadedRemarksByLotRef = useRef<Map<string, string>>(new Map()); | |||||
| const remarksFetchGenRef = useRef(0); | |||||
| const [adjustmentEntries, setAdjustmentEntries] = useState<AdjustmentEntry[]>([]); | const [adjustmentEntries, setAdjustmentEntries] = useState<AdjustmentEntry[]>([]); | ||||
| const [isAdjustSaving, setIsAdjustSaving] = useState(false); | |||||
| useEffect(() => { | useEffect(() => { | ||||
| if (stockTransferModalOpen) { | if (stockTransferModalOpen) { | ||||
| fetchWarehouseListClient() | fetchWarehouseListClient() | ||||
| @@ -153,9 +157,34 @@ const prevAdjustmentModalOpenRef = useRef(false); | |||||
| })); | })); | ||||
| setAdjustmentEntries(initial); | setAdjustmentEntries(initial); | ||||
| originalAdjustmentLinesRef.current = initial; | originalAdjustmentLinesRef.current = initial; | ||||
| loadedRemarksByLotRef.current = new Map(); | |||||
| const fetchGen = ++remarksFetchGenRef.current; | |||||
| const itemId = inventory.itemId; | |||||
| fetchLatestAdjustmentRemarks(itemId) | |||||
| .then((rows) => { | |||||
| if (fetchGen !== remarksFetchGenRef.current) return; | |||||
| const byLot = new Map<string, string>(); | |||||
| for (const row of rows ?? []) { | |||||
| const lot = row.lotNo?.trim(); | |||||
| const remarks = row.remarks?.trim(); | |||||
| if (!lot || !remarks || byLot.has(lot)) continue; | |||||
| byLot.set(lot, remarks); | |||||
| } | |||||
| loadedRemarksByLotRef.current = byLot; | |||||
| const apply = (line: AdjustmentEntry): AdjustmentEntry => { | |||||
| const lot = line.lotNo?.trim(); | |||||
| const remarks = (lot && byLot.get(lot)) || line.remarks || ''; | |||||
| return { ...line, remarks }; | |||||
| }; | |||||
| setAdjustmentEntries((prev) => prev.map(apply)); | |||||
| originalAdjustmentLinesRef.current = originalAdjustmentLinesRef.current.map(apply); | |||||
| }) | |||||
| .catch(console.error); | |||||
| } | } | ||||
| setPendingRemovalLineId(null); | setPendingRemovalLineId(null); | ||||
| setRemovalReasons({}); | setRemovalReasons({}); | ||||
| } else if (!stockAdjustmentModalOpen) { | |||||
| remarksFetchGenRef.current += 1; | |||||
| } | } | ||||
| }, [stockAdjustmentModalOpen, inventory, availableLotLines]); | }, [stockAdjustmentModalOpen, inventory, availableLotLines]); | ||||
| @@ -164,12 +193,15 @@ const prevAdjustmentModalOpenRef = useRef(false); | |||||
| setPendingRemovalLineId(null); | setPendingRemovalLineId(null); | ||||
| setRemovalReasons({}); | setRemovalReasons({}); | ||||
| setAdjustmentEntries( | 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]); | }, [availableLotLines]); | ||||
| @@ -241,15 +273,26 @@ const prevAdjustmentModalOpenRef = useRef(false); | |||||
| expiryDate, | expiryDate, | ||||
| warehouseId: line.warehouse?.id ?? 0, | warehouseId: line.warehouse?.id ?? 0, | ||||
| uom: line.uom ?? null, | uom: line.uom ?? null, | ||||
| remarks: line.remarks?.trim() || null, | |||||
| }; | }; | ||||
| }, []); | }, []); | ||||
| const handleAdjustmentSave = useCallback(async () => { | const handleAdjustmentSave = useCallback(async () => { | ||||
| if (!inventory) return; | 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 { | 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); | setIsUploading(true); | ||||
| await submitStockAdjustment({ | await submitStockAdjustment({ | ||||
| itemId: inventory.itemId, | itemId: inventory.itemId, | ||||
| @@ -264,8 +307,10 @@ const prevAdjustmentModalOpenRef = useRef(false); | |||||
| msgError(message || t("Save failed")); | msgError(message || t("Save failed")); | ||||
| } finally { | } finally { | ||||
| setIsUploading(false); | setIsUploading(false); | ||||
| setIsAdjustSaving(false); | |||||
| adjustSaveInFlightRef.current = false; | |||||
| } | } | ||||
| }, [adjustmentEntries, inventory, t, toApiLine, onStockAdjustmentSuccess]); | |||||
| }, [adjustmentEntries, inventory, t, toApiLine, onStockAdjustmentSuccess, removalReasons]); | |||||
| const handleOpenAddEntry = useCallback(() => { | const handleOpenAddEntry = useCallback(() => { | ||||
| setAddEntryForm({ | setAddEntryForm({ | ||||
| @@ -857,7 +902,7 @@ const prevAdjustmentModalOpenRef = useRef(false); | |||||
| color="primary" | color="primary" | ||||
| startIcon={<SaveIcon />} | startIcon={<SaveIcon />} | ||||
| onClick={handleAdjustmentSave} | onClick={handleAdjustmentSave} | ||||
| disabled={!hasAdjustmentChange} | |||||
| disabled={!hasAdjustmentChange || isAdjustSaving} | |||||
| > | > | ||||
| {t("Save")} | {t("Save")} | ||||
| </Button> | </Button> | ||||
| @@ -1004,7 +1049,9 @@ const prevAdjustmentModalOpenRef = useRef(false); | |||||
| }, | }, | ||||
| }} | }} | ||||
| /> | /> | ||||
| ) : null} | |||||
| ) : ( | |||||
| line.remarks || null | |||||
| )} | |||||
| </TableCell> | </TableCell> | ||||
| <TableCell align="center"> | <TableCell align="center"> | ||||
| {pendingRemovalLineId === line.id ? ( | {pendingRemovalLineId === line.id ? ( | ||||
| @@ -2,7 +2,7 @@ | |||||
| import { InventoryLotLineResult, InventoryResult } from '@/app/api/inventory'; | import { InventoryLotLineResult, InventoryResult } from '@/app/api/inventory'; | ||||
| import { useTranslation } from 'react-i18next'; | import { useTranslation } from 'react-i18next'; | ||||
| import SearchBox, { Criterion } from '../SearchBox'; | 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 { uniq, uniqBy } from 'lodash'; | ||||
| import InventoryTable from './InventoryTable'; | import InventoryTable from './InventoryTable'; | ||||
| import { defaultPagingController } from '../SearchResults/SearchResults'; | import { defaultPagingController } from '../SearchResults/SearchResults'; | ||||
| @@ -16,23 +16,10 @@ import { | |||||
| fetchInventoryLotLines, | fetchInventoryLotLines, | ||||
| } from '@/app/api/inventory/actions'; | } from '@/app/api/inventory/actions'; | ||||
| import { PrinterCombo } from '@/app/api/settings/printer'; | 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 { | interface Props { | ||||
| inventories: InventoryResult[]; | inventories: InventoryResult[]; | ||||
| @@ -56,42 +43,68 @@ type SearchQuery = Partial< | |||||
| >; | >; | ||||
| type SearchParamNames = keyof SearchQuery; | type SearchParamNames = keyof SearchQuery; | ||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 18 | v1.0.0 | 2026-07-17 */ | |||||
| type ItemLookupRow = { | |||||
| id: number; | |||||
| code: string; | |||||
| name: string; | |||||
| type?: string; | |||||
| uom?: string; | |||||
| uomDesc?: string; | |||||
| purchaseUnit?: string; | |||||
| }; | |||||
| const extractItemRecords = (res: unknown): ItemLookupRow[] => { | |||||
| if (!res) return []; | |||||
| if (Array.isArray(res)) return res as ItemLookupRow[]; | |||||
| if (typeof res === 'object' && Array.isArray((res as { records?: unknown }).records)) { | |||||
| return (res as { records: ItemLookupRow[] }).records; | |||||
| } | |||||
| return []; | |||||
| }; | |||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 18 | v1.0.3 | 2026-09-07 */ | |||||
| const InventorySearch: React.FC<Props> = ({ inventories, printerCombo }) => { | const InventorySearch: React.FC<Props> = ({ inventories, printerCombo }) => { | ||||
| const { t } = useTranslation(['inventory', 'common', 'item']); | 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, | id: 0, | ||||
| itemId: item.id, | |||||
| itemId: Number(item.id), | |||||
| itemCode: item.code, | itemCode: item.code, | ||||
| itemName: item.name, | itemName: item.name, | ||||
| itemType: 'Material', | |||||
| itemType: item.type || 'mat', | |||||
| onHandQty: 0, | onHandQty: 0, | ||||
| onHoldQty: 0, | onHoldQty: 0, | ||||
| unavailableQty: 0, | unavailableQty: 0, | ||||
| availableQty: 0, | availableQty: 0, | ||||
| uomCode: item.uom, | |||||
| uomUdfudesc: item.uomDesc, | |||||
| uomShortDesc: item.uom, | |||||
| uomCode: item.uom || uom, | |||||
| uomUdfudesc: uom, | |||||
| uomShortDesc: item.uom || uom, | |||||
| qtyPerSmallestUnit: 1, | qtyPerSmallestUnit: 1, | ||||
| baseUom: item.uom, | |||||
| baseUom: uom, | |||||
| price: 0, | price: 0, | ||||
| currencyName: '', | currencyName: '', | ||||
| status: 'active', | status: 'active', | ||||
| latestMarketUnitPrice: undefined, | latestMarketUnitPrice: undefined, | ||||
| latestMupUpdatedDate: undefined, | latestMupUpdatedDate: undefined, | ||||
| }), | |||||
| [], | |||||
| ); | |||||
| const getFirstItemRecord = useCallback((res: any): ItemWithDetails | null => { | |||||
| if (!res) return null; | |||||
| if (Array.isArray(res)) return (res[0] as ItemWithDetails) ?? null; | |||||
| if (Array.isArray(res?.records)) return (res.records[0] as ItemWithDetails) ?? null; | |||||
| return null; | |||||
| }; | |||||
| }, []); | }, []); | ||||
| const lookupItemsByCodeOrName = useCallback(async (code?: string, name?: string) => { | |||||
| const trimmedCode = code?.trim(); | |||||
| const trimmedName = name?.trim(); | |||||
| if (!trimmedCode && !trimmedName) return []; | |||||
| const params: Record<string, string | number> = { pageSize: 50, pageNum: 1 }; | |||||
| if (trimmedCode) params.code = trimmedCode; | |||||
| else params.name = trimmedName as string; | |||||
| const itemRes = await fetchItemsByPage(params); | |||||
| return extractItemRecords(itemRes); | |||||
| }, []); | |||||
| // Inventory | // Inventory | ||||
| const [filteredInventories, setFilteredInventories] = useState<InventoryResult[]>([]); | const [filteredInventories, setFilteredInventories] = useState<InventoryResult[]>([]); | ||||
| @@ -104,6 +117,20 @@ const InventorySearch: React.FC<Props> = ({ inventories, printerCombo }) => { | |||||
| const [inventoryLotLinesPagingController, setInventoryLotLinesPagingController] = useState(defaultPagingController) | const [inventoryLotLinesPagingController, setInventoryLotLinesPagingController] = useState(defaultPagingController) | ||||
| const [inventoryLotLinesTotalCount, setInventoryLotLinesTotalCount] = useState(0) | 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) | // Scan-mode UI (hardware QR scanner via QrCodeScannerProvider) | ||||
| const qrScanner = useQrCodeScannerContext(); | const qrScanner = useQrCodeScannerContext(); | ||||
| const [scanUiMode, setScanUiMode] = useState<'idle' | 'scanning'>('idle'); | const [scanUiMode, setScanUiMode] = useState<'idle' | 'scanning'>('idle'); | ||||
| @@ -113,13 +140,6 @@ const InventorySearch: React.FC<Props> = ({ inventories, printerCombo }) => { | |||||
| const [lotNoFilter, setLotNoFilter] = useState(''); | const [lotNoFilter, setLotNoFilter] = useState(''); | ||||
| const [scannedItemId, setScannedItemId] = useState<number | null>(null); | const [scannedItemId, setScannedItemId] = useState<number | null>(null); | ||||
| // Opening inventory (pure opening stock for items without existing inventory) | |||||
| const [openingItems, setOpeningItems] = useState<ItemCombo[]>([]); | |||||
| const [openingModalOpen, setOpeningModalOpen] = useState(false); | |||||
| const [openingSelectedItem, setOpeningSelectedItem] = useState<ItemCombo | null>(null); | |||||
| const [openingLoading, setOpeningLoading] = useState(false); | |||||
| const [openingSearchText, setOpeningSearchText] = useState(''); | |||||
| const defaultInputs = useMemo( | const defaultInputs = useMemo( | ||||
| () => ({ | () => ({ | ||||
| itemId: '', | itemId: '', | ||||
| @@ -297,46 +317,55 @@ const InventorySearch: React.FC<Props> = ({ inventories, printerCombo }) => { | |||||
| // On Search | // On Search | ||||
| const onSearch = useCallback( | const onSearch = useCallback( | ||||
| async (query: Record<SearchParamNames, string>) => { | async (query: Record<SearchParamNames, string>) => { | ||||
| setLotNoFilter(''); | |||||
| setScannedItemId(null); | |||||
| setScanUiMode('idle'); | |||||
| setScanHoverCancel(false); | |||||
| qrScanner.stopScan(); | |||||
| qrScanner.resetScan(); | |||||
| const invRes = await refetchInventoryData(query, 'search', defaultPagingController, ''); | |||||
| await refetchInventoryLotLineData(null, 'search', defaultPagingController); | |||||
| setInputs(() => query); | |||||
| setInventoriesPagingController(() => defaultPagingController); | |||||
| setInventoryLotLinesPagingController(() => defaultPagingController); | |||||
| if (searchInFlightRef.current) return; | |||||
| searchInFlightRef.current = true; | |||||
| try { | |||||
| setLotNoFilter(''); | |||||
| setScannedItemId(null); | |||||
| setScanUiMode('idle'); | |||||
| setScanHoverCancel(false); | |||||
| qrScanner.stopScan(); | |||||
| qrScanner.resetScan(); | |||||
| const invRes = await refetchInventoryData(query, 'search', defaultPagingController, ''); | |||||
| await refetchInventoryLotLineData(null, 'search', defaultPagingController); | |||||
| setInputs(() => query); | |||||
| setInventoriesPagingController(() => defaultPagingController); | |||||
| setInventoryLotLinesPagingController(() => defaultPagingController); | |||||
| // If there are no inventory rows, render a synthetic inventory so the "Stock Adjustment" chip can be used. | |||||
| if (invRes?.records?.length === 0) { | |||||
| try { | |||||
| const code = query.itemCode?.trim?.(); | |||||
| const name = query.itemName?.trim?.(); | |||||
| const lookupParams = code ? { code } : name ? { name } : null; | |||||
| if (lookupParams) { | |||||
| const itemRes = await fetchItemsWithDetails(lookupParams); | |||||
| const firstItem = getFirstItemRecord(itemRes); | |||||
| if (firstItem) { | |||||
| setSelectedInventory(buildSyntheticInventory(firstItem)); | |||||
| setFilteredInventoryLotLines([]); | |||||
| setInventoryLotLinesPagingController(() => defaultPagingController); | |||||
| // No inventory rows: look up item master so 0-qty rows can be selected for stock adjustment. | |||||
| if (canStockAdjust && invRes?.records?.length === 0) { | |||||
| try { | |||||
| const items = await lookupItemsByCodeOrName(query.itemCode, query.itemName); | |||||
| const typeFilter = query.itemType?.trim(); | |||||
| let filtered = | |||||
| typeFilter && typeFilter.toLowerCase() !== 'all' | |||||
| ? items.filter((it) => (it.type ?? '').toLowerCase() === typeFilter.toLowerCase()) | |||||
| : items; | |||||
| const exactCode = query.itemCode?.trim().toLowerCase(); | |||||
| if (exactCode) { | |||||
| filtered = [...filtered].sort((a, b) => { | |||||
| const aExact = a.code?.toLowerCase() === exactCode ? 0 : 1; | |||||
| const bExact = b.code?.toLowerCase() === exactCode ? 0 : 1; | |||||
| return aExact - bExact; | |||||
| }); | |||||
| } | } | ||||
| applyItemsAsSyntheticInventories(filtered); | |||||
| } catch (e) { | |||||
| console.error('Failed to build synthetic inventory:', e); | |||||
| } | } | ||||
| } catch (e) { | |||||
| console.error('Failed to build synthetic inventory:', e); | |||||
| } | } | ||||
| } finally { | |||||
| searchInFlightRef.current = false; | |||||
| } | } | ||||
| }, | }, | ||||
| [ | [ | ||||
| qrScanner, | qrScanner, | ||||
| refetchInventoryData, | refetchInventoryData, | ||||
| refetchInventoryLotLineData, | refetchInventoryLotLineData, | ||||
| buildSyntheticInventory, | |||||
| getFirstItemRecord, | |||||
| lookupItemsByCodeOrName, | |||||
| applyItemsAsSyntheticInventories, | |||||
| canStockAdjust, | |||||
| ], | ], | ||||
| ); | ); | ||||
| @@ -382,13 +411,11 @@ const InventorySearch: React.FC<Props> = ({ inventories, printerCombo }) => { | |||||
| onInventoryRowClick(target); | onInventoryRowClick(target); | ||||
| } else { | } else { | ||||
| refetchInventoryLotLineData(null, 'search', defaultPagingController); | 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 { | } else { | ||||
| setSelectedInventory(null); | setSelectedInventory(null); | ||||
| } | } | ||||
| @@ -410,112 +437,12 @@ const InventorySearch: React.FC<Props> = ({ inventories, printerCombo }) => { | |||||
| qrScanner.result, | qrScanner.result, | ||||
| refetchInventoryData, | refetchInventoryData, | ||||
| refetchInventoryLotLineData, | refetchInventoryLotLineData, | ||||
| buildSyntheticInventory, | |||||
| getFirstItemRecord, | |||||
| lookupItemsByCodeOrName, | |||||
| applyItemsAsSyntheticInventories, | |||||
| canStockAdjust, | |||||
| scanUiMode, | scanUiMode, | ||||
| ]); | ]); | ||||
| //console.log('', 'color: #666', inventoriesPagingController); | |||||
| const handleOpenOpeningInventoryModal = useCallback(() => { | |||||
| setOpeningSelectedItem(null); | |||||
| setOpeningItems([]); | |||||
| setOpeningSearchText(''); | |||||
| setOpeningModalOpen(true); | |||||
| }, []); | |||||
| const handleOpeningSearch = useCallback(async () => { | |||||
| const trimmed = openingSearchText.trim(); | |||||
| if (!trimmed) { | |||||
| setOpeningItems([]); | |||||
| return; | |||||
| } | |||||
| setOpeningLoading(true); | |||||
| try { | |||||
| const searchParams: Record<string, any> = { | |||||
| pageSize: 50, | |||||
| pageNum: 1, | |||||
| }; | |||||
| // Heuristic: if input contains space, treat as name; otherwise treat as code. | |||||
| if (trimmed.includes(' ')) { | |||||
| searchParams.name = trimmed; | |||||
| } else { | |||||
| searchParams.code = trimmed; | |||||
| } | |||||
| const response = await fetchItemsWithDetails(searchParams); | |||||
| let records: any[] = []; | |||||
| if (response && typeof response === 'object') { | |||||
| const anyRes = response as any; | |||||
| if (Array.isArray(anyRes.records)) { | |||||
| records = anyRes.records; | |||||
| } else if (Array.isArray(anyRes)) { | |||||
| records = anyRes; | |||||
| } | |||||
| } | |||||
| const combos: ItemCombo[] = records.map((item: any) => ({ | |||||
| id: item.id, | |||||
| label: `${item.code} - ${item.name}`, | |||||
| uomId: item.uomId, | |||||
| uom: item.uom, | |||||
| uomDesc: item.uomDesc, | |||||
| group: item.group, | |||||
| currentStockBalance: item.currentStockBalance, | |||||
| })); | |||||
| setOpeningItems(combos); | |||||
| } catch (e) { | |||||
| console.error('Failed to search items for opening inventory:', e); | |||||
| setOpeningItems([]); | |||||
| } finally { | |||||
| setOpeningLoading(false); | |||||
| } | |||||
| }, [openingSearchText]); | |||||
| const handleConfirmOpeningInventory = useCallback(() => { | |||||
| if (!openingSelectedItem) { | |||||
| setOpeningModalOpen(false); | |||||
| return; | |||||
| } | |||||
| const rawLabel = openingSelectedItem.label ?? ''; | |||||
| const [codePart, ...nameParts] = rawLabel.split(' - '); | |||||
| const itemCode = codePart?.trim() || rawLabel; | |||||
| const itemName = nameParts.join(' - ').trim() || itemCode; | |||||
| const syntheticInventory: InventoryResult = { | |||||
| id: 0, | |||||
| itemId: Number(openingSelectedItem.id), | |||||
| itemCode, | |||||
| itemName, | |||||
| itemType: 'Material', | |||||
| onHandQty: 0, | |||||
| onHoldQty: 0, | |||||
| unavailableQty: 0, | |||||
| availableQty: 0, | |||||
| uomCode: openingSelectedItem.uom, | |||||
| uomUdfudesc: openingSelectedItem.uomDesc, | |||||
| uomShortDesc: openingSelectedItem.uom, | |||||
| qtyPerSmallestUnit: 1, | |||||
| baseUom: openingSelectedItem.uom, | |||||
| price: 0, | |||||
| currencyName: '', | |||||
| status: 'active', | |||||
| latestMarketUnitPrice: undefined, | |||||
| latestMupUpdatedDate: undefined, | |||||
| }; | |||||
| // Use this synthetic inventory to drive the stock adjustment UI | |||||
| setSelectedInventory(syntheticInventory); | |||||
| setFilteredInventoryLotLines([]); | |||||
| setInventoryLotLinesPagingController(() => defaultPagingController); | |||||
| setOpeningModalOpen(false); | |||||
| }, [openingSelectedItem]); | |||||
| return ( | return ( | ||||
| <> | <> | ||||
| <SearchBox | <SearchBox | ||||
| @@ -540,15 +467,6 @@ const InventorySearch: React.FC<Props> = ({ inventories, printerCombo }) => { | |||||
| </Button> | </Button> | ||||
| </> | </> | ||||
| )} | )} | ||||
| <Button | |||||
| variant="outlined" | |||||
| color="secondary" | |||||
| onClick={handleOpenOpeningInventoryModal} | |||||
| sx={{ display: 'none' }} | |||||
| > | |||||
| {t('Add entry for items without inventory')} | |||||
| </Button> | |||||
| </Box> | </Box> | ||||
| } | } | ||||
| /> | /> | ||||
| @@ -600,98 +518,6 @@ const InventorySearch: React.FC<Props> = ({ inventories, printerCombo }) => { | |||||
| } | } | ||||
| }} | }} | ||||
| /> | /> | ||||
| <Dialog | |||||
| open={openingModalOpen} | |||||
| onClose={() => setOpeningModalOpen(false)} | |||||
| fullWidth | |||||
| maxWidth="md" | |||||
| > | |||||
| <DialogTitle>{t('Add entry for items without inventory')}</DialogTitle> | |||||
| <DialogContent sx={{ pt: 2 }}> | |||||
| <Box sx={{ display: 'flex', gap: 1, mb: 2 }}> | |||||
| <TextField | |||||
| label={t('Item')} | |||||
| fullWidth | |||||
| value={openingSearchText} | |||||
| onChange={(e) => setOpeningSearchText(e.target.value)} | |||||
| onKeyDown={(e) => { | |||||
| if (e.key === 'Enter') { | |||||
| e.preventDefault(); | |||||
| handleOpeningSearch(); | |||||
| } | |||||
| }} | |||||
| sx={{ flex: 2 }} | |||||
| /> | |||||
| <Button | |||||
| variant="contained" | |||||
| onClick={handleOpeningSearch} | |||||
| disabled={openingLoading} | |||||
| sx={{ flex: 1 }} | |||||
| > | |||||
| {openingLoading ? <CircularProgress size={20} /> : t('common:Search')} | |||||
| </Button> | |||||
| </Box> | |||||
| {openingItems.length === 0 && !openingLoading ? ( | |||||
| <Box sx={{ py: 1, color: 'text.secondary', fontSize: 14 }}> | |||||
| {openingSearchText | |||||
| ? t('No data') | |||||
| : t('Enter item code or name to search')} | |||||
| </Box> | |||||
| ) : ( | |||||
| <Table size="small"> | |||||
| <TableHead> | |||||
| <TableRow> | |||||
| <TableCell /> | |||||
| <TableCell>{t('Code')}</TableCell> | |||||
| <TableCell>{t('Name')}</TableCell> | |||||
| <TableCell>{t('UoM')}</TableCell> | |||||
| <TableCell align="right">{t('Current Stock')}</TableCell> | |||||
| </TableRow> | |||||
| </TableHead> | |||||
| <TableBody> | |||||
| {openingItems.map((it) => { | |||||
| const [code, ...nameParts] = (it.label ?? '').split(' - '); | |||||
| const name = nameParts.join(' - '); | |||||
| const selected = openingSelectedItem?.id === it.id; | |||||
| return ( | |||||
| <TableRow | |||||
| key={it.id} | |||||
| hover | |||||
| selected={selected} | |||||
| onClick={() => setOpeningSelectedItem(it)} | |||||
| sx={{ cursor: 'pointer' }} | |||||
| > | |||||
| <TableCell padding="checkbox"> | |||||
| <Radio checked={selected} /> | |||||
| </TableCell> | |||||
| <TableCell>{code}</TableCell> | |||||
| <TableCell>{name}</TableCell> | |||||
| <TableCell>{it.uomDesc || it.uom}</TableCell> | |||||
| <TableCell align="right"> | |||||
| {it.currentStockBalance != null ? it.currentStockBalance : '-'} | |||||
| </TableCell> | |||||
| </TableRow> | |||||
| ); | |||||
| })} | |||||
| </TableBody> | |||||
| </Table> | |||||
| )} | |||||
| </DialogContent> | |||||
| <DialogActions> | |||||
| <Button onClick={() => setOpeningModalOpen(false)}> | |||||
| {t('common:Cancel')} | |||||
| </Button> | |||||
| <Button | |||||
| variant="contained" | |||||
| onClick={handleConfirmOpeningInventory} | |||||
| disabled={!openingSelectedItem} | |||||
| > | |||||
| {t('common:Confirm')} | |||||
| </Button> | |||||
| </DialogActions> | |||||
| </Dialog> | |||||
| </> | </> | ||||
| ); | ); | ||||
| }; | }; | ||||
| @@ -207,7 +207,7 @@ interface PolInputResult { | |||||
| dnQty: string, | 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<Props> = ({ po, warehouse, printerCombo }) => { | const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => { | ||||
| const cameras = useContext(CameraContext); | const cameras = useContext(CameraContext); | ||||
| const { data: session } = useSession(); | const { data: session } = useSession(); | ||||
| @@ -578,8 +578,6 @@ const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => { | |||||
| // setStockInLine([]) | // setStockInLine([]) | ||||
| // }, []); | // }, []); | ||||
| const [tabIndex, setTabIndex] = useState(0); | const [tabIndex, setTabIndex] = useState(0); | ||||
| const handleTabChange = useCallback<NonNullable<TabsProps["onChange"]>>( | const handleTabChange = useCallback<NonNullable<TabsProps["onChange"]>>( | ||||
| (_e, newValue) => { | (_e, newValue) => { | ||||
| @@ -701,9 +699,9 @@ const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => { | |||||
| return ( | return ( | ||||
| <> | <> | ||||
| <Stack spacing={2}> | |||||
| <Stack spacing={2} sx={{ width: "100%" }}> | |||||
| {/* Area1: title */} | {/* Area1: title */} | ||||
| <Grid container xs={12} justifyContent="start"> | |||||
| <Grid container justifyContent="start"> | |||||
| <Grid item> | <Grid item> | ||||
| <Typography mb={2} variant="h4"> | <Typography mb={2} variant="h4"> | ||||
| {purchaseOrder.code} -{" "} | {purchaseOrder.code} -{" "} | ||||
| @@ -713,7 +711,7 @@ const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => { | |||||
| </Grid> | </Grid> | ||||
| {/* area2: dn info */} | {/* area2: dn info */} | ||||
| <Grid container spacing={3} sx={{ maxWidth: 'fit-content' }} alignItems="stretch"> | |||||
| <Grid container spacing={3} sx={{ width: "100%" }} alignItems="stretch"> | |||||
| {/* left side select po */} | {/* left side select po */} | ||||
| <Grid item xs={4} sx={{ display: "flex" }}> | <Grid item xs={4} sx={{ display: "flex" }}> | ||||
| <Stack spacing={1} sx={{ flex: 1 }}> | <Stack spacing={1} sx={{ flex: 1 }}> | ||||
| @@ -727,8 +725,8 @@ const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => { | |||||
| </Grid> | </Grid> | ||||
| {/* right side po info */} | {/* right side po info */} | ||||
| <Grid item xs={8}> | |||||
| <Grid container spacing={3} sx={{ maxWidth: 'fit-content' }}> | |||||
| <Grid item xs={8} sx={{ minWidth: 0 }}> | |||||
| <Grid container spacing={3} sx={{ width: "100%" }}> | |||||
| <Grid item xs={12}> | <Grid item xs={12}> | ||||
| <FormProvider {...dnFormProps}> | <FormProvider {...dnFormProps}> | ||||
| <Card sx={{ display: "block" }}> | <Card sx={{ display: "block" }}> | ||||
| @@ -858,10 +856,10 @@ const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => { | |||||
| {/* Area4: Main Table */} | {/* Area4: Main Table */} | ||||
| <Grid container xs={12} justifyContent="start"> | |||||
| <Grid item xs={12}> | |||||
| <TableContainer component={Paper} sx={{ width: 'fit-content', overflow: 'auto' }}> | |||||
| <Table aria-label="collapsible table" stickyHeader> | |||||
| <Grid container justifyContent="start" sx={{ width: "100%" }}> | |||||
| <Grid item xs={12} sx={{ width: "100%", minWidth: 0 }}> | |||||
| <TableContainer component={Paper} sx={{ width: "100%", overflow: "auto" }}> | |||||
| <Table aria-label="collapsible table" stickyHeader sx={{ width: "100%" }}> | |||||
| <TableHead> | <TableHead> | ||||
| <TableRow> | <TableRow> | ||||
| <TableCell align="center" sx={{ width: '60px' }}></TableCell> | <TableCell align="center" sx={{ width: '60px' }}></TableCell> | ||||
| @@ -905,13 +903,13 @@ const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => { | |||||
| </Grid> | </Grid> | ||||
| {/* area5: selected item info */} | {/* area5: selected item info */} | ||||
| <Grid container xs={12} justifyContent="start"> | |||||
| <Grid container justifyContent="start" sx={{ width: "100%" }}> | |||||
| <Grid item xs={12}> | <Grid item xs={12}> | ||||
| <Typography variant="h6"> | <Typography variant="h6"> | ||||
| {selectedRow ? `已選擇貨品: ${selectedRow?.itemNo ? selectedRow.itemNo : 'N/A'} - ${selectedRow?.itemName ? selectedRow?.itemName : 'N/A'}` : "未選擇貨品"} | {selectedRow ? `已選擇貨品: ${selectedRow?.itemNo ? selectedRow.itemNo : 'N/A'} - ${selectedRow?.itemName ? selectedRow?.itemName : 'N/A'}` : "未選擇貨品"} | ||||
| </Typography> | </Typography> | ||||
| </Grid> | </Grid> | ||||
| <Grid item xs={12} sx={{ minWidth: 0 }}> | |||||
| <Grid item xs={12} sx={{ width: "100%", minWidth: 0 }}> | |||||
| {selectedRow && ( | {selectedRow && ( | ||||
| <PoInputGrid | <PoInputGrid | ||||
| setRows={setRows} | setRows={setRows} | ||||
| @@ -5,11 +5,9 @@ import { | |||||
| Box, | Box, | ||||
| Button, | Button, | ||||
| Radio, | Radio, | ||||
| Stack, | |||||
| TableCell, | TableCell, | ||||
| TableRow, | TableRow, | ||||
| TextField, | TextField, | ||||
| Typography, | |||||
| alpha, | alpha, | ||||
| } from "@mui/material"; | } from "@mui/material"; | ||||
| import { memo, useCallback, useEffect, useRef, useState } from "react"; | import { memo, useCallback, useEffect, useRef, useState } from "react"; | ||||
| @@ -17,13 +15,7 @@ import { useTranslation } from "react-i18next"; | |||||
| import { decimalFormatter, integerFormatter } from "@/app/utils/formatUtil"; | import { decimalFormatter, integerFormatter } from "@/app/utils/formatUtil"; | ||||
| import { submitDialogWithWarning } from "../Swal/CustomAlerts"; | import { submitDialogWithWarning } from "../Swal/CustomAlerts"; | ||||
| import { createStockInLine } from "@/app/api/stockIn/actions"; | import { createStockInLine } from "@/app/api/stockIn/actions"; | ||||
| import { | |||||
| isNotIntegerQty, | |||||
| previewPoBatchStockQty, | |||||
| roundStockQty, | |||||
| StockQtyRoundChoice, | |||||
| StockQtyRoundMode, | |||||
| } from "./stockQtyRound"; | |||||
| import { previewPoBatchStockQty } from "./stockQtyRound"; | |||||
| const PURCHASE_STOCK_IN_ALERT_STATUSES = new Set(["pending", "receiving"]); | const PURCHASE_STOCK_IN_ALERT_STATUSES = new Set(["pending", "receiving"]); | ||||
| @@ -76,6 +68,7 @@ type Props = { | |||||
| onSubmitted: (row: PurchaseOrderLine) => void; | onSubmitted: (row: PurchaseOrderLine) => void; | ||||
| }; | }; | ||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.3 | 2026-09-10 */ | |||||
| export const PoDetailRow = memo(function PoDetailRow({ | export const PoDetailRow = memo(function PoDetailRow({ | ||||
| row, | row, | ||||
| selected, | selected, | ||||
| @@ -97,17 +90,17 @@ export const PoDetailRow = memo(function PoDetailRow({ | |||||
| useEffect(() => { | useEffect(() => { | ||||
| setLotNoInput(savedLotNo); | setLotNoInput(savedLotNo); | ||||
| setDnQtyInput(savedDnQty); | |||||
| setDnQtyInput(savedDnQty.replace(/[^\d]/g, "")); | |||||
| }, [savedLotNo, savedDnQty]); | }, [savedLotNo, savedDnQty]); | ||||
| const handleStart = useCallback( | const handleStart = useCallback( | ||||
| (roundMode?: StockQtyRoundMode) => { | |||||
| () => { | |||||
| if (submitInFlightRef.current || isStarting) return; | if (submitInFlightRef.current || isStarting) return; | ||||
| const orderQty = Number(row?.qty) ?? 0; | const orderQty = Number(row?.qty) ?? 0; | ||||
| const acceptedQty = Number(dnQtyInput.trim()); | const acceptedQty = Number(dnQtyInput.trim()); | ||||
| if (isNaN(acceptedQty) || acceptedQty <= 0) { | |||||
| alert("來貨數量必須大於0!"); | |||||
| if (!Number.isInteger(acceptedQty) || acceptedQty <= 0) { | |||||
| alert("來貨數量必須為大於0的整數!"); | |||||
| return; | return; | ||||
| } | } | ||||
| @@ -116,18 +109,6 @@ export const PoDetailRow = memo(function PoDetailRow({ | |||||
| Number(row.stockUom?.stockQty ?? 0), | Number(row.stockUom?.stockQty ?? 0), | ||||
| acceptedQty, | 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 = () => { | const doSubmit = () => { | ||||
| if (submitInFlightRef.current) return; | if (submitInFlightRef.current) return; | ||||
| @@ -145,12 +126,6 @@ export const PoDetailRow = memo(function PoDetailRow({ | |||||
| purchaseOrderLineId: row.id, | purchaseOrderLineId: row.id, | ||||
| acceptedQty: acceptedQty, | acceptedQty: acceptedQty, | ||||
| productLotNo: lotNoInput || "", | productLotNo: lotNoInput || "", | ||||
| ...(round | |||||
| ? { | |||||
| stockQtyRoundMode: round.mode, | |||||
| stockQtyRoundSource: "CREATE" as const, | |||||
| } | |||||
| : {}), | |||||
| }; | }; | ||||
| const res = await createStockInLine(postData); | const res = await createStockInLine(postData); | ||||
| if (res) { | 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, { | submitDialogWithWarning(doSubmit, t, { | ||||
| title: t("Confirm submit"), | title: t("Confirm submit"), | ||||
| html: t("This batch quantity exceeds order quantity. Do you still want to submit?"), | |||||
| html: t("qtyExceedsOrderConfirm"), | |||||
| confirmButtonText: t("Submit"), | confirmButtonText: t("Submit"), | ||||
| }); | }); | ||||
| } else { | } 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 | const totalStockReceived = row.stockInLine | ||||
| .filter((sil) => sil.purchaseOrderLineId === row.id) | .filter((sil) => sil.purchaseOrderLineId === row.id) | ||||
| .reduce((acc, cur) => acc + (cur.acceptedQty ?? 0), 0); | .reduce((acc, cur) => acc + (cur.acceptedQty ?? 0), 0); | ||||
| @@ -299,61 +275,35 @@ export const PoDetailRow = memo(function PoDetailRow({ | |||||
| type="text" | type="text" | ||||
| variant="outlined" | variant="outlined" | ||||
| value={dnQtyInput} | value={dnQtyInput} | ||||
| onChange={(e) => setDnQtyInput(e.target.value)} | |||||
| onChange={(e) => setDnQtyInput(e.target.value.replace(/[^\d]/g, ""))} | |||||
| onBlur={() => onInputBlur(row.id, lotNoInput, dnQtyInput)} | onBlur={() => onInputBlur(row.id, lotNoInput, dnQtyInput)} | ||||
| onClick={(e) => e.stopPropagation()} | onClick={(e) => e.stopPropagation()} | ||||
| InputProps={{ | InputProps={{ | ||||
| inputProps: { | inputProps: { | ||||
| min: 0, | |||||
| step: "any", | |||||
| inputMode: "decimal", | |||||
| min: 1, | |||||
| step: 1, | |||||
| inputMode: "numeric", | |||||
| pattern: "[0-9]*", | |||||
| }, | }, | ||||
| }} | }} | ||||
| /> | /> | ||||
| </TableCell> | </TableCell> | ||||
| ) : null} | ) : null} | ||||
| <TableCell align="center"> | <TableCell align="center"> | ||||
| {needsStockQtyRound ? ( | |||||
| <Stack spacing={0.5} alignItems="stretch" onClick={(e) => e.stopPropagation()}> | |||||
| <Typography variant="caption" color="text.secondary" sx={{ whiteSpace: "nowrap" }}> | |||||
| {t("Converted stock qty is")} {decimalFormatter.format(previewStockQty)} | |||||
| {row.stockUom?.stockUomDesc ? ` (${row.stockUom.stockUomDesc})` : ""} | |||||
| </Typography> | |||||
| <Button | |||||
| variant="contained" | |||||
| size="medium" | |||||
| disabled={isStarting} | |||||
| onClick={(e) => { | |||||
| e.stopPropagation(); | |||||
| handleStart("CEILING"); | |||||
| }} | |||||
| > | |||||
| {t("Round ceiling")} {roundUpQty}{stockUomLabel} | |||||
| </Button> | |||||
| <Button | |||||
| variant="outlined" | |||||
| size="medium" | |||||
| disabled={isStarting} | |||||
| onClick={(e) => { | |||||
| e.stopPropagation(); | |||||
| handleStart("FLOOR"); | |||||
| }} | |||||
| > | |||||
| {t("Round floor")} {roundDownQty}{stockUomLabel} | |||||
| </Button> | |||||
| </Stack> | |||||
| ) : ( | |||||
| <Button | |||||
| variant="contained" | |||||
| disabled={isStarting} | |||||
| onClick={(e) => { | |||||
| e.stopPropagation(); | |||||
| handleStart(); | |||||
| }} | |||||
| > | |||||
| {t("submit")} | |||||
| </Button> | |||||
| )} | |||||
| <Button | |||||
| variant="contained" | |||||
| disabled={isStarting} | |||||
| onMouseDown={(e) => { | |||||
| e.preventDefault(); | |||||
| e.stopPropagation(); | |||||
| }} | |||||
| onClick={(e) => { | |||||
| e.stopPropagation(); | |||||
| handleStart(); | |||||
| }} | |||||
| > | |||||
| {t("submit")} | |||||
| </Button> | |||||
| </TableCell> | </TableCell> | ||||
| </TableRow> | </TableRow> | ||||
| ); | ); | ||||
| @@ -35,7 +35,7 @@ import ShoppingCartIcon from "@mui/icons-material/ShoppingCart"; | |||||
| import PlayArrowIcon from "@mui/icons-material/PlayArrow"; | import PlayArrowIcon from "@mui/icons-material/PlayArrow"; | ||||
| import { PurchaseOrderLine } from "@/app/api/po"; | import { PurchaseOrderLine } from "@/app/api/po"; | ||||
| import { StockInLine } from "@/app/api/stockIn"; | 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 { usePathname, useSearchParams } from "next/navigation"; | ||||
| import { | import { | ||||
| returnWeightUnit, | returnWeightUnit, | ||||
| @@ -69,39 +69,22 @@ import { SessionWithTokens } from "@/config/authConfig"; | |||||
| import { EscalationCombo } from "@/app/api/user"; | import { EscalationCombo } from "@/app/api/user"; | ||||
| import { deleteDialog } from "../Swal/CustomAlerts"; | import { deleteDialog } from "../Swal/CustomAlerts"; | ||||
| import StockInLineRowActions from "./StockInLineRowActions"; | 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<string, number> = { | |||||
| dnNo: 1, | |||||
| productLotNo: 1, | |||||
| uom: 1.5, | |||||
| stockQty: 1, | |||||
| stockUom: 1.5, | |||||
| }; | |||||
| /** Tighter horizontal padding for narrow data columns (headers unchanged). */ | /** Tighter horizontal padding for narrow data columns (headers unchanged). */ | ||||
| const COMPACT_STOCK_IN_CELL_FIELDS = [ | 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({ | function PoInputGrid({ | ||||
| // qc, | // qc, | ||||
| setRows, | setRows, | ||||
| @@ -188,8 +171,6 @@ function PoInputGrid({ | |||||
| const theme = useTheme(); | const theme = useTheme(); | ||||
| /** Narrow phones: hide low-priority columns. */ | /** Narrow phones: hide low-priority columns. */ | ||||
| const isCompact = useMediaQuery(theme.breakpoints.down("md"), { noSsr: true }); | 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 apiRef = useGridApiRef(); | ||||
| const [rowModesModel, setRowModesModel] = useState<GridRowModesModel>({}); | const [rowModesModel, setRowModesModel] = useState<GridRowModesModel>({}); | ||||
| const getRowId = useCallback<GridRowIdGetter<StockInLineRow>>( | const getRowId = useCallback<GridRowIdGetter<StockInLineRow>>( | ||||
| @@ -215,6 +196,8 @@ function PoInputGrid({ | |||||
| const [btnIsLoading, setBtnIsLoading] = useState(false); | const [btnIsLoading, setBtnIsLoading] = useState(false); | ||||
| const [isDeleting, setIsDeleting] = useState(false); | const [isDeleting, setIsDeleting] = useState(false); | ||||
| const deleteInFlightRef = useRef(false); | const deleteInFlightRef = useRef(false); | ||||
| const roundInFlightRef = useRef(false); | |||||
| const [roundingSilId, setRoundingSilId] = useState<number | null>(null); | |||||
| const [currQty, setCurrQty] = useState(() => { | const [currQty, setCurrQty] = useState(() => { | ||||
| const total = entries.reduce( | const total = entries.reduce( | ||||
| // remaining qty (M18 unit) | // remaining qty (M18 unit) | ||||
| @@ -248,6 +231,16 @@ function PoInputGrid({ | |||||
| const handleSoftDelete = useCallback( | const handleSoftDelete = useCallback( | ||||
| (row: StockInLineRow) => { | (row: StockInLineRow) => { | ||||
| if (deleteInFlightRef.current || isDeleting) return; | if (deleteInFlightRef.current || isDeleting) return; | ||||
| if ( | |||||
| needsPoQcStockQtyRound( | |||||
| row.purchaseOrderLineId, | |||||
| row.status, | |||||
| row.acceptedQty, | |||||
| ) | |||||
| ) { | |||||
| alert("請先在換算庫存數量選擇向上或向下取整"); | |||||
| return; | |||||
| } | |||||
| const rowId = row.id as number; | const rowId = row.id as number; | ||||
| const isDraft = row._isNew || row.status === "draft"; | const isDraft = row._isNew || row.status === "draft"; | ||||
| @@ -280,6 +273,50 @@ function PoInputGrid({ | |||||
| }, | }, | ||||
| [fetchPoDetail, handleDelete, isDeleting, itemDetail.id, itemDetail.purchaseOrderId, t], | [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(() => { | const closeQcModal = useCallback(() => { | ||||
| setQcOpen(false); | setQcOpen(false); | ||||
| @@ -434,6 +471,16 @@ function PoInputGrid({ | |||||
| const handleNewQC = useCallback( | const handleNewQC = useCallback( | ||||
| (id: GridRowId, params: any) => async() => { | (id: GridRowId, params: any) => async() => { | ||||
| if (!params?.row) return; | if (!params?.row) return; | ||||
| if ( | |||||
| needsPoQcStockQtyRound( | |||||
| params.row.purchaseOrderLineId, | |||||
| params.row.status, | |||||
| params.row.acceptedQty, | |||||
| ) | |||||
| ) { | |||||
| alert("請先在換算庫存數量選擇向上或向下取整"); | |||||
| return; | |||||
| } | |||||
| setRowModesModel((prev) => ({ | setRowModesModel((prev) => ({ | ||||
| ...prev, | ...prev, | ||||
| [id]: { mode: GridRowModes.View }, | [id]: { mode: GridRowModes.View }, | ||||
| @@ -645,7 +692,8 @@ function PoInputGrid({ | |||||
| { | { | ||||
| field: "dnNo", | field: "dnNo", | ||||
| headerName: t("dnNo"), | headerName: t("dnNo"), | ||||
| width: 92, | |||||
| width: 100, | |||||
| minWidth: 100, | |||||
| }, | }, | ||||
| { | { | ||||
| field: "receiptDate", | field: "receiptDate", | ||||
| @@ -656,12 +704,15 @@ function PoInputGrid({ | |||||
| { | { | ||||
| field: "productLotNo", | field: "productLotNo", | ||||
| headerName: t("productLotNo"), | headerName: t("productLotNo"), | ||||
| width: 100, | |||||
| width: 110, | |||||
| minWidth: 110, | |||||
| }, | }, | ||||
| { | { | ||||
| field: "purchaseAcceptedQty", | field: "purchaseAcceptedQty", | ||||
| headerName: t("acceptedQty"), | headerName: t("acceptedQty"), | ||||
| width: 84, | |||||
| width: PURCHASE_QTY_COLUMN_WIDTH, | |||||
| minWidth: PURCHASE_QTY_COLUMN_WIDTH, | |||||
| flex: 0, | |||||
| align: "right", | align: "right", | ||||
| headerAlign: "right", | headerAlign: "right", | ||||
| type: "number", | type: "number", | ||||
| @@ -673,7 +724,9 @@ function PoInputGrid({ | |||||
| { | { | ||||
| field: "uom", | field: "uom", | ||||
| headerName: t("uom"), | headerName: t("uom"), | ||||
| width: 156, | |||||
| width: UOM_COLUMN_WIDTH, | |||||
| minWidth: UOM_COLUMN_WIDTH, | |||||
| flex: 0, | |||||
| renderCell: () => { | renderCell: () => { | ||||
| const text = itemDetail.uom?.udfudesc ?? "-"; | const text = itemDetail.uom?.udfudesc ?? "-"; | ||||
| return ( | return ( | ||||
| @@ -694,17 +747,23 @@ function PoInputGrid({ | |||||
| { | { | ||||
| field: "stockQty", | field: "stockQty", | ||||
| headerName: t("Stock In Qty"), | headerName: t("Stock In Qty"), | ||||
| width: 125, | |||||
| width: STOCK_QTY_COLUMN_WIDTH, | |||||
| minWidth: STOCK_QTY_COLUMN_WIDTH, | |||||
| flex: 0, | |||||
| type: "number", | type: "number", | ||||
| align: "left", | |||||
| headerAlign: "left", | |||||
| renderCell: (params) => { | renderCell: (params) => { | ||||
| const stockQty = params.row.acceptedQty ?? 0; | |||||
| const stockQty = Number(params.row.acceptedQty ?? 0); | |||||
| return decimalFormatter.format(stockQty); | return decimalFormatter.format(stockQty); | ||||
| }, | }, | ||||
| }, | }, | ||||
| { | { | ||||
| field: "stockUom", | field: "stockUom", | ||||
| headerName: t("Stock UoM"), | headerName: t("Stock UoM"), | ||||
| width: 124, | |||||
| width: UOM_COLUMN_WIDTH, | |||||
| minWidth: UOM_COLUMN_WIDTH, | |||||
| flex: 0, | |||||
| renderCell: () => { | renderCell: () => { | ||||
| const text = itemDetail.stockUom.stockUomDesc ?? "-"; | const text = itemDetail.stockUom.stockUomDesc ?? "-"; | ||||
| return ( | return ( | ||||
| @@ -748,6 +807,8 @@ function PoInputGrid({ | |||||
| field: "actions", | field: "actions", | ||||
| headerName: "操作", | headerName: "操作", | ||||
| width: ACTIONS_COLUMN_WIDTH, | width: ACTIONS_COLUMN_WIDTH, | ||||
| minWidth: ACTIONS_COLUMN_WIDTH, | |||||
| flex: 0, | |||||
| sortable: false, | sortable: false, | ||||
| filterable: false, | filterable: false, | ||||
| disableColumnMenu: true, | disableColumnMenu: true, | ||||
| @@ -760,6 +821,11 @@ function PoInputGrid({ | |||||
| status === "rejected" || status === "partially_completed"; | status === "rejected" || status === "partially_completed"; | ||||
| const canPrint = status === "received"; | const canPrint = status === "received"; | ||||
| const canDelete = canDeleteStockInLine(data); | const canDelete = canDeleteStockInLine(data); | ||||
| const needsStockQtyRound = needsPoQcStockQtyRound( | |||||
| data.purchaseOrderLineId, | |||||
| data.status, | |||||
| data.acceptedQty, | |||||
| ); | |||||
| return ( | return ( | ||||
| <StockInLineRowActions | <StockInLineRowActions | ||||
| @@ -777,31 +843,42 @@ function PoInputGrid({ | |||||
| onDelete={() => handleSoftDelete(data)} | onDelete={() => handleSoftDelete(data)} | ||||
| btnIsLoading={btnIsLoading} | btnIsLoading={btnIsLoading} | ||||
| isDeleting={isDeleting} | 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) => { | 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, | t, | ||||
| isTablet, | |||||
| itemDetail, | itemDetail, | ||||
| handleNewQC, | handleNewQC, | ||||
| handleMailTemplateForStockInLine, | handleMailTemplateForStockInLine, | ||||
| printQrcode, | printQrcode, | ||||
| handleSoftDelete, | handleSoftDelete, | ||||
| handleRoundStockQty, | |||||
| roundingSilId, | |||||
| btnIsLoading, | btnIsLoading, | ||||
| isDeleting, | isDeleting, | ||||
| sessionToken?.id, | 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 ( | return ( | ||||
| <> | <> | ||||
| @@ -945,7 +1016,7 @@ function PoInputGrid({ | |||||
| columnVisibilityModel={columnVisibilityModel} | columnVisibilityModel={columnVisibilityModel} | ||||
| sx={{ | sx={{ | ||||
| width: "100%", | width: "100%", | ||||
| minWidth: isTablet ? undefined : STOCK_IN_GRID_MIN_WIDTH_DESKTOP, | |||||
| minWidth: 0, | |||||
| "--DataGrid-overlayHeight": "100px", | "--DataGrid-overlayHeight": "100px", | ||||
| ".MuiDataGrid-row .MuiDataGrid-cell.hasError": { | ".MuiDataGrid-row .MuiDataGrid-cell.hasError": { | ||||
| border: "1px solid", | border: "1px solid", | ||||
| @@ -957,14 +1028,39 @@ function PoInputGrid({ | |||||
| }, | }, | ||||
| "& .MuiDataGrid-cell.actions": { | "& .MuiDataGrid-cell.actions": { | ||||
| overflow: "visible", | overflow: "visible", | ||||
| alignItems: "flex-start", | |||||
| py: 0.75, | |||||
| alignItems: "center", | |||||
| py: 0.5, | |||||
| lineHeight: "normal", | 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']": { | "& .MuiDataGrid-cell[data-field='actions']": { | ||||
| py: 0.75, | |||||
| py: 0.5, | |||||
| px: 1, | 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( | ...Object.fromEntries( | ||||
| COMPACT_STOCK_IN_CELL_FIELDS.flatMap((field) => [ | COMPACT_STOCK_IN_CELL_FIELDS.flatMap((field) => [ | ||||
| [ | [ | ||||
| @@ -71,7 +71,7 @@ interface CommonProps extends Omit<ModalProps, "children"> { | |||||
| interface Props extends CommonProps { | interface Props extends CommonProps { | ||||
| // itemDetail: StockInLine & { qcResult?: PurchaseQcResult[] } & { escResult?: EscalationResult[] }; | // 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<Props> = ({ | const PoQcStockInModalVer2: React.FC<Props> = ({ | ||||
| open, | open, | ||||
| onClose, | onClose, | ||||
| @@ -2,6 +2,7 @@ | |||||
| import { Box, Button } from "@mui/material"; | import { Box, Button } from "@mui/material"; | ||||
| import { useTranslation } from "react-i18next"; | import { useTranslation } from "react-i18next"; | ||||
| import { roundStockQty, StockQtyRoundMode } from "./stockQtyRound"; | |||||
| export type StockInLineActionStyle = { | export type StockInLineActionStyle = { | ||||
| label: string; | label: string; | ||||
| @@ -19,8 +20,13 @@ type Props = { | |||||
| onDelete: () => void; | onDelete: () => void; | ||||
| btnIsLoading: boolean; | btnIsLoading: boolean; | ||||
| isDeleting: 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({ | export default function StockInLineRowActions({ | ||||
| btnSx, | btnSx, | ||||
| onPrimaryClick, | onPrimaryClick, | ||||
| @@ -32,83 +38,119 @@ export default function StockInLineRowActions({ | |||||
| onDelete, | onDelete, | ||||
| btnIsLoading, | btnIsLoading, | ||||
| isDeleting, | isDeleting, | ||||
| needsStockQtyRound = false, | |||||
| stockQty = 0, | |||||
| isRounding = false, | |||||
| onRound, | |||||
| }: Props) { | }: Props) { | ||||
| const { t } = useTranslation("purchaseOrder"); | const { t } = useTranslation("purchaseOrder"); | ||||
| const buttonSx = { | const buttonSx = { | ||||
| whiteSpace: "nowrap" as const, | whiteSpace: "nowrap" as const, | ||||
| fontSize: 14, | |||||
| flexShrink: 0, | |||||
| width: 176, | |||||
| minWidth: 176, | |||||
| maxWidth: 176, | |||||
| px: 1.5, | px: 1.5, | ||||
| py: 0.75, | |||||
| minHeight: 34, | |||||
| width: "100%", | |||||
| py: 1, | |||||
| minHeight: 44, | |||||
| height: 44, | |||||
| fontSize: 16, | |||||
| fontWeight: 700, | |||||
| justifyContent: "center", | justifyContent: "center", | ||||
| boxSizing: "border-box" as const, | |||||
| }; | }; | ||||
| return ( | return ( | ||||
| <Box | <Box | ||||
| sx={{ | sx={{ | ||||
| display: "flex", | display: "flex", | ||||
| flexDirection: "column", | |||||
| alignItems: "stretch", | |||||
| flexDirection: "row", | |||||
| flexWrap: "nowrap", | |||||
| alignItems: "center", | |||||
| gap: 0.75, | gap: 0.75, | ||||
| width: "100%", | width: "100%", | ||||
| py: 0.5, | |||||
| py: 0.25, | |||||
| boxSizing: "border-box", | boxSizing: "border-box", | ||||
| }} | }} | ||||
| onClick={(e) => e.stopPropagation()} | onClick={(e) => e.stopPropagation()} | ||||
| > | > | ||||
| <Button | |||||
| variant="contained" | |||||
| size="small" | |||||
| sx={{ | |||||
| ...buttonSx, | |||||
| backgroundColor: btnSx.color, | |||||
| }} | |||||
| onClick={onPrimaryClick} | |||||
| > | |||||
| {btnSx.label} | |||||
| </Button> | |||||
| {canEmail && ( | |||||
| <Button | |||||
| id="emailSupplier" | |||||
| type="button" | |||||
| variant="contained" | |||||
| color="primary" | |||||
| size="small" | |||||
| sx={buttonSx} | |||||
| onClick={onEmail} | |||||
| > | |||||
| {t("email supplier")} | |||||
| </Button> | |||||
| )} | |||||
| {canPrint && ( | |||||
| <Button | |||||
| id="printQrCode" | |||||
| type="button" | |||||
| variant="contained" | |||||
| size="small" | |||||
| sx={{ | |||||
| ...buttonSx, | |||||
| backgroundColor: "#7f434a", | |||||
| }} | |||||
| disabled={btnIsLoading} | |||||
| onClick={onPrint} | |||||
| > | |||||
| {t("printQrCode")} | |||||
| </Button> | |||||
| )} | |||||
| {canDelete && ( | |||||
| <Button | |||||
| variant="outlined" | |||||
| color="error" | |||||
| size="small" | |||||
| sx={buttonSx} | |||||
| disabled={isDeleting || btnIsLoading} | |||||
| onClick={onDelete} | |||||
| > | |||||
| {t("delete")} | |||||
| </Button> | |||||
| {needsStockQtyRound ? ( | |||||
| <> | |||||
| <Button | |||||
| variant="contained" | |||||
| size="medium" | |||||
| disabled={isRounding} | |||||
| onClick={() => onRound?.("CEILING")} | |||||
| sx={buttonSx} | |||||
| > | |||||
| {t("Round ceiling")} {roundStockQty(stockQty, "CEILING")} | |||||
| </Button> | |||||
| <Button | |||||
| variant="outlined" | |||||
| size="medium" | |||||
| disabled={isRounding} | |||||
| onClick={() => onRound?.("FLOOR")} | |||||
| sx={buttonSx} | |||||
| > | |||||
| {t("Round floor")} {roundStockQty(stockQty, "FLOOR")} | |||||
| </Button> | |||||
| </> | |||||
| ) : ( | |||||
| <> | |||||
| <Button | |||||
| variant="contained" | |||||
| size="medium" | |||||
| sx={{ | |||||
| ...buttonSx, | |||||
| backgroundColor: btnSx.color, | |||||
| }} | |||||
| onClick={onPrimaryClick} | |||||
| > | |||||
| {btnSx.label} | |||||
| </Button> | |||||
| {canEmail && ( | |||||
| <Button | |||||
| id="emailSupplier" | |||||
| type="button" | |||||
| variant="contained" | |||||
| color="primary" | |||||
| size="medium" | |||||
| sx={buttonSx} | |||||
| onClick={onEmail} | |||||
| > | |||||
| {t("email supplier")} | |||||
| </Button> | |||||
| )} | |||||
| {canPrint && ( | |||||
| <Button | |||||
| id="printQrCode" | |||||
| type="button" | |||||
| variant="contained" | |||||
| size="medium" | |||||
| sx={{ | |||||
| ...buttonSx, | |||||
| backgroundColor: "#7f434a", | |||||
| }} | |||||
| disabled={btnIsLoading} | |||||
| onClick={onPrint} | |||||
| > | |||||
| {t("printQrCode")} | |||||
| </Button> | |||||
| )} | |||||
| {canDelete && ( | |||||
| <Button | |||||
| variant="outlined" | |||||
| color="error" | |||||
| size="medium" | |||||
| sx={buttonSx} | |||||
| disabled={isDeleting || btnIsLoading} | |||||
| onClick={onDelete} | |||||
| > | |||||
| {t("delete")} | |||||
| </Button> | |||||
| )} | |||||
| </> | |||||
| )} | )} | ||||
| </Box> | </Box> | ||||
| ); | ); | ||||
| @@ -7,6 +7,7 @@ export type StockQtyRoundChoice = { | |||||
| after: number; | after: number; | ||||
| }; | }; | ||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.3 | 2026-09-10 */ | |||||
| export function previewPoBatchStockQty( | export function previewPoBatchStockQty( | ||||
| orderM18Qty: number, | orderM18Qty: number, | ||||
| orderStockQty: number, | orderStockQty: number, | ||||
| @@ -23,7 +24,10 @@ export function isNotIntegerQty(qty: number): boolean { | |||||
| return Math.abs(qty - Math.round(qty)) > 1e-9; | 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( | export function needsPoQcStockQtyRound( | ||||
| purchaseOrderLineId: number | null | undefined, | purchaseOrderLineId: number | null | undefined, | ||||
| status: string | null | undefined, | status: string | null | undefined, | ||||
| @@ -35,6 +39,7 @@ export function needsPoQcStockQtyRound( | |||||
| return isNotIntegerQty(Number(acceptedQty ?? 0)); | 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 { | export function roundStockQty(before: number, mode: StockQtyRoundMode): number { | ||||
| if (mode === "CEILING") return Math.ceil(before); | if (mode === "CEILING") return Math.ceil(before); | ||||
| return Math.floor(before); | return Math.floor(before); | ||||
| @@ -29,6 +29,7 @@ type SearchParamNames = keyof SearchQuery; | |||||
| // cal offset (pageSize) | // cal offset (pageSize) | ||||
| // cal limit (pageSize) | // cal limit (pageSize) | ||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 76 | v1.0.0 | 2026-09-07 */ | |||||
| const PoSearch: React.FC<Props> = ({ | const PoSearch: React.FC<Props> = ({ | ||||
| po, | po, | ||||
| totalCount: initTotalCount, | totalCount: initTotalCount, | ||||
| @@ -150,7 +151,7 @@ const PoSearch: React.FC<Props> = ({ | |||||
| return <Grid>"N/A"</Grid> | return <Grid>"N/A"</Grid> | ||||
| } | } | ||||
| const items = value.split(",") | const items = value.split(",") | ||||
| return items.map((item) => <Grid key={item}>{item}</Grid>) | |||||
| return items.map((item, index) => <Grid key={`${index}-${item}`}>{item}</Grid>) | |||||
| }, []) | }, []) | ||||
| const columns = useMemo<Column<PoResult>[]>( | const columns = useMemo<Column<PoResult>[]>( | ||||
| @@ -279,8 +280,8 @@ const PoSearch: React.FC<Props> = ({ | |||||
| pagingController: Record<string, number>, | pagingController: Record<string, number>, | ||||
| filterArgs: Record<string, number>, | filterArgs: Record<string, number>, | ||||
| ) => { | ) => { | ||||
| console.log(pagingController); | |||||
| console.log(filterArgs); | |||||
| // console.log(pagingController); | |||||
| //console.log(filterArgs); | |||||
| const params = { | const params = { | ||||
| ...pagingController, | ...pagingController, | ||||
| ...filterArgs, | ...filterArgs, | ||||
| @@ -390,7 +391,7 @@ const PoSearch: React.FC<Props> = ({ | |||||
| ); | ); | ||||
| useEffect(() => { | useEffect(() => { | ||||
| console.log(filteredPo) | |||||
| //console.log(filteredPo) | |||||
| }, [filteredPo]) | }, [filteredPo]) | ||||
| useEffect(() => { | useEffect(() => { | ||||
| @@ -415,7 +416,7 @@ const PoSearch: React.FC<Props> = ({ | |||||
| disabled={isM18LookupLoading} | disabled={isM18LookupLoading} | ||||
| onSearch={(query) => { | onSearch={(query) => { | ||||
| if (isM18LookupLoading) return; | if (isM18LookupLoading) return; | ||||
| console.log(query); | |||||
| //console.log(query); | |||||
| const code = typeof query.code === "string" ? query.code.trim() : ""; | const code = typeof query.code === "string" ? query.code.trim() : ""; | ||||
| if (code) { | if (code) { | ||||
| // When PO code is provided, ignore other search criteria (especially date ranges). | // When PO code is provided, ignore other search criteria (especially date ranges). | ||||
| @@ -23,7 +23,7 @@ import QcComponent from "./QcComponent"; | |||||
| import PutAwayForm from "../PoDetail/PutAwayForm"; | import PutAwayForm from "../PoDetail/PutAwayForm"; | ||||
| import { GridRowModes, GridRowSelectionModel, useGridApiRef } from "@mui/x-data-grid"; | import { GridRowModes, GridRowSelectionModel, useGridApiRef } from "@mui/x-data-grid"; | ||||
| import {msg, submitDialogWithWarning} from "../Swal/CustomAlerts"; | 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 { INPUT_DATE_FORMAT, arrayToDateString, dayjsToDateTimeString } from "@/app/utils/formatUtil"; | ||||
| import dayjs from "dayjs"; | import dayjs from "dayjs"; | ||||
| import { fetchPoQrcode } from "@/app/api/pdf/actions"; | import { fetchPoQrcode } from "@/app/api/pdf/actions"; | ||||
| @@ -73,7 +73,7 @@ interface Props extends CommonProps { | |||||
| // itemDetail: StockInLine & { qcResult?: PurchaseQcResult[] } & { escResult?: EscalationResult[] }; | // 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<Props> = ({ | const QcStockInModal: React.FC<Props> = ({ | ||||
| open, | open, | ||||
| onClose, | onClose, | ||||
| @@ -243,7 +243,6 @@ const QcStockInModal: React.FC<Props> = ({ | |||||
| ...defaultNewValue, | ...defaultNewValue, | ||||
| }, | }, | ||||
| }); | }); | ||||
| const stockQtyRoundMode = formProps.watch("stockQtyRoundMode"); | |||||
| const qcDecision = formProps.watch("qcDecision"); | const qcDecision = formProps.watch("qcDecision"); | ||||
| const roundChoiceRequired = useMemo(() => { | const roundChoiceRequired = useMemo(() => { | ||||
| const needsRound = needsPoQcStockQtyRound( | const needsRound = needsPoQcStockQtyRound( | ||||
| @@ -252,19 +251,13 @@ const QcStockInModal: React.FC<Props> = ({ | |||||
| stockInLineInfo?.acceptedQty, | stockInLineInfo?.acceptedQty, | ||||
| ); | ); | ||||
| const willAcceptStock = Boolean(skipQc) || qcDecision == 1; | const willAcceptStock = Boolean(skipQc) || qcDecision == 1; | ||||
| return ( | |||||
| needsRound && | |||||
| willAcceptStock && | |||||
| stockQtyRoundMode !== "CEILING" && | |||||
| stockQtyRoundMode !== "FLOOR" | |||||
| ); | |||||
| return needsRound && willAcceptStock; | |||||
| }, [ | }, [ | ||||
| stockInLineInfo?.purchaseOrderLineId, | stockInLineInfo?.purchaseOrderLineId, | ||||
| stockInLineInfo?.status, | stockInLineInfo?.status, | ||||
| stockInLineInfo?.acceptedQty, | stockInLineInfo?.acceptedQty, | ||||
| skipQc, | skipQc, | ||||
| qcDecision, | qcDecision, | ||||
| stockQtyRoundMode, | |||||
| ]); | ]); | ||||
| const closeWithResult = useCallback( | const closeWithResult = useCallback( | ||||
| @@ -444,23 +437,9 @@ const QcStockInModal: React.FC<Props> = ({ | |||||
| storedStockQty, | storedStockQty, | ||||
| ); | ); | ||||
| const willAcceptStock = Boolean(skipQc) || qcAcceptLocal; | const willAcceptStock = Boolean(skipQc) || qcAcceptLocal; | ||||
| let roundPayload: | |||||
| | { | |||||
| stockQtyRoundMode: "CEILING" | "FLOOR"; | |||||
| stockQtyRoundSource: "QC"; | |||||
| } | |||||
| | undefined; | |||||
| if (willAcceptStock && needsStockQtyRound) { | 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"; | const isJobOrderSource = Boolean(stockInLineInfo?.jobOrderId) || printSource === "productionProcess"; | ||||
| @@ -476,7 +455,6 @@ const QcStockInModal: React.FC<Props> = ({ | |||||
| // For Job Order QC, allow updating received qty beyond demand/accepted. | // For Job Order QC, allow updating received qty beyond demand/accepted. | ||||
| // Backend uses request.acceptedQty in QC flow, so we must send it explicitly. | // Backend uses request.acceptedQty in QC flow, so we must send it explicitly. | ||||
| acceptedQty: (qcAcceptLocal && isJobOrderSource) ? (acceptQtyLocal ? acceptQtyLocal : 0) : stockInLineInfo?.acceptedQty, | acceptedQty: (qcAcceptLocal && isJobOrderSource) ? (acceptQtyLocal ? acceptQtyLocal : 0) : stockInLineInfo?.acceptedQty, | ||||
| ...roundPayload, | |||||
| // qcResult: itemDetail.status != "escalated" ? qcResults.map(item => ({ | // qcResult: itemDetail.status != "escalated" ? qcResults.map(item => ({ | ||||
| qcResult: qcResultsLocal.map(item => ({ | qcResult: qcResultsLocal.map(item => ({ | ||||
| // id: item.id, | // id: item.id, | ||||
| @@ -10,24 +10,17 @@ import { | |||||
| CardContent, | CardContent, | ||||
| Grid, | Grid, | ||||
| InputAdornment, | InputAdornment, | ||||
| Stack, | |||||
| TextField, | TextField, | ||||
| Tooltip, | Tooltip, | ||||
| Typography, | |||||
| } from "@mui/material"; | } from "@mui/material"; | ||||
| import { Controller, useFormContext } from "react-hook-form"; | import { Controller, useFormContext } from "react-hook-form"; | ||||
| import { useTranslation } from "react-i18next"; | import { useTranslation } from "react-i18next"; | ||||
| import StyledDataGrid from "../StyledDataGrid"; | import StyledDataGrid from "../StyledDataGrid"; | ||||
| import { useCallback, useEffect, useState, useMemo } from "react"; | |||||
| import { useCallback, useEffect, useState } from "react"; | |||||
| import { | import { | ||||
| useGridApiRef, | useGridApiRef, | ||||
| } from "@mui/x-data-grid"; | } from "@mui/x-data-grid"; | ||||
| import { StockInLine } from "@/app/api/stockIn"; | import { StockInLine } from "@/app/api/stockIn"; | ||||
| import { | |||||
| needsPoQcStockQtyRound, | |||||
| roundStockQty, | |||||
| StockQtyRoundMode, | |||||
| } from "@/components/PoDetail/stockQtyRound"; | |||||
| import { DatePicker, LocalizationProvider } from "@mui/x-date-pickers"; | import { DatePicker, LocalizationProvider } from "@mui/x-date-pickers"; | ||||
| import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; | import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; | ||||
| import { dayjsToDateString, INPUT_DATE_FORMAT, OUTPUT_DATE_FORMAT } from "@/app/utils/formatUtil"; | import { dayjsToDateString, INPUT_DATE_FORMAT, OUTPUT_DATE_FORMAT } from "@/app/utils/formatUtil"; | ||||
| @@ -119,36 +112,8 @@ const StockInForm: React.FC<Props> = ({ | |||||
| const productionDate = watch("productionDate"); | const productionDate = watch("productionDate"); | ||||
| const expiryDate = watch("expiryDate"); | const expiryDate = watch("expiryDate"); | ||||
| const uom = watch("uom"); | const uom = watch("uom"); | ||||
| const stockQtyRoundMode = watch("stockQtyRoundMode"); | |||||
| const displayedAcceptedQty = watch("acceptedQty"); | const displayedAcceptedQty = watch("acceptedQty"); | ||||
| const originalAcceptedQty = Number(itemDetail.acceptedQty ?? 0); | 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<boolean>(false); | const [openModal, setOpenModal] = useState<boolean>(false); | ||||
| const [openExpDatePicker, setOpenExpDatePicker] = useState<boolean>(false); | const [openExpDatePicker, setOpenExpDatePicker] = useState<boolean>(false); | ||||
| @@ -449,33 +414,6 @@ const StockInForm: React.FC<Props> = ({ | |||||
| })} | })} | ||||
| /> | /> | ||||
| </Grid> | </Grid> | ||||
| {showStockQtyRoundButtons && ( | |||||
| <Grid item xs={6}> | |||||
| <Stack spacing={0.75} justifyContent="flex-end" sx={{ height: "100%" }}> | |||||
| <Typography variant="caption" color="text.secondary"> | |||||
| {t("Please choose a rounding method")} | |||||
| </Typography> | |||||
| <Stack direction="row" spacing={1} alignItems="stretch"> | |||||
| <Button | |||||
| variant={stockQtyRoundMode === "CEILING" ? "contained" : "outlined"} | |||||
| size={compactFields ? "medium" : "large"} | |||||
| onClick={() => selectStockQtyRound("CEILING")} | |||||
| sx={{ flex: 1, fontWeight: 700 }} | |||||
| > | |||||
| {t("Round ceiling")} {roundUpQty}{stockUomLabel} | |||||
| </Button> | |||||
| <Button | |||||
| variant={stockQtyRoundMode === "FLOOR" ? "contained" : "outlined"} | |||||
| size={compactFields ? "medium" : "large"} | |||||
| onClick={() => selectStockQtyRound("FLOOR")} | |||||
| sx={{ flex: 1, fontWeight: 700 }} | |||||
| > | |||||
| {t("Round floor")} {roundDownQty}{stockUomLabel} | |||||
| </Button> | |||||
| </Stack> | |||||
| </Stack> | |||||
| </Grid> | |||||
| )} | |||||
| </> | </> | ||||
| )} | )} | ||||
| {/* <Grid item xs={4}> | {/* <Grid item xs={4}> | ||||
| @@ -11,42 +11,168 @@ import SearchResults, { Column } from "@/components/SearchResults/index"; | |||||
| import { SessionWithTokens } from "@/config/authConfig"; | import { SessionWithTokens } from "@/config/authConfig"; | ||||
| import { | import { | ||||
| batchSubmitExpiryItem, | batchSubmitExpiryItem, | ||||
| ExpiryItemFilter, | |||||
| ExpiryItemResult, | ExpiryItemResult, | ||||
| fetchExpiryItemList, | fetchExpiryItemList, | ||||
| submitExpiryItem, | submitExpiryItem, | ||||
| } from "@/app/api/stockIssue/actions"; | } 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"; | import { useSession } from "next-auth/react"; | ||||
| type SearchQuery = { | type SearchQuery = { | ||||
| itemCode: string; | itemCode: string; | ||||
| itemName: string; | itemName: string; | ||||
| expiryDate: string; | |||||
| lotNo: string; | |||||
| }; | }; | ||||
| type SearchParamNames = keyof SearchQuery; | 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 ExpiryHandleTab: React.FC = () => { | ||||
| const BATCH_CHUNK_SIZE = 20; | const BATCH_CHUNK_SIZE = 20; | ||||
| const { t } = useTranslation("stockIssue"); | const { t } = useTranslation("stockIssue"); | ||||
| const { t: tCommon } = useTranslation("common"); | |||||
| const { data: session } = useSession() as { data: SessionWithTokens | null }; | const { data: session } = useSession() as { data: SessionWithTokens | null }; | ||||
| const currentUserId = session?.id ? parseInt(session.id) : undefined; | const currentUserId = session?.id ? parseInt(session.id) : undefined; | ||||
| const [expiryItems, setExpiryItems] = useState<ExpiryItemResult[]>([]); | const [expiryItems, setExpiryItems] = useState<ExpiryItemResult[]>([]); | ||||
| const [lastFilters, setLastFilters] = useState<ExpiryItemFilter>({ | |||||
| daysAhead: DEFAULT_DAYS_AHEAD, | |||||
| }); | |||||
| const [hasSearched, setHasSearched] = useState(false); | |||||
| const [resultTab, setResultTab] = useState<ResultBucket>("expired"); | |||||
| const [submittingIds, setSubmittingIds] = useState<Set<number>>(new Set()); | const [submittingIds, setSubmittingIds] = useState<Set<number>>(new Set()); | ||||
| const [batchSubmitting, setBatchSubmitting] = useState(false); | const [batchSubmitting, setBatchSubmitting] = useState(false); | ||||
| const [batchConfirmOpen, setBatchConfirmOpen] = useState(false); | |||||
| const [batchProgress, setBatchProgress] = useState<{ | const [batchProgress, setBatchProgress] = useState<{ | ||||
| done: number; | done: number; | ||||
| total: number; | total: number; | ||||
| } | null>(null); | } | null>(null); | ||||
| const expirySubmitInFlightRef = useRef<Set<number>>(new Set()); | const expirySubmitInFlightRef = useRef<Set<number>>(new Set()); | ||||
| const batchSubmitInFlightRef = useRef(false); | 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 [paging, setPaging] = useState({ pageNum: 1, pageSize: 10 }); | ||||
| const [daysAheadDraft, setDaysAheadDraft] = useState(String(DEFAULT_DAYS_AHEAD)); | |||||
| const daysAhead = lastFilters.daysAhead ?? DEFAULT_DAYS_AHEAD; | |||||
| const itemsByBucket = useMemo(() => { | |||||
| const expired: ExpiryItemResult[] = []; | |||||
| const today: ExpiryItemResult[] = []; | |||||
| const upcoming: ExpiryItemResult[] = []; | |||||
| for (const item of expiryItems) { | |||||
| const bucket = getExpiryBucket(item, daysAhead); | |||||
| if (bucket === "expired") expired.push(item); | |||||
| else if (bucket === "today") today.push(item); | |||||
| else if (bucket === "upcoming") upcoming.push(item); | |||||
| } | |||||
| return { | |||||
| expired, | |||||
| today, | |||||
| upcoming, | |||||
| }; | |||||
| }, [expiryItems, daysAhead]); | |||||
| const tabItems = itemsByBucket[resultTab]; | |||||
| const handleableIds = useMemo( | |||||
| () => tabItems.filter(canHandleExpiryItem).map((item) => item.id), | |||||
| [tabItems], | |||||
| ); | |||||
| const searchFields: StockIssueSearchField<SearchParamNames>[] = useMemo( | const searchFields: StockIssueSearchField<SearchParamNames>[] = useMemo( | ||||
| () => [ | () => [ | ||||
| { name: "itemCode", label: t("Item Code"), type: "text" }, | { name: "itemCode", label: t("Item Code"), type: "text" }, | ||||
| { name: "itemName", label: t("Item"), 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], | [t], | ||||
| ); | ); | ||||
| @@ -62,6 +188,10 @@ const ExpiryHandleTab: React.FC = () => { | |||||
| alert(t("Item not found")); | alert(t("Item not found")); | ||||
| return; | return; | ||||
| } | } | ||||
| if (!canHandleExpiryItem(item)) { | |||||
| alert(t("Not yet due; cannot dispose until the expiry date")); | |||||
| return; | |||||
| } | |||||
| if (expirySubmitInFlightRef.current.has(id)) return; | if (expirySubmitInFlightRef.current.has(id)) return; | ||||
| try { | try { | ||||
| @@ -88,7 +218,7 @@ const ExpiryHandleTab: React.FC = () => { | |||||
| const handleSubmitAll = useCallback(async () => { | const handleSubmitAll = useCallback(async () => { | ||||
| if (!currentUserId) return; | if (!currentUserId) return; | ||||
| if (batchSubmitInFlightRef.current) 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; | if (allIds.length === 0) return; | ||||
| batchSubmitInFlightRef.current = true; | batchSubmitInFlightRef.current = true; | ||||
| @@ -114,7 +244,7 @@ const ExpiryHandleTab: React.FC = () => { | |||||
| setBatchProgress(null); | setBatchProgress(null); | ||||
| batchSubmitInFlightRef.current = false; | batchSubmitInFlightRef.current = false; | ||||
| } | } | ||||
| }, [currentUserId, expiryItems, t]); | |||||
| }, [currentUserId, tabItems, t]); | |||||
| const expiryColumns = useMemo<Column<ExpiryItemResult>[]>( | const expiryColumns = useMemo<Column<ExpiryItemResult>[]>( | ||||
| () => [ | () => [ | ||||
| @@ -126,52 +256,40 @@ const ExpiryHandleTab: React.FC = () => { | |||||
| name: "expiryDate", | name: "expiryDate", | ||||
| label: t("Expiry Date"), | label: t("Expiry Date"), | ||||
| renderCell: (item) => { | 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: "remainingQty", label: t("Remaining Qty") }, | ||||
| { | |||||
| name: "uomDesc", | |||||
| label: t("UoM"), | |||||
| renderCell: (item) => item.uomDesc?.trim() || "—", | |||||
| }, | |||||
| { | { | ||||
| name: "id", | name: "id", | ||||
| label: t("Action"), | label: t("Action"), | ||||
| renderCell: (item) => ( | |||||
| <Button | |||||
| size="small" | |||||
| variant="contained" | |||||
| color="primary" | |||||
| onClick={() => handleSubmitSingle(item.id)} | |||||
| disabled={submittingIds.has(item.id) || !currentUserId} | |||||
| > | |||||
| {submittingIds.has(item.id) ? t("Disposing...") : t("Disposed")} | |||||
| </Button> | |||||
| ), | |||||
| renderCell: (item) => { | |||||
| const canHandle = canHandleExpiryItem(item); | |||||
| const disposing = submittingIds.has(item.id); | |||||
| const button = ( | |||||
| <Button | |||||
| size="small" | |||||
| variant="contained" | |||||
| color="primary" | |||||
| onClick={() => handleSubmitSingle(item.id)} | |||||
| disabled={disposing || !currentUserId || !canHandle} | |||||
| > | |||||
| {disposing ? t("Disposing...") : t("Disposed")} | |||||
| </Button> | |||||
| ); | |||||
| if (canHandle) return button; | |||||
| return ( | |||||
| <Tooltip title={t("Not yet due; cannot dispose until the expiry date")}> | |||||
| <span>{button}</span> | |||||
| </Tooltip> | |||||
| ); | |||||
| }, | |||||
| }, | }, | ||||
| ], | ], | ||||
| [t, handleSubmitSingle, submittingIds, currentUserId], | [t, handleSubmitSingle, submittingIds, currentUserId], | ||||
| @@ -179,49 +297,238 @@ const ExpiryHandleTab: React.FC = () => { | |||||
| const handleSearch = useCallback( | const handleSearch = useCallback( | ||||
| async (query: Record<SearchParamNames, string>) => { | async (query: Record<SearchParamNames, string>) => { | ||||
| if (searchInFlightRef.current) return; | |||||
| const parsedDays = parseDaysAhead(daysAheadDraft); | |||||
| setDaysAheadDraft(String(parsedDays)); | |||||
| setPaging((prev) => ({ ...prev, pageNum: 1 })); | 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 { | 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); | setExpiryItems(result); | ||||
| } catch (error) { | } catch (error) { | ||||
| console.error("Failed to search expiry items:", error); | console.error("Failed to search expiry items:", error); | ||||
| alert(t("Failed to load expiry items")); | alert(t("Failed to load expiry items")); | ||||
| } finally { | |||||
| searchInFlightRef.current = false; | |||||
| } | } | ||||
| }, | }, | ||||
| [t], | |||||
| [t, daysAheadDraft], | |||||
| ); | ); | ||||
| const pagedItems = useMemo(() => { | |||||
| const start = (paging.pageNum - 1) * paging.pageSize; | |||||
| return expiryItems.slice(start, start + paging.pageSize); | |||||
| }, [expiryItems, paging]); | |||||
| const applyDaysAhead = useCallback( | |||||
| async (nextDays: number) => { | |||||
| const parsedDays = parseDaysAhead(nextDays); | |||||
| setDaysAheadDraft(String(parsedDays)); | |||||
| if (parsedDays === daysAhead) return; | |||||
| if (!hasSearched) { | |||||
| setLastFilters((prev) => ({ ...prev, daysAhead: parsedDays })); | |||||
| return; | |||||
| } | |||||
| if (searchInFlightRef.current) return; | |||||
| searchInFlightRef.current = true; | |||||
| try { | |||||
| const filters: ExpiryItemFilter = { | |||||
| ...lastFilters, | |||||
| daysAhead: parsedDays, | |||||
| }; | |||||
| const result = await fetchExpiryItemList(filters); | |||||
| setLastFilters(filters); | |||||
| setExpiryItems(result); | |||||
| setPaging((prev) => ({ ...prev, pageNum: 1 })); | |||||
| } catch (error) { | |||||
| console.error("Failed to search expiry items:", error); | |||||
| alert(t("Failed to load expiry items")); | |||||
| } finally { | |||||
| searchInFlightRef.current = false; | |||||
| } | |||||
| }, | |||||
| [daysAhead, hasSearched, lastFilters, t], | |||||
| ); | |||||
| const handleDaysAheadChange = useCallback( | |||||
| (event: SelectChangeEvent<string>) => { | |||||
| void applyDaysAhead(parseDaysAhead(event.target.value)); | |||||
| }, | |||||
| [applyDaysAhead], | |||||
| ); | |||||
| const handleExportExcel = useCallback( | |||||
| async (mode: "filtered" | "all") => { | |||||
| if (!hasSearched) return; | |||||
| if (exportInFlightRef.current) return; | |||||
| exportInFlightRef.current = true; | |||||
| setExporting(mode); | |||||
| try { | |||||
| await exportExpiryItemExcel( | |||||
| mode === "all" | |||||
| ? { | |||||
| daysAhead, | |||||
| } | |||||
| : { | |||||
| ...lastFilters, | |||||
| bucket: resultTab, | |||||
| }, | |||||
| ); | |||||
| } catch (error) { | |||||
| console.error("Failed to export expiry items:", error); | |||||
| alert(t("Failed to export Excel")); | |||||
| } finally { | |||||
| setExporting(null); | |||||
| exportInFlightRef.current = false; | |||||
| } | |||||
| }, | |||||
| [hasSearched, lastFilters, resultTab, daysAhead, t], | |||||
| ); | |||||
| const handleResultTabChange = useCallback( | |||||
| (_: React.SyntheticEvent, value: string) => { | |||||
| setResultTab(value as ResultBucket); | |||||
| setPaging((prev) => ({ ...prev, pageNum: 1 })); | |||||
| }, | |||||
| [], | |||||
| ); | |||||
| return ( | return ( | ||||
| <Box> | <Box> | ||||
| <StockIssueSearchPanel fields={searchFields} onSearch={handleSearch} /> | <StockIssueSearchPanel fields={searchFields} onSearch={handleSearch} /> | ||||
| <Box sx={{ display: "flex", justifyContent: "flex-end", mb: 1 }}> | |||||
| <Box | |||||
| sx={{ | |||||
| display: "flex", | |||||
| alignItems: "center", | |||||
| justifyContent: "flex-start", | |||||
| gap: 1.5, | |||||
| mb: 2, | |||||
| flexWrap: "wrap", | |||||
| }} | |||||
| > | |||||
| <Tabs | |||||
| value={resultTab} | |||||
| onChange={handleResultTabChange} | |||||
| sx={{ | |||||
| minHeight: 48, | |||||
| "& .MuiTab-root": { minHeight: 48, minWidth: 0, px: 2 }, | |||||
| }} | |||||
| > | |||||
| <Tab | |||||
| value="expired" | |||||
| label={`${t("Already expired")} (${itemsByBucket.expired.length})`} | |||||
| /> | |||||
| <Tab | |||||
| value="today" | |||||
| label={`${t("Expires today")} (${itemsByBucket.today.length})`} | |||||
| /> | |||||
| <Tab | |||||
| value="upcoming" | |||||
| label={`${t("Expires within X days")} (${itemsByBucket.upcoming.length})`} | |||||
| /> | |||||
| </Tabs> | |||||
| <Button | <Button | ||||
| variant="contained" | |||||
| color="primary" | |||||
| onClick={handleSubmitAll} | |||||
| disabled={batchSubmitting || !currentUserId || expiryItems.length === 0} | |||||
| variant="outlined" | |||||
| startIcon={<FileDownload />} | |||||
| onClick={() => handleExportExcel("all")} | |||||
| disabled={!hasSearched || exporting != null} | |||||
| > | > | ||||
| {batchSubmitting | |||||
| ? `${t("Disposing...")} ${batchProgress ? `(${batchProgress.done}/${batchProgress.total})` : ""}` | |||||
| : t("Batch Disposed All")} | |||||
| {exporting === "all" ? t("Exporting...") : t("Export all in tab")} | |||||
| </Button> | </Button> | ||||
| </Box> | </Box> | ||||
| <Box | |||||
| sx={{ | |||||
| display: "flex", | |||||
| alignItems: "center", | |||||
| gap: 1, | |||||
| mb: 1, | |||||
| flexWrap: "wrap", | |||||
| }} | |||||
| > | |||||
| {resultTab === "upcoming" && ( | |||||
| <FormControl size="small" sx={{ minWidth: 120 }}> | |||||
| <InputLabel id="expiry-days-ahead-label">{t("Days ahead")}</InputLabel> | |||||
| <Select | |||||
| labelId="expiry-days-ahead-label" | |||||
| label={t("Days ahead")} | |||||
| value={String(parseDaysAhead(daysAheadDraft))} | |||||
| onChange={handleDaysAheadChange} | |||||
| > | |||||
| {DAYS_AHEAD_OPTIONS.map((days) => ( | |||||
| <MenuItem key={days} value={String(days)}> | |||||
| {days} | |||||
| </MenuItem> | |||||
| ))} | |||||
| </Select> | |||||
| </FormControl> | |||||
| )} | |||||
| <Box sx={{ display: "flex", gap: 1, ml: "auto" }}> | |||||
| <Button | |||||
| variant="outlined" | |||||
| startIcon={<FileDownload />} | |||||
| onClick={() => handleExportExcel("filtered")} | |||||
| disabled={!hasSearched || exporting != null || tabItems.length === 0} | |||||
| > | |||||
| {exporting === "filtered" ? t("Exporting...") : t("Export Excel")} | |||||
| </Button> | |||||
| <Button | |||||
| variant="contained" | |||||
| color="primary" | |||||
| onClick={() => setBatchConfirmOpen(true)} | |||||
| disabled={ | |||||
| batchSubmitting || !currentUserId || handleableIds.length === 0 | |||||
| } | |||||
| > | |||||
| {batchSubmitting | |||||
| ? `${t("Disposing...")} ${batchProgress ? `(${batchProgress.done}/${batchProgress.total})` : ""}` | |||||
| : t("Batch Disposed All")} | |||||
| </Button> | |||||
| </Box> | |||||
| </Box> | |||||
| <SearchResults<ExpiryItemResult> | <SearchResults<ExpiryItemResult> | ||||
| items={pagedItems} | |||||
| items={tabItems} | |||||
| columns={expiryColumns} | columns={expiryColumns} | ||||
| pagingController={paging} | pagingController={paging} | ||||
| setPagingController={setPaging} | setPagingController={setPaging} | ||||
| totalCount={expiryItems.length} | |||||
| totalCount={tabItems.length} | |||||
| /> | /> | ||||
| <Dialog | |||||
| open={batchConfirmOpen} | |||||
| onClose={() => { | |||||
| if (!batchSubmitting) setBatchConfirmOpen(false); | |||||
| }} | |||||
| fullWidth | |||||
| maxWidth="xs" | |||||
| > | |||||
| <DialogTitle>{t("Confirm batch dispose")}</DialogTitle> | |||||
| <DialogContent> | |||||
| <Typography> | |||||
| {t("Confirm batch dispose message", { count: handleableIds.length })} | |||||
| </Typography> | |||||
| </DialogContent> | |||||
| <DialogActions> | |||||
| <Button | |||||
| onClick={() => setBatchConfirmOpen(false)} | |||||
| disabled={batchSubmitting} | |||||
| > | |||||
| {t("Cancel")} | |||||
| </Button> | |||||
| <Button | |||||
| variant="contained" | |||||
| color="primary" | |||||
| disabled={batchSubmitting || handleableIds.length === 0} | |||||
| onClick={async () => { | |||||
| setBatchConfirmOpen(false); | |||||
| await handleSubmitAll(); | |||||
| }} | |||||
| > | |||||
| {tCommon("Confirm")} | |||||
| </Button> | |||||
| </DialogActions> | |||||
| </Dialog> | |||||
| </Box> | </Box> | ||||
| ); | ); | ||||
| }; | }; | ||||
| @@ -22,6 +22,8 @@ type SearchQuery = { | |||||
| lotNo: string; | lotNo: string; | ||||
| startDate: string; | startDate: string; | ||||
| endDate: string; | endDate: string; | ||||
| handledStartDate: string; | |||||
| handledEndDate: string; | |||||
| }; | }; | ||||
| type SearchParamNames = keyof SearchQuery; | type SearchParamNames = keyof SearchQuery; | ||||
| @@ -29,6 +31,7 @@ interface Props { | |||||
| kind: RecordKind; | kind: RecordKind; | ||||
| } | } | ||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 74 | v1.0.2 | 2026-09-07 */ | |||||
| const StockIssueRecordTab: React.FC<Props> = ({ kind }) => { | const StockIssueRecordTab: React.FC<Props> = ({ kind }) => { | ||||
| const { t } = useTranslation("stockIssue"); | const { t } = useTranslation("stockIssue"); | ||||
| const [items, setItems] = useState<StockIssueHandleRecord[]>([]); | const [items, setItems] = useState<StockIssueHandleRecord[]>([]); | ||||
| @@ -40,27 +43,47 @@ const StockIssueRecordTab: React.FC<Props> = ({ kind }) => { | |||||
| lotNo: "", | lotNo: "", | ||||
| startDate: "", | startDate: "", | ||||
| endDate: "", | endDate: "", | ||||
| handledStartDate: "", | |||||
| handledEndDate: "", | |||||
| }); | }); | ||||
| const hasSearchedRef = useRef(false); | const hasSearchedRef = useRef(false); | ||||
| const prevPagingRef = useRef(paging); | const prevPagingRef = useRef(paging); | ||||
| const searchFields: StockIssueSearchField<SearchParamNames>[] = useMemo( | const searchFields: StockIssueSearchField<SearchParamNames>[] = useMemo( | ||||
| () => [ | |||||
| { name: "itemCode", label: t("Item Code"), type: "text" }, | |||||
| { name: "itemName", label: t("Item"), type: "text" }, | |||||
| { name: "lotNo", label: t("Lot No."), type: "text" }, | |||||
| { | |||||
| name: "startDate", | |||||
| label: kind === "expiry" ? t("Expiry Start Date") : t("Start Date"), | |||||
| type: "date", | |||||
| mirrorTo: "endDate", | |||||
| }, | |||||
| { | |||||
| name: "endDate", | |||||
| label: kind === "expiry" ? t("Expiry End Date") : t("End Date"), | |||||
| type: "date", | |||||
| }, | |||||
| ], | |||||
| () => { | |||||
| const fields: StockIssueSearchField<SearchParamNames>[] = [ | |||||
| { name: "itemCode", label: t("Item Code"), type: "text" }, | |||||
| { name: "itemName", label: t("Item"), type: "text" }, | |||||
| { name: "lotNo", label: t("Lot No."), type: "text" }, | |||||
| { | |||||
| name: "startDate", | |||||
| label: kind === "expiry" ? t("Expiry Start Date") : t("Start Date"), | |||||
| type: "date", | |||||
| mirrorTo: "endDate", | |||||
| }, | |||||
| { | |||||
| name: "endDate", | |||||
| label: kind === "expiry" ? t("Expiry End Date") : t("End Date"), | |||||
| type: "date", | |||||
| }, | |||||
| ]; | |||||
| if (kind === "expiry") { | |||||
| fields.push( | |||||
| { | |||||
| name: "handledStartDate", | |||||
| label: t("Handled Start Date"), | |||||
| type: "date", | |||||
| mirrorTo: "handledEndDate", | |||||
| }, | |||||
| { | |||||
| name: "handledEndDate", | |||||
| label: t("Handled End Date"), | |||||
| type: "date", | |||||
| }, | |||||
| ); | |||||
| } | |||||
| return fields; | |||||
| }, | |||||
| [t, kind], | [t, kind], | ||||
| ); | ); | ||||
| @@ -73,6 +96,8 @@ const StockIssueRecordTab: React.FC<Props> = ({ kind }) => { | |||||
| lotNo: query.lotNo?.trim() || undefined, | lotNo: query.lotNo?.trim() || undefined, | ||||
| startDate: query.startDate || undefined, | startDate: query.startDate || undefined, | ||||
| endDate: query.endDate || undefined, | endDate: query.endDate || undefined, | ||||
| handledStartDate: query.handledStartDate || undefined, | |||||
| handledEndDate: query.handledEndDate || undefined, | |||||
| pageNum: page.pageNum - 1, | pageNum: page.pageNum - 1, | ||||
| pageSize: page.pageSize, | pageSize: page.pageSize, | ||||
| }; | }; | ||||
| @@ -166,7 +191,7 @@ const StockIssueRecordTab: React.FC<Props> = ({ kind }) => { | |||||
| }, | }, | ||||
| { | { | ||||
| name: "uomDesc", | name: "uomDesc", | ||||
| label: t("UOM"), | |||||
| label: t("UoM"), | |||||
| renderCell: (row) => ( | renderCell: (row) => ( | ||||
| <> | <> | ||||
| {row.uomDesc ?? ""} | {row.uomDesc ?? ""} | ||||
| @@ -179,8 +204,10 @@ const StockIssueRecordTab: React.FC<Props> = ({ kind }) => { | |||||
| renderCell: (row) => | renderCell: (row) => | ||||
| row.handlerName ?? (row.handlerId != null ? String(row.handlerId) : "—"), | 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; | return base; | ||||
| }, [t, kind]); | }, [t, kind]); | ||||
| @@ -25,7 +25,7 @@ import "dayjs/locale/zh-hk"; | |||||
| import { useCallback, useMemo, useState } from "react"; | import { useCallback, useMemo, useState } from "react"; | ||||
| import { useTranslation } from "react-i18next"; | import { useTranslation } from "react-i18next"; | ||||
| export type StockIssueSearchFieldType = "text" | "select" | "date"; | |||||
| export type StockIssueSearchFieldType = "text" | "select" | "date" | "number"; | |||||
| export interface StockIssueSearchField<K extends string> { | export interface StockIssueSearchField<K extends string> { | ||||
| name: K; | name: K; | ||||
| @@ -36,6 +36,9 @@ export interface StockIssueSearchField<K extends string> { | |||||
| getOptionLabel?: (value: string) => string; | getOptionLabel?: (value: string) => string; | ||||
| /** When this date is picked, copy the same value to `mirrorTo`. */ | /** When this date is picked, copy the same value to `mirrorTo`. */ | ||||
| mirrorTo?: K; | mirrorTo?: K; | ||||
| defaultValue?: string; | |||||
| min?: number; | |||||
| max?: number; | |||||
| } | } | ||||
| interface Props<K extends string> { | interface Props<K extends string> { | ||||
| @@ -46,6 +49,7 @@ interface Props<K extends string> { | |||||
| disabled?: boolean; | disabled?: boolean; | ||||
| } | } | ||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 74 | v1.0.2 | 2026-09-07 */ | |||||
| function StockIssueSearchPanel<K extends string>({ | function StockIssueSearchPanel<K extends string>({ | ||||
| fields, | fields, | ||||
| onSearch, | onSearch, | ||||
| @@ -60,7 +64,8 @@ function StockIssueSearchPanel<K extends string>({ | |||||
| return fields.reduce( | return fields.reduce( | ||||
| (acc, field) => { | (acc, field) => { | ||||
| acc[field.name] = | acc[field.name] = | ||||
| field.type === "select" ? "All" : ""; | |||||
| field.defaultValue ?? | |||||
| (field.type === "select" ? "All" : ""); | |||||
| return acc; | return acc; | ||||
| }, | }, | ||||
| {} as Record<K, string>, | {} as Record<K, string>, | ||||
| @@ -127,6 +132,21 @@ function StockIssueSearchPanel<K extends string>({ | |||||
| disabled={disabled} | disabled={disabled} | ||||
| /> | /> | ||||
| )} | )} | ||||
| {field.type === "number" && ( | |||||
| <TextField | |||||
| label={field.label} | |||||
| type="number" | |||||
| fullWidth | |||||
| value={values[field.name] ?? ""} | |||||
| onChange={handleTextChange(field.name)} | |||||
| disabled={disabled} | |||||
| inputProps={{ | |||||
| min: field.min, | |||||
| max: field.max, | |||||
| step: 1, | |||||
| }} | |||||
| /> | |||||
| )} | |||||
| {field.type === "select" && ( | {field.type === "select" && ( | ||||
| <FormControl fullWidth disabled={disabled}> | <FormControl fullWidth disabled={disabled}> | ||||
| <InputLabel>{field.label}</InputLabel> | <InputLabel>{field.label}</InputLabel> | ||||
| @@ -166,6 +166,9 @@ | |||||
| "Truck X": "Truck X", | "Truck X": "Truck X", | ||||
| "Truck lane search requires date message": "Truck lane search requires date message", | "Truck lane search requires date message": "Truck lane search requires date message", | ||||
| "Truck lane search requires date title": "Truck lane search requires date title", | "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.", | "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", | "Workbench Batch Release": "Workbench Batch Release", | ||||
| "code": "code", | "code": "code", | ||||
| @@ -53,6 +53,7 @@ | |||||
| "putawayQty": "Put Away Qty", | "putawayQty": "Put Away Qty", | ||||
| "Confirm submit": "Confirm Submit", | "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?", | "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", | "Stock qty is not an integer": "Stock quantity is not an integer", | ||||
| "Converted stock qty is": "Converted stock qty is", | "Converted stock qty is": "Converted stock qty is", | ||||
| "Choose rounding method": ". Please choose a rounding method:", | "Choose rounding method": ". Please choose a rounding method:", | ||||
| @@ -6,15 +6,29 @@ | |||||
| "Bad Item Qty": "Bad Item Qty", | "Bad Item Qty": "Bad Item Qty", | ||||
| "Bad Item Records": "Bad Item Records", | "Bad Item Records": "Bad Item Records", | ||||
| "Batch Disposed All": "Batch Disposed All", | "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", | "Book Qty": "Book Qty", | ||||
| "Cancel": "Cancel", | "Cancel": "Cancel", | ||||
| "Code": "Code", | "Code": "Code", | ||||
| "Defective Qty": "Defective Qty", | "Defective Qty": "Defective Qty", | ||||
| "Disposed": "Disposed", | |||||
| "Disposed": "Expiry handle", | |||||
| "Disposing...": "Disposing...", | "Disposing...": "Disposing...", | ||||
| "DO Order Code": "DO Order Code", | "DO Order Code": "DO Order Code", | ||||
| "End Date": "End Date", | "End Date": "End Date", | ||||
| "Expiry Date": "Expiry 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 End Date": "Expiry End Date", | ||||
| "Expiry Item": "Expiry Item", | "Expiry Item": "Expiry Item", | ||||
| "Expiry Item Handle": "Expiry Item Handle", | "Expiry Item Handle": "Expiry Item Handle", | ||||
| @@ -24,7 +38,10 @@ | |||||
| "Failed to load expiry items": "Failed to load expiry items", | "Failed to load expiry items": "Failed to load expiry items", | ||||
| "Failed to submit": "Failed to submit", | "Failed to submit": "Failed to submit", | ||||
| "Failed to submit expiry item": "Failed to submit expiry item", | "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 Date": "Handled Date", | ||||
| "Handled Start Date": "Handled Start Date", | |||||
| "Handled End Date": "Handled End Date", | |||||
| "Handler": "Handler", | "Handler": "Handler", | ||||
| "Issue Qty": "Issue Qty", | "Issue Qty": "Issue Qty", | ||||
| "Item": "Item", | "Item": "Item", | ||||
| @@ -221,5 +221,6 @@ | |||||
| "Replenishment demo note": "此為前端假資料回應;正式環境將呼叫後端 API。", | "Replenishment demo note": "此為前端假資料回應;正式環境將呼叫後端 API。", | ||||
| "Search Delivery Order": "搜尋送貨單", | "Search Delivery Order": "搜尋送貨單", | ||||
| "DO Replenishment": "送貨單補貨", | "DO Replenishment": "送貨單補貨", | ||||
| "Error": "錯誤" | |||||
| "Error": "錯誤", | |||||
| "User session not found": "找不到登入使用者,請重新登入。" | |||||
| } | } | ||||
| @@ -53,6 +53,7 @@ | |||||
| "putawayQty": "上架數量", | "putawayQty": "上架數量", | ||||
| "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": "累計收貨數量超出訂單數量。仍要提交嗎?", | |||||
| "Stock qty is not an integer": "換算庫存數量不是整數", | "Stock qty is not an integer": "換算庫存數量不是整數", | ||||
| "Converted stock qty is": "換算庫存數量為", | "Converted stock qty is": "換算庫存數量為", | ||||
| "Choose rounding method": "。請選擇進位方式:", | "Choose rounding method": "。請選擇進位方式:", | ||||
| @@ -6,15 +6,29 @@ | |||||
| "Bad Item Qty": "不良品數量", | "Bad Item Qty": "不良品數量", | ||||
| "Bad Item Records": "不良品處理紀錄", | "Bad Item Records": "不良品處理紀錄", | ||||
| "Batch Disposed All": "批量處理完成", | "Batch Disposed All": "批量處理完成", | ||||
| "Export Excel": "匯出目前分類", | |||||
| "Exporting...": "匯出中...", | |||||
| "Failed to export Excel": "匯出 Excel 失敗", | |||||
| "Book Qty": "帳面庫存", | "Book Qty": "帳面庫存", | ||||
| "Cancel": "取消", | "Cancel": "取消", | ||||
| "Code": "編號", | "Code": "編號", | ||||
| "Defective Qty": "不良數量", | "Defective Qty": "不良數量", | ||||
| "Disposed": "已處置", | |||||
| "Disposed": "過期處理", | |||||
| "Disposing...": "處理中...", | "Disposing...": "處理中...", | ||||
| "DO Order Code": "送貨單編號", | "DO Order Code": "送貨單編號", | ||||
| "End Date": "結束日期", | "End Date": "結束日期", | ||||
| "Expiry 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 End Date": "到期日(結束)", | ||||
| "Expiry Item": "過期", | "Expiry Item": "過期", | ||||
| "Expiry Item Handle": "過期品處理", | "Expiry Item Handle": "過期品處理", | ||||
| @@ -24,10 +38,13 @@ | |||||
| "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": "尚未到期,到期日當日才可處置", | |||||
| "Handled Date": "處理日期", | "Handled Date": "處理日期", | ||||
| "Handled Start Date": "處理日期(開始)", | |||||
| "Handled End Date": "處理日期(結束)", | |||||
| "Handler": "處理人", | "Handler": "處理人", | ||||
| "Issue Qty": "問題數量", | "Issue Qty": "問題數量", | ||||
| "Item": "貨品", | |||||
| "Item": "貨品名稱", | |||||
| "Item Code": "貨品編號", | "Item Code": "貨品編號", | ||||
| "Item not found": "找不到貨品", | "Item not found": "找不到貨品", | ||||
| "Item selected": "已選擇貨品", | "Item selected": "已選擇貨品", | ||||
| @@ -50,7 +67,7 @@ | |||||
| "Processing...": "處理中...", | "Processing...": "處理中...", | ||||
| "Quantity exceeds available quantity": "數量超過可用數量", | "Quantity exceeds available quantity": "數量超過可用數量", | ||||
| "Remain available Quantity": "剩餘可用數量", | "Remain available Quantity": "剩餘可用數量", | ||||
| "Remaining Qty": "剩餘數量", | |||||
| "Remaining Qty": "數量", | |||||
| "Remark": "備註", | "Remark": "備註", | ||||
| "Remarks": "備註", | "Remarks": "備註", | ||||
| "Reset": "重置", | "Reset": "重置", | ||||
| @@ -69,7 +86,7 @@ | |||||
| "Submitting...": "提交中...", | "Submitting...": "提交中...", | ||||
| "Type": "類型", | "Type": "類型", | ||||
| "Unknown error": "未知錯誤", | "Unknown error": "未知錯誤", | ||||
| "UoM": "單位", | |||||
| "UoM": "庫存單位", | |||||
| "User ID is required": "需要用戶ID", | "User ID is required": "需要用戶ID", | ||||
| "Warehouse": "倉庫", | "Warehouse": "倉庫", | ||||
| "available": "可用", | "available": "可用", | ||||