同 UOM 不轉換 lot expiry 邏輯 PoSearch fix the console.og PoDetail 累計超過 PO 也要 warnundefined
| @@ -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.1 | 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); | ||||
| @@ -46,6 +46,16 @@ export type CreateItemInputs = { | |||||
| isFee?: boolean | undefined; | isFee?: boolean | undefined; | ||||
| isBag?: boolean | undefined; | isBag?: boolean | undefined; | ||||
| qcType?: string | undefined; | qcType?: string | undefined; | ||||
| averageUnitPrice?: string | undefined; | |||||
| averageUnitPriceEditable?: boolean; | |||||
| stockUnitLabel?: string | undefined; | |||||
| }; | |||||
| export const recalculateAverageUnitPrices = async () => { | |||||
| return serverFetchJson<{ updatedItemCount: number }>( | |||||
| `${BASE_API_URL}/items/averageUnitPrice/recalculate`, | |||||
| { method: "POST" }, | |||||
| ); | |||||
| }; | }; | ||||
| export const saveItem = async (data: CreateItemInputs) => { | export const saveItem = async (data: CreateItemInputs) => { | ||||
| @@ -138,6 +148,23 @@ export const fetchItemsWithDetails = cache(async (searchParams?: Record<string, | |||||
| } | } | ||||
| }); | }); | ||||
| /** Item master page search: items table only (no inventory / lot join). */ | |||||
| export const fetchItemsByPage = cache(async (searchParams?: Record<string, string | number>) => { | |||||
| const params = new URLSearchParams(); | |||||
| if (searchParams) { | |||||
| Object.entries(searchParams).forEach(([key, value]) => { | |||||
| if (value !== undefined && value !== null && String(value) !== "") { | |||||
| params.append(key, String(value)); | |||||
| } | |||||
| }); | |||||
| } | |||||
| const qs = params.toString(); | |||||
| return serverFetchJson<RecordsRes<ItemsResult>>( | |||||
| qs ? `${BASE_API_URL}/items/getRecordByPage?${qs}` : `${BASE_API_URL}/items/getRecordByPage`, | |||||
| { 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"] }, | ||||
| @@ -66,12 +66,17 @@ export type ItemsResult = { | |||||
| latestMarketUnitPrice?: number; | latestMarketUnitPrice?: number; | ||||
| latestMupUpdatedDate?: string; | latestMupUpdatedDate?: string; | ||||
| purchaseUnit?: string; | purchaseUnit?: string; | ||||
| purchaseCurrencyId?: number; | |||||
| purchaseUnitPrice?: number; | |||||
| purchaseFxRate?: number; | |||||
| }; | }; | ||||
| export type Result = { | export type Result = { | ||||
| item: ItemsResult; | item: ItemsResult; | ||||
| qcChecks: ItemQc[]; | qcChecks: ItemQc[]; | ||||
| qcType?: string; | qcType?: string; | ||||
| averageUnitPriceEditable?: boolean; | |||||
| stockUnitLabel?: string; | |||||
| }; | }; | ||||
| export const fetchAllItems = cache(async () => { | export const fetchAllItems = cache(async () => { | ||||
| return serverFetchJson<ItemsResult[]>(`${BASE_API_URL}/items`, { | return serverFetchJson<ItemsResult[]>(`${BASE_API_URL}/items`, { | ||||
| @@ -17,12 +17,15 @@ 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; | |||||
| } | } | ||||
| export interface HandleBadItemRequest { | export interface HandleBadItemRequest { | ||||
| @@ -54,6 +57,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; | ||||
| @@ -63,9 +68,9 @@ export interface SearchStockIssueRecordParams { | |||||
| 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); | |||||
| 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 +112,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,66 @@ | |||||
| "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; | |||||
| } | |||||
| /** | |||||
| * Partner backend contract (not implemented here): | |||||
| * GET /pickExecution/issues/expiryItem/excel | |||||
| * Query: itemCode, itemName, lotNo, bucket (expired|today|upcoming) | |||||
| * 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.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); | |||||
| } | |||||
| @@ -1,6 +1,6 @@ | |||||
| "use client"; | "use client"; | ||||
| import { useCallback, useEffect, useMemo, useState } from "react"; | |||||
| import { useCallback, useEffect, useMemo, useRef, useState } from "react"; | |||||
| import { useRouter, useSearchParams } from "next/navigation"; | import { useRouter, useSearchParams } from "next/navigation"; | ||||
| import { useTranslation } from "react-i18next"; | import { useTranslation } from "react-i18next"; | ||||
| import { CreateItemInputs, saveItem } from "@/app/api/settings/item/actions"; | import { CreateItemInputs, saveItem } from "@/app/api/settings/item/actions"; | ||||
| @@ -42,6 +42,7 @@ type Props = { | |||||
| warehouses: WarehouseResult[]; | warehouses: WarehouseResult[]; | ||||
| }; | }; | ||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 71 | v1.0.0 | 2026-09-07 */ | |||||
| const CreateItem: React.FC<Props> = ({ | const CreateItem: React.FC<Props> = ({ | ||||
| isEditMode, | isEditMode, | ||||
| // type, | // type, | ||||
| @@ -56,6 +57,8 @@ const CreateItem: React.FC<Props> = ({ | |||||
| console.log(params.get("id")); | console.log(params.get("id")); | ||||
| const [serverError, setServerError] = useState(""); | const [serverError, setServerError] = useState(""); | ||||
| const [tabIndex, setTabIndex] = useState(0); | const [tabIndex, setTabIndex] = useState(0); | ||||
| const [isSaving, setIsSaving] = useState(false); | |||||
| const inFlightRef = useRef(false); | |||||
| const { t } = useTranslation("items"); | const { t } = useTranslation("items"); | ||||
| const router = useRouter(); | const router = useRouter(); | ||||
| const title = "Product / Material"; | const title = "Product / Material"; | ||||
| @@ -104,10 +107,11 @@ const CreateItem: React.FC<Props> = ({ | |||||
| }; | }; | ||||
| const onSubmit = useCallback<SubmitHandler<CreateItemInputs & {}>>( | const onSubmit = useCallback<SubmitHandler<CreateItemInputs & {}>>( | ||||
| async (data, event) => { | async (data, event) => { | ||||
| if (inFlightRef.current) return; | |||||
| inFlightRef.current = true; | |||||
| setIsSaving(true); | |||||
| const hasErrors = false; | const hasErrors = false; | ||||
| console.log(errors); | console.log(errors); | ||||
| // console.log(apiRef.current.getCellValue(2, "lowerLimit")) | |||||
| // apiRef.current. | |||||
| try { | try { | ||||
| if (hasErrors) { | if (hasErrors) { | ||||
| setServerError(t("An error has occurred. Please try again later.")); | setServerError(t("An error has occurred. Please try again later.")); | ||||
| @@ -191,6 +195,9 @@ const CreateItem: React.FC<Props> = ({ | |||||
| // backend error | // backend error | ||||
| setServerError(t("An error has occurred. Please try again later.")); | setServerError(t("An error has occurred. Please try again later.")); | ||||
| console.log(e); | console.log(e); | ||||
| } finally { | |||||
| setIsSaving(false); | |||||
| inFlightRef.current = false; | |||||
| } | } | ||||
| }, | }, | ||||
| [apiRef, router, t], | [apiRef, router, t], | ||||
| @@ -244,6 +251,7 @@ const CreateItem: React.FC<Props> = ({ | |||||
| qcCategoryCombo={qcCategoryCombo} | qcCategoryCombo={qcCategoryCombo} | ||||
| warehouses={warehouses} | warehouses={warehouses} | ||||
| defaultValues={defaultValues} | defaultValues={defaultValues} | ||||
| isSaving={isSaving} | |||||
| /> | /> | ||||
| )} | )} | ||||
| {tabIndex === 1 && <QcDetails apiRef={apiRef} />} | {tabIndex === 1 && <QcDetails apiRef={apiRef} />} | ||||
| @@ -16,6 +16,7 @@ type Props = { | |||||
| // type: TypeEnum; | // type: TypeEnum; | ||||
| }; | }; | ||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 71 | v1.0.0 | 2026-09-07 */ | |||||
| const CreateItemWrapper: React.FC<Props> & SubComponents = async ({ id }) => { | const CreateItemWrapper: React.FC<Props> & SubComponents = async ({ id }) => { | ||||
| let result; | let result; | ||||
| let defaultValues: Partial<CreateItemInputs> | undefined; | let defaultValues: Partial<CreateItemInputs> | undefined; | ||||
| @@ -29,7 +30,7 @@ const CreateItemWrapper: React.FC<Props> & SubComponents = async ({ id }) => { | |||||
| // Normalize LocationCode field (handle case sensitivity from MySQL) | // Normalize LocationCode field (handle case sensitivity from MySQL) | ||||
| const locationCode = item?.LocationCode || item?.locationCode; | const locationCode = item?.LocationCode || item?.locationCode; | ||||
| /* | |||||
| console.log("Fetched item data for edit:", { | console.log("Fetched item data for edit:", { | ||||
| id: item?.id, | id: item?.id, | ||||
| code: item?.code, | code: item?.code, | ||||
| @@ -37,7 +38,7 @@ const CreateItemWrapper: React.FC<Props> & SubComponents = async ({ id }) => { | |||||
| LocationCode: locationCode, | LocationCode: locationCode, | ||||
| rawItem: item | rawItem: item | ||||
| }); | }); | ||||
| */ | |||||
| defaultValues = { | defaultValues = { | ||||
| type: item?.type, | type: item?.type, | ||||
| id: item?.id, | id: item?.id, | ||||
| @@ -60,6 +61,9 @@ const CreateItemWrapper: React.FC<Props> & SubComponents = async ({ id }) => { | |||||
| isEgg: item?.isEgg, | isEgg: item?.isEgg, | ||||
| isFee: item?.isFee, | isFee: item?.isFee, | ||||
| isBag: item?.isBag, | isBag: item?.isBag, | ||||
| averageUnitPrice: item?.averageUnitPrice != null ? String(item.averageUnitPrice) : undefined, | |||||
| averageUnitPriceEditable: result.averageUnitPriceEditable ?? true, | |||||
| stockUnitLabel: result.stockUnitLabel, | |||||
| }; | }; | ||||
| } | } | ||||
| @@ -42,9 +42,11 @@ type Props = { | |||||
| qcChecks?: ItemQc[]; | qcChecks?: ItemQc[]; | ||||
| qcCategoryCombo: QcCategoryCombo[]; | qcCategoryCombo: QcCategoryCombo[]; | ||||
| warehouses: WarehouseResult[]; | warehouses: WarehouseResult[]; | ||||
| isSaving?: boolean; | |||||
| }; | }; | ||||
| const ProductDetails: React.FC<Props> = ({ isEditMode, qcCategoryCombo, warehouses, defaultValues: initialDefaultValues }) => { | |||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 71 | v1.0.0 | 2026-09-07 */ | |||||
| const ProductDetails: React.FC<Props> = ({ isEditMode, qcCategoryCombo, warehouses, defaultValues: initialDefaultValues, isSaving }) => { | |||||
| const [qcItems, setQcItems] = useState<QcItemInfo[]>([]); | const [qcItems, setQcItems] = useState<QcItemInfo[]>([]); | ||||
| const [qcItemsLoading, setQcItemsLoading] = useState(false); | const [qcItemsLoading, setQcItemsLoading] = useState(false); | ||||
| @@ -315,6 +317,19 @@ const ProductDetails: React.FC<Props> = ({ isEditMode, qcCategoryCombo, warehous | |||||
| )} | )} | ||||
| /> | /> | ||||
| </Grid> | </Grid> | ||||
| <Grid item xs={6}> | |||||
| <TextField | |||||
| label={ | |||||
| initialDefaultValues?.stockUnitLabel | |||||
| ? `${t("Average unit cost")} (HKD / ${initialDefaultValues.stockUnitLabel})` | |||||
| : `${t("Average unit cost")} (HKD)` | |||||
| } | |||||
| fullWidth | |||||
| disabled={!isEditMode} | |||||
| {...register("averageUnitPrice")} | |||||
| helperText={t("Average unit cost hint")} | |||||
| /> | |||||
| </Grid> | |||||
| <Grid item xs={12}> | <Grid item xs={12}> | ||||
| <FormControl component="fieldset"> | <FormControl component="fieldset"> | ||||
| <FormLabel component="legend">{t("Special Type")}</FormLabel> | <FormLabel component="legend">{t("Special Type")}</FormLabel> | ||||
| @@ -359,7 +374,7 @@ const ProductDetails: React.FC<Props> = ({ isEditMode, qcCategoryCombo, warehous | |||||
| variant="contained" | variant="contained" | ||||
| startIcon={<Check />} | startIcon={<Check />} | ||||
| type="submit" | type="submit" | ||||
| // disabled={submitDisabled} | |||||
| disabled={isSaving} | |||||
| > | > | ||||
| {isEditMode ? t("Save") : t("Confirm")} | {isEditMode ? t("Save") : t("Confirm")} | ||||
| </Button> | </Button> | ||||
| @@ -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,7 +16,7 @@ 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 { ItemCombo, fetchItemsByPage } from '@/app/api/settings/item/actions'; | |||||
| import { | import { | ||||
| Button, | Button, | ||||
| Dialog, | Dialog, | ||||
| @@ -56,42 +56,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; | |||||
| }; | |||||
| type OpeningItemRow = ItemCombo & { code: string; name: string; type?: 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.1 | 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 searchInFlightRef = useRef(false); | |||||
| const openingSearchInFlightRef = useRef(false); | |||||
| const buildSyntheticInventory = useCallback( | |||||
| (item: ItemWithDetails): InventoryResult => ({ | |||||
| 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 +130,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'); | ||||
| @@ -114,9 +154,9 @@ const InventorySearch: React.FC<Props> = ({ inventories, printerCombo }) => { | |||||
| const [scannedItemId, setScannedItemId] = useState<number | null>(null); | const [scannedItemId, setScannedItemId] = useState<number | null>(null); | ||||
| // Opening inventory (pure opening stock for items without existing inventory) | // Opening inventory (pure opening stock for items without existing inventory) | ||||
| const [openingItems, setOpeningItems] = useState<ItemCombo[]>([]); | |||||
| const [openingItems, setOpeningItems] = useState<OpeningItemRow[]>([]); | |||||
| const [openingModalOpen, setOpeningModalOpen] = useState(false); | const [openingModalOpen, setOpeningModalOpen] = useState(false); | ||||
| const [openingSelectedItem, setOpeningSelectedItem] = useState<ItemCombo | null>(null); | |||||
| const [openingSelectedItem, setOpeningSelectedItem] = useState<OpeningItemRow | null>(null); | |||||
| const [openingLoading, setOpeningLoading] = useState(false); | const [openingLoading, setOpeningLoading] = useState(false); | ||||
| const [openingSearchText, setOpeningSearchText] = useState(''); | const [openingSearchText, setOpeningSearchText] = useState(''); | ||||
| @@ -297,46 +337,54 @@ 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 (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, | |||||
| ], | ], | ||||
| ); | ); | ||||
| @@ -382,14 +430,8 @@ 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); | |||||
| } else { | |||||
| const items = await lookupItemsByCodeOrName(res?.itemCode); | |||||
| if (!applyItemsAsSyntheticInventories(items)) { | |||||
| setSelectedInventory(null); | setSelectedInventory(null); | ||||
| } | } | ||||
| } | } | ||||
| @@ -410,8 +452,8 @@ const InventorySearch: React.FC<Props> = ({ inventories, printerCombo }) => { | |||||
| qrScanner.result, | qrScanner.result, | ||||
| refetchInventoryData, | refetchInventoryData, | ||||
| refetchInventoryLotLineData, | refetchInventoryLotLineData, | ||||
| buildSyntheticInventory, | |||||
| getFirstItemRecord, | |||||
| lookupItemsByCodeOrName, | |||||
| applyItemsAsSyntheticInventories, | |||||
| scanUiMode, | scanUiMode, | ||||
| ]); | ]); | ||||
| @@ -430,51 +472,35 @@ const InventorySearch: React.FC<Props> = ({ inventories, printerCombo }) => { | |||||
| setOpeningItems([]); | setOpeningItems([]); | ||||
| return; | return; | ||||
| } | } | ||||
| if (openingSearchInFlightRef.current) return; | |||||
| openingSearchInFlightRef.current = true; | |||||
| setOpeningLoading(true); | setOpeningLoading(true); | ||||
| try { | 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, | |||||
| })); | |||||
| const items = trimmed.includes(' ') | |||||
| ? await lookupItemsByCodeOrName(undefined, trimmed) | |||||
| : await lookupItemsByCodeOrName(trimmed); | |||||
| const combos: OpeningItemRow[] = items.map((item) => { | |||||
| const uom = item.uomDesc || item.uom || item.purchaseUnit || ''; | |||||
| return { | |||||
| id: item.id, | |||||
| label: `${item.code} - ${item.name}`, | |||||
| uomId: 0, | |||||
| uom, | |||||
| uomDesc: uom, | |||||
| code: item.code, | |||||
| name: item.name, | |||||
| type: item.type, | |||||
| }; | |||||
| }); | |||||
| setOpeningItems(combos); | setOpeningItems(combos); | ||||
| } catch (e) { | } catch (e) { | ||||
| console.error('Failed to search items for opening inventory:', e); | console.error('Failed to search items for opening inventory:', e); | ||||
| setOpeningItems([]); | setOpeningItems([]); | ||||
| } finally { | } finally { | ||||
| setOpeningLoading(false); | setOpeningLoading(false); | ||||
| openingSearchInFlightRef.current = false; | |||||
| } | } | ||||
| }, [openingSearchText]); | |||||
| }, [openingSearchText, lookupItemsByCodeOrName]); | |||||
| const handleConfirmOpeningInventory = useCallback(() => { | const handleConfirmOpeningInventory = useCallback(() => { | ||||
| if (!openingSelectedItem) { | if (!openingSelectedItem) { | ||||
| @@ -482,39 +508,18 @@ const InventorySearch: React.FC<Props> = ({ inventories, printerCombo }) => { | |||||
| return; | 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); | |||||
| applyItemsAsSyntheticInventories([ | |||||
| { | |||||
| id: Number(openingSelectedItem.id), | |||||
| code: openingSelectedItem.code, | |||||
| name: openingSelectedItem.name, | |||||
| type: openingSelectedItem.type, | |||||
| uom: openingSelectedItem.uom, | |||||
| uomDesc: openingSelectedItem.uomDesc, | |||||
| }, | |||||
| ]); | |||||
| setOpeningModalOpen(false); | setOpeningModalOpen(false); | ||||
| }, [openingSelectedItem]); | |||||
| }, [openingSelectedItem, applyItemsAsSyntheticInventories]); | |||||
| return ( | return ( | ||||
| <> | <> | ||||
| @@ -545,7 +550,6 @@ const InventorySearch: React.FC<Props> = ({ inventories, printerCombo }) => { | |||||
| variant="outlined" | variant="outlined" | ||||
| color="secondary" | color="secondary" | ||||
| onClick={handleOpenOpeningInventoryModal} | onClick={handleOpenOpeningInventoryModal} | ||||
| sx={{ display: 'none' }} | |||||
| > | > | ||||
| {t('Add entry for items without inventory')} | {t('Add entry for items without inventory')} | ||||
| </Button> | </Button> | ||||
| @@ -652,8 +656,6 @@ const InventorySearch: React.FC<Props> = ({ inventories, printerCombo }) => { | |||||
| </TableHead> | </TableHead> | ||||
| <TableBody> | <TableBody> | ||||
| {openingItems.map((it) => { | {openingItems.map((it) => { | ||||
| const [code, ...nameParts] = (it.label ?? '').split(' - '); | |||||
| const name = nameParts.join(' - '); | |||||
| const selected = openingSelectedItem?.id === it.id; | const selected = openingSelectedItem?.id === it.id; | ||||
| return ( | return ( | ||||
| <TableRow | <TableRow | ||||
| @@ -666,8 +668,8 @@ const InventorySearch: React.FC<Props> = ({ inventories, printerCombo }) => { | |||||
| <TableCell padding="checkbox"> | <TableCell padding="checkbox"> | ||||
| <Radio checked={selected} /> | <Radio checked={selected} /> | ||||
| </TableCell> | </TableCell> | ||||
| <TableCell>{code}</TableCell> | |||||
| <TableCell>{name}</TableCell> | |||||
| <TableCell>{it.code}</TableCell> | |||||
| <TableCell>{it.name}</TableCell> | |||||
| <TableCell>{it.uomDesc || it.uom}</TableCell> | <TableCell>{it.uomDesc || it.uom}</TableCell> | ||||
| <TableCell align="right"> | <TableCell align="right"> | ||||
| {it.currentStockBalance != null ? it.currentStockBalance : '-'} | {it.currentStockBalance != null ? it.currentStockBalance : '-'} | ||||
| @@ -253,7 +253,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.2 | 2026-09-07 */ | |||||
| 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(); | ||||
| @@ -678,11 +678,29 @@ const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => { | |||||
| }, 200); | }, 200); | ||||
| }; | }; | ||||
| const exceedOrderBy10Percent = orderQty > 0 && acceptedQty > orderQty * 1.1; | |||||
| if (exceedOrderBy10Percent) { | |||||
| const sils = row.stockInLine ?? []; | |||||
| const alreadyM18 = sils.reduce( | |||||
| (acc, sil) => acc + Number(sil.purchaseAcceptedQty ?? 0), | |||||
| 0, | |||||
| ); | |||||
| const alreadyStock = sils.reduce( | |||||
| (acc, sil) => acc + Number(sil.acceptedQty ?? 0), | |||||
| 0, | |||||
| ); | |||||
| const stockDemand = Number(row.stockUom?.stockQty ?? 0); | |||||
| const thisBatchStock = | |||||
| orderQty > 0 && stockDemand > 0 | |||||
| ? acceptedQty * (stockDemand / orderQty) | |||||
| : acceptedQty; | |||||
| const exceedByOrderUnit = | |||||
| orderQty > 0 && alreadyM18 + acceptedQty > orderQty * 1.1; | |||||
| const exceedByStockUnit = | |||||
| stockDemand > 0 && | |||||
| alreadyStock + thisBatchStock > stockDemand * 1.1; | |||||
| if (exceedByOrderUnit || exceedByStockUnit) { | |||||
| submitDialogWithWarning(doSubmit, t, { | 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 { | ||||
| @@ -834,6 +852,11 @@ const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => { | |||||
| <TableCell align="center"> | <TableCell align="center"> | ||||
| <Button | <Button | ||||
| variant="contained" | variant="contained" | ||||
| onMouseDown={(e) => { | |||||
| // Keep input focused so onBlur does not remount this row and swallow the click. | |||||
| e.preventDefault(); | |||||
| e.stopPropagation(); | |||||
| }} | |||||
| onClick={(e) => { | onClick={(e) => { | ||||
| e.stopPropagation(); | e.stopPropagation(); | ||||
| handleStart(); | handleStart(); | ||||
| @@ -998,7 +1021,7 @@ const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => { | |||||
| <> | <> | ||||
| <Stack spacing={2}> | <Stack spacing={2}> | ||||
| {/* 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} -{" "} | ||||
| @@ -1153,7 +1176,7 @@ const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => { | |||||
| {/* Area4: Main Table */} | {/* Area4: Main Table */} | ||||
| <Grid container xs={12} justifyContent="start"> | |||||
| <Grid container justifyContent="start"> | |||||
| <Grid item xs={12}> | <Grid item xs={12}> | ||||
| <TableContainer component={Paper} sx={{ width: 'fit-content', overflow: 'auto' }}> | <TableContainer component={Paper} sx={{ width: 'fit-content', overflow: 'auto' }}> | ||||
| <Table aria-label="collapsible table" stickyHeader> | <Table aria-label="collapsible table" stickyHeader> | ||||
| @@ -1187,7 +1210,7 @@ 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"> | |||||
| <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'}` : "未選擇貨品"} | ||||
| @@ -170,7 +170,7 @@ class ProcessRowUpdateError extends Error { | |||||
| } | } | ||||
| } | } | ||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.1 | 2026-08-12 */ | |||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.2 | 2026-09-07 */ | |||||
| function PoInputGrid({ | function PoInputGrid({ | ||||
| // qc, | // qc, | ||||
| setRows, | setRows, | ||||
| @@ -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.2 | 2026-09-07 */ | |||||
| const PoQcStockInModalVer2: React.FC<Props> = ({ | const PoQcStockInModalVer2: React.FC<Props> = ({ | ||||
| open, | open, | ||||
| onClose, | onClose, | ||||
| @@ -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>[]>( | ||||
| @@ -272,8 +273,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, | ||||
| @@ -379,7 +380,7 @@ const PoSearch: React.FC<Props> = ({ | |||||
| ); | ); | ||||
| useEffect(() => { | useEffect(() => { | ||||
| console.log(filteredPo) | |||||
| //console.log(filteredPo) | |||||
| }, [filteredPo]) | }, [filteredPo]) | ||||
| useEffect(() => { | useEffect(() => { | ||||
| @@ -404,7 +405,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). | ||||
| @@ -72,7 +72,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.2 | 2026-09-07 */ | |||||
| const QcStockInModal: React.FC<Props> = ({ | const QcStockInModal: React.FC<Props> = ({ | ||||
| open, | open, | ||||
| onClose, | onClose, | ||||
| @@ -11,42 +11,134 @@ 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, | |||||
| 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"; | |||||
| 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): 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 (!d.isAfter(today.add(7, "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.0 | 2026-09-07 */ | |||||
| 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>({}); | |||||
| 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 [exporting, setExporting] = useState(false); | |||||
| const [paging, setPaging] = useState({ pageNum: 1, pageSize: 10 }); | const [paging, setPaging] = useState({ pageNum: 1, pageSize: 10 }); | ||||
| const itemsByBucket = useMemo(() => { | |||||
| const expired: ExpiryItemResult[] = []; | |||||
| const today: ExpiryItemResult[] = []; | |||||
| const upcoming: ExpiryItemResult[] = []; | |||||
| for (const item of expiryItems) { | |||||
| const bucket = getExpiryBucket(item); | |||||
| 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]); | |||||
| 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 +154,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 +184,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 +210,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 +222,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], | ||||
| @@ -180,12 +264,15 @@ const ExpiryHandleTab: React.FC = () => { | |||||
| const handleSearch = useCallback( | const handleSearch = useCallback( | ||||
| async (query: Record<SearchParamNames, string>) => { | async (query: Record<SearchParamNames, string>) => { | ||||
| 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, | |||||
| }; | |||||
| 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); | ||||
| @@ -195,20 +282,66 @@ const ExpiryHandleTab: React.FC = () => { | |||||
| [t], | [t], | ||||
| ); | ); | ||||
| const pagedItems = useMemo(() => { | |||||
| const start = (paging.pageNum - 1) * paging.pageSize; | |||||
| return expiryItems.slice(start, start + paging.pageSize); | |||||
| }, [expiryItems, paging]); | |||||
| const handleExportExcel = useCallback(async () => { | |||||
| if (!hasSearched) return; | |||||
| if (exportInFlightRef.current) return; | |||||
| exportInFlightRef.current = true; | |||||
| setExporting(true); | |||||
| try { | |||||
| await exportExpiryItemExcel({ | |||||
| ...lastFilters, | |||||
| bucket: resultTab, | |||||
| }); | |||||
| } catch (error) { | |||||
| console.error("Failed to export expiry items:", error); | |||||
| alert(t("Failed to export Excel")); | |||||
| } finally { | |||||
| setExporting(false); | |||||
| exportInFlightRef.current = false; | |||||
| } | |||||
| }, [hasSearched, lastFilters, resultTab, 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 }}> | |||||
| <Tabs value={resultTab} onChange={handleResultTabChange} sx={{ mb: 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 7 days")} (${itemsByBucket.upcoming.length})`} | |||||
| /> | |||||
| </Tabs> | |||||
| <Box sx={{ display: "flex", justifyContent: "flex-end", gap: 1, mb: 1 }}> | |||||
| <Button | |||||
| variant="outlined" | |||||
| startIcon={<FileDownload />} | |||||
| onClick={handleExportExcel} | |||||
| disabled={!hasSearched || exporting || tabItems.length === 0} | |||||
| > | |||||
| {exporting ? t("Exporting...") : t("Export Excel")} | |||||
| </Button> | |||||
| <Button | <Button | ||||
| variant="contained" | variant="contained" | ||||
| color="primary" | color="primary" | ||||
| onClick={handleSubmitAll} | |||||
| disabled={batchSubmitting || !currentUserId || expiryItems.length === 0} | |||||
| onClick={() => setBatchConfirmOpen(true)} | |||||
| disabled={ | |||||
| batchSubmitting || !currentUserId || handleableIds.length === 0 | |||||
| } | |||||
| > | > | ||||
| {batchSubmitting | {batchSubmitting | ||||
| ? `${t("Disposing...")} ${batchProgress ? `(${batchProgress.done}/${batchProgress.total})` : ""}` | ? `${t("Disposing...")} ${batchProgress ? `(${batchProgress.done}/${batchProgress.total})` : ""}` | ||||
| @@ -216,12 +349,46 @@ const ExpiryHandleTab: React.FC = () => { | |||||
| </Button> | </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.0 | 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]); | ||||
| @@ -66,5 +66,7 @@ | |||||
| "Sales UOM": "Sales UOM", | "Sales UOM": "Sales UOM", | ||||
| "Stock Qty": "Stock Qty", | "Stock Qty": "Stock Qty", | ||||
| "Uom": "Uom", | "Uom": "Uom", | ||||
| "Average unit cost": "Average unit cost", | |||||
| "Average unit cost hint": "HKD per stock unit. Recalculate from POs overwrites this value.", | |||||
| "Cost (HKD)": "Cost (HKD)" | "Cost (HKD)": "Cost (HKD)" | ||||
| } | } | ||||
| @@ -51,7 +51,7 @@ | |||||
| "acceptedPutawayQty": "Put Away Qty (This Batch)", | "acceptedPutawayQty": "Put Away Qty (This Batch)", | ||||
| "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?", | |||||
| "qtyExceedsOrderConfirm": "Cumulative received quantity exceeds order quantity. Do you still want to submit?", | |||||
| "acceptQty": "Accept Qty", | "acceptQty": "Accept Qty", | ||||
| "printQty": "Print Qty", | "printQty": "Print Qty", | ||||
| "qcResult": "QC Result", | "qcResult": "QC Result", | ||||
| @@ -6,15 +6,24 @@ | |||||
| "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 Excel", | |||||
| "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 7 days", | |||||
| "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 +33,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", | ||||
| @@ -66,5 +66,7 @@ | |||||
| "Sales UOM": "銷售單位", | "Sales UOM": "銷售單位", | ||||
| "Stock Qty": "庫存數量", | "Stock Qty": "庫存數量", | ||||
| "Uom": "單位", | "Uom": "單位", | ||||
| "Average unit cost": "平均單位成本", | |||||
| "Average unit cost hint": "HKD/庫存單位。從採購單重算會覆寫此值。", | |||||
| "Cost (HKD)": "費用 (HKD)" | "Cost (HKD)": "費用 (HKD)" | ||||
| } | } | ||||
| @@ -51,7 +51,7 @@ | |||||
| "acceptedPutawayQty": "本批上架數量", | "acceptedPutawayQty": "本批上架數量", | ||||
| "putawayQty": "上架數量", | "putawayQty": "上架數量", | ||||
| "Confirm submit": "確定提交", | "Confirm submit": "確定提交", | ||||
| "This batch quantity exceeds order quantity. Do you still want to submit?": "本批收貨數量超出訂單數量。仍要提交嗎?", | |||||
| "qtyExceedsOrderConfirm": "累計收貨數量超出訂單數量。仍要提交嗎?", | |||||
| "acceptQty": "揀收數量", | "acceptQty": "揀收數量", | ||||
| "printQty": "列印數量", | "printQty": "列印數量", | ||||
| "qcResult": "品檢結果", | "qcResult": "品檢結果", | ||||
| @@ -6,15 +6,24 @@ | |||||
| "Bad Item Qty": "不良品數量", | "Bad Item Qty": "不良品數量", | ||||
| "Bad Item Records": "不良品處理紀錄", | "Bad Item Records": "不良品處理紀錄", | ||||
| "Batch Disposed All": "批量處理完成", | "Batch Disposed All": "批量處理完成", | ||||
| "Export Excel": "匯出 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": "未來 7 日到期", | |||||
| "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,7 +33,10 @@ | |||||
| "Failed to load expiry items": "載入過期品失敗", | "Failed to load expiry items": "載入過期品失敗", | ||||
| "Failed to submit": "提交失敗", | "Failed to submit": "提交失敗", | ||||
| "Failed to submit expiry item": "提交過期品失敗", | "Failed to submit expiry item": "提交過期品失敗", | ||||
| "Not yet due; cannot dispose until the expiry date": "尚未到期,到期日當日才可處置", | |||||
| "Handled Date": "處理日期", | "Handled Date": "處理日期", | ||||
| "Handled Start Date": "處理日期(開始)", | |||||
| "Handled End Date": "處理日期(結束)", | |||||
| "Handler": "處理人", | "Handler": "處理人", | ||||
| "Issue Qty": "問題數量", | "Issue Qty": "問題數量", | ||||
| "Item": "貨品", | "Item": "貨品", | ||||