diff --git a/src/app/(main)/report/AsyncItemCodeAutocomplete.tsx b/src/app/(main)/report/AsyncItemCodeAutocomplete.tsx new file mode 100644 index 00000000..3e1681a1 --- /dev/null +++ b/src/app/(main)/report/AsyncItemCodeAutocomplete.tsx @@ -0,0 +1,223 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { Autocomplete, Chip, CircularProgress, TextField } from "@mui/material"; +import { useTranslation } from "react-i18next"; +import { searchItemCodes, type ItemCodeSearchHit } from "./itemCodeSearchApi"; + +type Props = { + label: string; + value: string[]; + onChange: (codes: string[]) => void; + placeholder?: string; + disabled?: boolean; + minChars?: number; +}; + +const hitLabel = (hit: ItemCodeSearchHit) => + hit.name ? `${hit.code} ${hit.name}` : hit.code; + +/** FP-MTMS Version Checklist | Functions Ref. No. 81 | v1.0.0 | 2026-09-10 */ +const AsyncItemCodeAutocomplete: React.FC = ({ + label, + value, + onChange, + placeholder, + disabled = false, + minChars = 2, +}) => { + const { t } = useTranslation("report"); + const [inputValue, setInputValue] = useState(""); + const [suggestions, setSuggestions] = useState([]); + const [labelByCode, setLabelByCode] = useState>({}); + const [isSearching, setIsSearching] = useState(false); + + const trimmedInput = inputValue.trim(); + const needsMoreChars = trimmedInput.length > 0 && trimmedInput.length < minChars; + + useEffect(() => { + if (trimmedInput.length < minChars) { + setSuggestions([]); + setIsSearching(false); + return; + } + + const controller = new AbortController(); + let cancelled = false; + const timer = window.setTimeout(async () => { + setIsSearching(true); + try { + const hits = await searchItemCodes(trimmedInput, controller.signal); + if (cancelled) return; + setSuggestions(hits); + setLabelByCode((prev) => { + const next = { ...prev }; + hits.forEach((hit) => { + next[hit.code] = hitLabel(hit); + }); + return next; + }); + } catch (error) { + if (cancelled) return; + if (error instanceof DOMException && error.name === "AbortError") return; + setSuggestions([]); + } finally { + if (!cancelled) setIsSearching(false); + } + }, 300); + + return () => { + cancelled = true; + window.clearTimeout(timer); + controller.abort(); + }; + }, [trimmedInput, minChars]); + + const options = useMemo(() => { + const seen = new Set(); + const codes: string[] = []; + suggestions.forEach((hit) => { + if (seen.has(hit.code)) return; + seen.add(hit.code); + codes.push(hit.code); + }); + value.forEach((code) => { + if (seen.has(code)) return; + seen.add(code); + codes.push(code); + }); + return codes; + }, [suggestions, value]); + + const noOptionsText = needsMoreChars + ? t("typeToSearchItemCode", { min: minChars }) + : isSearching + ? t("searchingItemCodes") + : trimmedInput.length < minChars + ? t("typeToSearchItemCode", { min: minChars }) + : t("noItemCodeMatches"); + + const hasSelection = value.length > 0; + + return ( + + trimmedInput.length < minChars + ? [] + : opts.filter((code) => !value.includes(code)) + } + isOptionEqualToValue={(option, selected) => option === selected} + autoHighlight + noOptionsText={noOptionsText} + sx={{ + '& .MuiOutlinedInput-root.MuiAutocomplete-inputRoot': hasSelection + ? { + alignItems: 'flex-start', + alignContent: 'flex-start', + flexWrap: 'wrap', + minHeight: 56, + paddingTop: '32px !important', + paddingBottom: '8px !important', + paddingLeft: '14px !important', + } + : { + alignItems: 'center', + height: 56, + minHeight: 56, + maxHeight: 56, + boxSizing: 'border-box', + paddingTop: '16.5px !important', + paddingBottom: '16.5px !important', + paddingLeft: '14px !important', + }, + '& .MuiOutlinedInput-root.MuiAutocomplete-inputRoot .MuiAutocomplete-input': { + fontSize: '1rem', + padding: '0 !important', + }, + '& .MuiOutlinedInput-root.MuiAutocomplete-inputRoot .MuiAutocomplete-input::placeholder': { + color: 'text.disabled', + opacity: 1, + }, + '& .MuiAutocomplete-tag': { + flex: '1 0 100%', + maxWidth: '100%', + width: '100%', + margin: '6px 0 4px', + }, + }} + componentsProps={{ + popper: { + placement: "top-start", + modifiers: [{ name: "flip", enabled: false }], + }, + }} + onInputChange={(_, next, reason) => { + if (reason === "reset") { + setInputValue(""); + return; + } + setInputValue(next); + }} + onChange={(_, newValue) => { + const codes = (Array.isArray(newValue) ? newValue : []) + .map((item) => (typeof item === "string" ? item.trim() : String(item).trim())) + .filter(Boolean); + onChange(Array.from(new Set(codes))); + setInputValue(""); + }} + getOptionLabel={(option) => labelByCode[option] || option} + renderTags={(selected, getTagProps) => + selected.map((option, index) => ( + + )) + } + renderInput={(params) => ( + + {isSearching ? : null} + {params.InputProps.endAdornment} + + ), + }} + /> + )} + /> + ); +}; + +export default AsyncItemCodeAutocomplete; diff --git a/src/app/(main)/report/ReportSelectionDashboard.tsx b/src/app/(main)/report/ReportSelectionDashboard.tsx index 50f5331a..b5eb3802 100644 --- a/src/app/(main)/report/ReportSelectionDashboard.tsx +++ b/src/app/(main)/report/ReportSelectionDashboard.tsx @@ -171,7 +171,7 @@ function CategoryColumn({ ); } -/** FP-MTMS Version Checklist | Functions Ref. No. 69 | v1.0.1 | 2026-08-31 */ +/** FP-MTMS Version Checklist | Functions Ref. No. 69 | v1.0.2 | 2026-09-10 */ export default function ReportSelectionDashboard({ selectedReportId, onSelectReport, diff --git a/src/app/(main)/report/itemCodeSearchApi.ts b/src/app/(main)/report/itemCodeSearchApi.ts new file mode 100644 index 00000000..34e7f0ca --- /dev/null +++ b/src/app/(main)/report/itemCodeSearchApi.ts @@ -0,0 +1,86 @@ +"use client"; + +import { NEXT_PUBLIC_API_URL } from "@/config/api"; +import { clientAuthFetch } from "@/app/utils/clientAuthFetch"; + +export type ItemCodeSearchHit = { + code: string; + name: string; +}; + +const PAGE_SIZE = 50; + +const extractRecords = (data: unknown): Array<{ code?: string; name?: string }> => { + if (!data) return []; + if (Array.isArray(data)) return data; + if (typeof data === "object" && Array.isArray((data as { records?: unknown }).records)) { + return (data as { records: Array<{ code?: string; name?: string }> }).records; + } + return []; +}; + +const toHits = (data: unknown): ItemCodeSearchHit[] => { + const seen = new Set(); + const hits: ItemCodeSearchHit[] = []; + for (const item of extractRecords(data)) { + const code = (item.code || "").trim(); + if (!code || seen.has(code)) continue; + seen.add(code); + hits.push({ code, name: (item.name || "").trim() }); + } + return hits; +}; + +const fetchItemPage = async ( + field: "code" | "name", + query: string, + signal?: AbortSignal, +): Promise => { + const params = new URLSearchParams({ + [field]: query, + pageSize: String(PAGE_SIZE), + pageNum: "1", + }); + + const response = await clientAuthFetch( + `${NEXT_PUBLIC_API_URL}/items/getRecordByPage?${params.toString()}`, + { + method: "GET", + headers: { "Content-Type": "application/json" }, + signal, + }, + ); + + if (response.status === 401 || response.status === 403) return []; + if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); + + return toHits(await response.json()); +}; + +/** + * FP-MTMS Version Checklist | Functions Ref. No. 81 | v1.0.0 | 2026-09-10 + * Typeahead lookup for report item-code multi-select. + * Uses the existing paged item API so we never load the full catalog. + */ +export const searchItemCodes = async ( + query: string, + signal?: AbortSignal, +): Promise => { + const q = query.trim(); + if (!q) return []; + + const [byCode, byName] = await Promise.all([ + fetchItemPage("code", q, signal), + fetchItemPage("name", q, signal), + ]); + + const seen = new Set(); + const merged: ItemCodeSearchHit[] = []; + for (const hit of [...byCode, ...byName]) { + if (seen.has(hit.code)) continue; + seen.add(hit.code); + merged.push(hit); + if (merged.length >= PAGE_SIZE) break; + } + return merged; +}; diff --git a/src/app/(main)/report/page.tsx b/src/app/(main)/report/page.tsx index 639f62d0..6dec1c10 100644 --- a/src/app/(main)/report/page.tsx +++ b/src/app/(main)/report/page.tsx @@ -29,6 +29,7 @@ import { REPORTS } from '@/config/reportConfig'; import { NEXT_PUBLIC_API_URL } from '@/config/api'; import { clientAuthFetch } from '@/app/utils/clientAuthFetch'; import SemiFGProductionAnalysisReport from './SemiFGProductionAnalysisReport'; +import AsyncItemCodeAutocomplete from './AsyncItemCodeAutocomplete'; import ReportSelectionDashboard from './ReportSelectionDashboard'; import { DatePicker, LocalizationProvider } from '@mui/x-date-pickers'; import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; @@ -48,15 +49,35 @@ import { FEATURE_USAGE_ACTION, logFeatureUsage, } from '@/lib/featureUsageLog'; +import { error as errorColor } from '@/theme/devias-material-kit/colors'; interface ItemCodeWithName { code: string; name: string; } -/** FP-MTMS Version Checklist | Functions Ref. No. 39 | v1.0.0 | 2026-08-03 */ +const FIELD_ERROR_SX = { + '& .MuiOutlinedInput-root.Mui-error': { + '& .MuiOutlinedInput-notchedOutline': { + borderColor: 'error.dark', + boxShadow: `0 0 0 2px ${errorColor.dark}40`, + }, + }, + '& .MuiInputLabel-root.Mui-error': { + color: 'error.dark', + }, + '& .MuiFormHelperText-root.Mui-error': { + color: 'error.dark', + }, + '& .MuiInputLabel-asterisk': { + color: 'error.dark', + }, +}; + +/** FP-MTMS Version Checklist | Functions Ref. No. 39 | v1.0.3 | 2026-09-10 */ /** FP-MTMS Version Checklist | Functions Ref. No. 60 | v1.0.1 | 2026-08-11 */ -/** FP-MTMS Version Checklist | Functions Ref. No. 69 | v1.0.1 | 2026-08-31 */ +/** FP-MTMS Version Checklist | Functions Ref. No. 69 | v1.0.2 | 2026-09-10 */ +/** FP-MTMS Version Checklist | Functions Ref. No. 82 | v1.0.0 | 2026-09-10 */ export default function ReportPage() { const { data: session } = useSession() as { data: SessionWithTokens | null }; const { t, i18n, reportTitle, fieldLabel, optionLabel } = useReportLabels(); @@ -72,6 +93,7 @@ export default function ReportPage() { const [dynamicOptions, setDynamicOptions] = useState>({}); const [showConfirmDialog, setShowConfirmDialog] = useState(false); const [showNoDataDialog, setShowNoDataDialog] = useState(false); + const [fieldErrors, setFieldErrors] = useState>({}); // Find the configuration for the currently selected report const rep012RoundIds = useMemo(() => { @@ -91,6 +113,7 @@ export default function ReportPage() { const handleSelectReport = (reportId: string) => { if (reportId === selectedReportId) return; setSelectedReportId(reportId); + setFieldErrors({}); if (reportId === 'rep-010') { setCriteria({ qcType: 'all', qcItemScope: 'all' }); } else if (reportId === 'rep-004') { @@ -104,6 +127,12 @@ export default function ReportPage() { const handleFieldChange = (name: string, value: string | string[]) => { const stringValue = Array.isArray(value) ? value.join(',') : value; + setFieldErrors((prev) => { + if (!prev[name]) return prev; + const next = { ...prev }; + delete next[name]; + return next; + }); setCriteria((prev) => { const next = { ...prev, [name]: stringValue }; if (currentReport?.id === 'rep-021' && name === 'warehouse') { @@ -246,27 +275,29 @@ export default function ReportPage() { if (currentReport.id === 'rep-012') { if (rep012RoundIds.length === 0) { - alert(t('missingRequired', { - fields: fieldLabel('rep-012', { name: 'stockTakeRoundId', label: '盤點輪次' }), - })); + setFieldErrors({ stockTakeRoundId: t('requiredField') }); return false; } + setFieldErrors({}); return true; } - // Mandatory Field Validation - const missingFields = currentReport.fields - .filter((field) => { - if (!field.required) return false; - return !criteria[field.name]; - }) - .map((field) => fieldLabel(currentReport.id, field)); + const missingFields = currentReport.fields.filter((field) => { + if (!field.required) return false; + return !criteria[field.name]; + }); if (missingFields.length > 0) { - alert(t('missingRequired', { fields: missingFields.join('\n- ') })); + const nextErrors: Record = {}; + missingFields.forEach((field) => { + nextErrors[field.name] = t('requiredField'); + }); + setFieldErrors(nextErrors); return false; } + setFieldErrors({}); + // Date fields with minDate: 'today' must not be before local today const today = new Date(); const todayStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`; @@ -528,6 +559,7 @@ export default function ReportPage() { > {currentReport.fields.map((field) => { + const fieldKey = `${currentReport.id}-${field.name}`; const translatedLabel = fieldLabel(currentReport.id, field); const rawOptions = field.dynamicOptions ? (dynamicOptions[field.name] || field.options || []) @@ -555,8 +587,9 @@ export default function ReportPage() { if (field.type === 'date') { const parsed = currentValue ? dayjs(currentValue) : null; + const dateError = fieldErrors[field.name]; return ( - + @@ -590,7 +629,7 @@ export default function ReportPage() { if (field.type === 'checkbox') { return ( - + + handleFieldChange(field.name, codes)} + minChars={field.asyncSearchMinChars ?? 2} + disabled={disabledByCheckedCheckbox || disabledRep012Status || !!field.disabled} + /> + + ); + } + // Use Autocomplete for fields that allow input if (field.type === 'select' && field.allowInput) { const autocompleteValue = field.multiple @@ -613,7 +668,7 @@ export default function ReportPage() { : (valueForSelect || null); return ( - + )} renderTags={(value, getTagProps) => @@ -696,22 +757,28 @@ export default function ReportPage() { // Regular TextField for other fields return ( - + { if (field.multiple) { const value = typeof e.target.value === 'string' diff --git a/src/app/(main)/report/reportCategories.ts b/src/app/(main)/report/reportCategories.ts index 543e6186..2b2f7aa7 100644 --- a/src/app/(main)/report/reportCategories.ts +++ b/src/app/(main)/report/reportCategories.ts @@ -9,7 +9,7 @@ export interface ReportCategoryConfig { reportIds: string[]; } -/** FP-MTMS Version Checklist | Functions Ref. No. 69 | v1.0.1 | 2026-08-31 */ +/** FP-MTMS Version Checklist | Functions Ref. No. 69 | v1.0.2 | 2026-09-10 */ export const REPORT_CATEGORIES: ReportCategoryConfig[] = [ { id: "inventory", diff --git a/src/app/(main)/report/reportI18n.ts b/src/app/(main)/report/reportI18n.ts index 86daf4e0..7e8be629 100644 --- a/src/app/(main)/report/reportI18n.ts +++ b/src/app/(main)/report/reportI18n.ts @@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next"; import type { TFunction } from "i18next"; import type { ReportDefinition, ReportField } from "@/config/reportConfig"; -/** FP-MTMS Version Checklist | Functions Ref. No. 69 | v1.0.1 | 2026-08-31 */ +/** FP-MTMS Version Checklist | Functions Ref. No. 69 | v1.0.2 | 2026-09-10 */ export function useReportLabels() { const { t, i18n } = useTranslation("report"); diff --git a/src/app/api/inventory/actions.ts b/src/app/api/inventory/actions.ts index 8cca8117..196ba7f6 100644 --- a/src/app/api/inventory/actions.ts +++ b/src/app/api/inventory/actions.ts @@ -28,8 +28,12 @@ export interface LotLineInfo { export interface SearchInventoryLotLine extends Pageable { itemId: number; + uomId?: number; /** Non-expired lots with in > out; includes available and unavailable. */ stockIssueBadItem?: boolean; + storeId?: string; + warehouse?: string; + area?: string; } export interface SearchStockIssueBadItemLotLine extends Pageable { @@ -44,6 +48,9 @@ export interface SearchInventory extends Pageable { name: string; type: string; lotNo?: string; + storeId?: string; + warehouse?: string; + area?: string; } export interface InventoryResultByPage { @@ -163,20 +170,25 @@ async function fetchInventoriesImpl(data: SearchInventory) { export const fetchInventories = cache(fetchInventoriesImpl); -/** - * ChangeList #24 — same as fetchInventories (all stock-UOM buckets). - * Backend `/inventory/searchLatest/getRecordByPage` is deprecated and aliases getRecordByPage. - */ -export const fetchInventoriesLatest = cache(fetchInventoriesImpl); +async function fetchInventoriesLatestImpl(data: SearchInventory) { + const queryStr = convertObjToURLSearchParams(data); + return serverFetchJson( + `${BASE_API_URL}/inventory/searchLatest/getRecordByPage?${queryStr}`, + { next: { tags: ["inventories"] } }, + ); +} + +/** Location Search: lot-line grouped by item + stock UoM, with optional location filters. */ +export const fetchInventoriesLatest = cache(fetchInventoriesLatestImpl); /** Bypass React cache() after mutations so lists show fresh qty. */ export async function fetchInventoriesFresh(data: SearchInventory) { return fetchInventoriesImpl(data); } -/** @deprecated Use fetchInventoriesFresh — same full-page search. */ +/** Bypass React cache() for Location Search (searchLatest). */ export async function fetchInventoriesLatestFresh(data: SearchInventory) { - return fetchInventoriesImpl(data); + return fetchInventoriesLatestImpl(data); } async function fetchInventoryLotLinesImpl(data: SearchInventoryLotLine) { @@ -187,6 +199,7 @@ async function fetchInventoryLotLinesImpl(data: SearchInventoryLotLine) { ); } +/** FP-MTMS Version Checklist | Functions Ref. No. 18 | v1.0.4 | 2026-09-10 */ export const fetchInventoryLotLines = cache(fetchInventoryLotLinesImpl); /** Bypass React cache() after mutations so lists show fresh qty. */ diff --git a/src/app/api/inventory/index.ts b/src/app/api/inventory/index.ts index 8002224c..74a7bb52 100644 --- a/src/app/api/inventory/index.ts +++ b/src/app/api/inventory/index.ts @@ -14,7 +14,10 @@ export interface InventoryResult { onHoldQty: number; unavailableQty: number; availableQty: number; + /** Item Search / getRecordByPage (inventory bucket). */ stockUomId?: number | null; + /** Location Search / searchLatest (lot-line grouped row). Same UOM id space as stockUomId. */ + uomId?: number; uomCode: string; uomUdfudesc: string; uomShortDesc: string; diff --git a/src/app/api/stockIn/actions.ts b/src/app/api/stockIn/actions.ts index 9f74ae87..159c564a 100644 --- a/src/app/api/stockIn/actions.ts +++ b/src/app/api/stockIn/actions.ts @@ -38,6 +38,10 @@ export interface StockInLineEntry { receiptDate?: string; dnDate?: string; dnNo?: string; + stockQtyRoundMode?: "CEILING" | "FLOOR" | "HALF_UP" | "CUSTOM"; + stockQtyCustomQty?: number; + stockQtyRoundSource?: "CREATE" | "QC"; + stockQtyCustomReason?: string; } export interface QcResult{ @@ -66,6 +70,7 @@ export interface StockInInput { productionDate?: string; expiryDate: string; uom: Uom; + stockQtyRoundMode?: "CEILING" | "FLOOR"; } export interface QCInput { status: string; diff --git a/src/app/api/stockIn/index.ts b/src/app/api/stockIn/index.ts index b4951bcc..94d1aeec 100644 --- a/src/app/api/stockIn/index.ts +++ b/src/app/api/stockIn/index.ts @@ -50,6 +50,7 @@ export interface StockInInput { productionDate?: string; expiryDate: string; uom?: Uom; + stockQtyRoundMode?: "CEILING" | "FLOOR"; } export interface PoResult { diff --git a/src/components/InventorySearch/InventoryLotLineTable.tsx b/src/components/InventorySearch/InventoryLotLineTable.tsx index 9e3a041f..237112dc 100644 --- a/src/components/InventorySearch/InventoryLotLineTable.tsx +++ b/src/components/InventorySearch/InventoryLotLineTable.tsx @@ -54,15 +54,18 @@ interface Props { totalCount: number; inventory: InventoryResult | null; filterLotNo?: string; + /** Location search: show only the slot (e.g. 00), not the full warehouse code. */ + warehouseDisplay?: "full" | "slot"; onStockTransferSuccess?: () => void | Promise; printerCombo?: PrinterCombo[]; onStockAdjustmentSuccess?: () => void | Promise; } -/** FP-MTMS Version Checklist | Functions Ref. No. 3 | v1.0.7 | 2026-09-08 */ +/** FP-MTMS Version Checklist | Functions Ref. No. 3 | v1.0.8 | 2026-09-10 */ const InventoryLotLineTable: React.FC = ({ inventoryLotLines, pagingController, setPagingController, totalCount, inventory, filterLotNo, + warehouseDisplay = "full", onStockTransferSuccess, printerCombo = [], onStockAdjustmentSuccess, }) => { @@ -479,9 +482,12 @@ const prevAdjustmentModalOpenRef = useRef(false); }, { name: "warehouse", - label: t("Warehouse"), + label: warehouseDisplay === "slot" ? t("Slot") : t("Warehouse"), renderCell: (params) => { - return `${params.warehouse.code}` + const code = params.warehouse?.code ?? ""; + if (warehouseDisplay !== "slot") return code; + const parts = code.split("-").filter(Boolean); + return parts[parts.length - 1] || code; }, }, { @@ -522,7 +528,7 @@ const prevAdjustmentModalOpenRef = useRef(false); // } // }, ], - [t, onDetailClick, downloadQrCode, handleStockTransfer, handlePrintClick], + [t, onDetailClick, downloadQrCode, handleStockTransfer, handlePrintClick, warehouseDisplay], ); diff --git a/src/components/InventorySearch/InventorySearch.tsx b/src/components/InventorySearch/InventorySearch.tsx index 8d278b6a..170f051e 100644 --- a/src/components/InventorySearch/InventorySearch.tsx +++ b/src/components/InventorySearch/InventorySearch.tsx @@ -13,6 +13,7 @@ import { SearchInventory, SearchInventoryLotLine, fetchInventories, + fetchInventoriesLatest, fetchInventoryLotLines, } from '@/app/api/inventory/actions'; import { PrinterCombo } from '@/app/api/settings/printer'; @@ -20,10 +21,19 @@ 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'; +import { WarehouseResult } from '@/app/api/warehouse'; +import { fetchWarehouseListClient } from '@/app/api/warehouse/client'; +import LocationFilterBar, { + emptyLocationFilter, + isLocationAll, + LocationFilterValue, +} from './LocationFilterBar'; interface Props { inventories: InventoryResult[]; printerCombo?: PrinterCombo[]; + warehouses?: WarehouseResult[]; + enableLocationFilter?: boolean; } type SearchQuery = Partial< @@ -33,6 +43,8 @@ type SearchQuery = Partial< | "qty" | "uomCode" | "uomUdfudesc" + | "uomId" + | "stockUomId" | "germPerSmallestUnit" | "qtyPerSmallestUnit" | "itemSmallestUnit" @@ -62,8 +74,22 @@ const extractItemRecords = (res: unknown): ItemLookupRow[] => { return []; }; -/** FP-MTMS Version Checklist | Functions Ref. No. 18 | v1.0.3 | 2026-09-07 */ -const InventorySearch: React.FC = ({ inventories, printerCombo }) => { +/** Item Search uses stockUomId; Location Search uses uomId. Same UOM id space. */ +const inventoryStockUomId = (row?: InventoryResult | null) => + row?.stockUomId ?? row?.uomId; + +const inventoryPageRowKey = (row: InventoryResult, byLocation: boolean) => + byLocation + ? `${row.itemId}-${row.uomId ?? row.stockUomId ?? row.uomUdfudesc ?? ''}` + : String(row.id); + +/** FP-MTMS Version Checklist | Functions Ref. No. 18 | v1.0.4 | 2026-09-10 */ +const InventorySearch: React.FC = ({ + inventories, + printerCombo, + warehouses = [], + enableLocationFilter = false, +}) => { const { t } = useTranslation(['inventory', 'common', 'item']); const { data: session } = useSession(); const abilities = session?.abilities ?? session?.user?.abilities ?? []; @@ -140,6 +166,19 @@ const InventorySearch: React.FC = ({ inventories, printerCombo }) => { // Resolved lot no for filtering const [lotNoFilter, setLotNoFilter] = useState(''); const [scannedItemId, setScannedItemId] = useState(null); + const [location, setLocation] = useState(emptyLocationFilter); + const [locationWarehouses, setLocationWarehouses] = useState(warehouses); + + useEffect(() => { + if (!enableLocationFilter) return; + if (warehouses.length) { + setLocationWarehouses(warehouses); + return; + } + fetchWarehouseListClient() + .then(setLocationWarehouses) + .catch(console.error); + }, [enableLocationFilter, warehouses]); const defaultInputs = useMemo( () => ({ @@ -186,30 +225,47 @@ const InventorySearch: React.FC = ({ inventories, printerCombo }) => { ); // Inventory + const withLocationParams = useCallback( + (params: T, loc: LocationFilterValue): T & Partial => { + if (!enableLocationFilter) return params; + return { + ...params, + ...(!isLocationAll(loc.storeId) ? { storeId: loc.storeId } : {}), + ...(!isLocationAll(loc.warehouse) ? { warehouse: loc.warehouse } : {}), + ...(!isLocationAll(loc.area) ? { area: loc.area } : {}), + }; + }, + [enableLocationFilter], + ); + const refetchInventoryData = useCallback( async ( query: Record, actionType: 'reset' | 'search' | 'paging' | 'init', pagingController: typeof defaultPagingController, lotNo: string, + loc: LocationFilterValue = location, ) => { - //console.log('%c Action Type 1.', 'color:red', actionType); // Avoid loading data again if (actionType === 'paging' && pagingController === defaultPagingController) { return; } - // console.log('%c Action Type 2.', 'color:blue', actionType); - - const params: SearchInventory = { - code: query?.itemCode ?? '', - name: query?.itemName ?? '', - type: query?.itemType.toLowerCase() === 'all' ? '' : query?.itemType ?? '', - lotNo: lotNo?.trim() ? lotNo.trim() : undefined, - pageNum: pagingController.pageNum - 1, - pageSize: pagingController.pageSize, - }; - const response = await fetchInventories(params); + const params: SearchInventory = withLocationParams( + { + code: query?.itemCode ?? '', + name: query?.itemName ?? '', + type: query?.itemType.toLowerCase() === 'all' ? '' : query?.itemType ?? '', + lotNo: lotNo?.trim() ? lotNo.trim() : undefined, + pageNum: pagingController.pageNum - 1, + pageSize: pagingController.pageSize, + }, + loc, + ); + + const response = await (enableLocationFilter + ? fetchInventoriesLatest(params) + : fetchInventories(params)); if (response) { setInventoriesTotalCount(response.total); @@ -221,19 +277,23 @@ const InventorySearch: React.FC = ({ inventories, printerCombo }) => { break; case 'paging': setFilteredInventories((fi) => - uniqBy([...fi, ...response.records], 'id'), + uniqBy([...fi, ...response.records], (row) => + inventoryPageRowKey(row, enableLocationFilter), + ), ); } } return response; }, - [], + [enableLocationFilter, location, withLocationParams], ); useEffect(() => { refetchInventoryData(defaultInputs, 'init', defaultPagingController, ''); - }, [defaultInputs, refetchInventoryData]); + // Mount / tab open only. Location changes search via handleLocationChange. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [defaultInputs]); useEffect(() => { // if (!isEqual(inventoriesPagingController, defaultPagingController)) { @@ -247,6 +307,8 @@ const InventorySearch: React.FC = ({ inventories, printerCombo }) => { itemId: number | null, actionType: 'reset' | 'search' | 'paging', pagingController: typeof defaultPagingController, + loc: LocationFilterValue = location, + uomId?: number, ) => { if (!itemId) { setSelectedInventory(null); @@ -260,11 +322,15 @@ const InventorySearch: React.FC = ({ inventories, printerCombo }) => { return; } - const params: SearchInventoryLotLine = { - itemId, - pageNum: pagingController.pageNum - 1, - pageSize: pagingController.pageSize, - }; + const params: SearchInventoryLotLine = withLocationParams( + { + itemId, + uomId: uomId || undefined, + pageNum: pagingController.pageNum - 1, + pageSize: pagingController.pageSize, + }, + loc, + ); const response = await fetchInventoryLotLines(params); if (response) { @@ -279,20 +345,27 @@ const InventorySearch: React.FC = ({ inventories, printerCombo }) => { } } }, - [], + [location, withLocationParams], ); useEffect(() => { // if (!isEqual(inventoryLotLinesPagingController, defaultPagingController)) { - refetchInventoryLotLineData(selectedInventory?.itemId ?? null, 'paging', inventoryLotLinesPagingController) + refetchInventoryLotLineData( + selectedInventory?.itemId ?? null, + 'paging', + inventoryLotLinesPagingController, + location, + inventoryStockUomId(selectedInventory), + ) // } }, [inventoryLotLinesPagingController]) // Reset const onReset = useCallback(() => { - refetchInventoryData(defaultInputs, 'reset', defaultPagingController, ''); - refetchInventoryLotLineData(null, 'reset', defaultPagingController); - // setFilteredInventories(inventories); + const clearedLocation = emptyLocationFilter(); + setLocation(clearedLocation); + refetchInventoryData(defaultInputs, 'reset', defaultPagingController, '', clearedLocation); + refetchInventoryLotLineData(null, 'reset', defaultPagingController, clearedLocation); setLotNoFilter(''); setScannedItemId(null); @@ -305,14 +378,38 @@ const InventorySearch: React.FC = ({ inventories, printerCombo }) => { setInventoryLotLinesPagingController(() => defaultPagingController) }, [defaultInputs, qrScanner, refetchInventoryData, refetchInventoryLotLineData]); + const handleLocationChange = useCallback( + (next: LocationFilterValue) => { + setLocation(next); + setSelectedInventory(null); + setFilteredInventoryLotLines([]); + setInventoryLotLinesTotalCount(0); + setInventoryLotLinesPagingController(() => defaultPagingController); + setInventoriesPagingController(() => defaultPagingController); + if (searchInFlightRef.current) return; + searchInFlightRef.current = true; + refetchInventoryData(inputs, 'search', defaultPagingController, lotNoFilter, next) + .finally(() => { + searchInFlightRef.current = false; + }); + }, + [inputs, lotNoFilter, refetchInventoryData], + ); + // Click Row const onInventoryRowClick = useCallback( (item: InventoryResult) => { - refetchInventoryLotLineData(item.itemId, 'search', defaultPagingController); + refetchInventoryLotLineData( + item.itemId, + 'search', + defaultPagingController, + location, + inventoryStockUomId(item), + ); setSelectedInventory(item); setInventoryLotLinesPagingController(() => defaultPagingController); }, - [refetchInventoryLotLineData], + [location, refetchInventoryLotLineData], ); // On Search @@ -335,7 +432,7 @@ const InventorySearch: React.FC = ({ inventories, printerCombo }) => { 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) { + if (canStockAdjust && !enableLocationFilter && invRes?.records?.length === 0) { try { const items = await lookupItemsByCodeOrName(query.itemCode, query.itemName); const typeFilter = query.itemType?.trim(); @@ -367,6 +464,7 @@ const InventorySearch: React.FC = ({ inventories, printerCombo }) => { lookupItemsByCodeOrName, applyItemsAsSyntheticInventories, canStockAdjust, + enableLocationFilter, ], ); @@ -452,6 +550,15 @@ const InventorySearch: React.FC = ({ inventories, printerCombo }) => { onSearch(query); }} onReset={onReset} + extraCriteria={ + enableLocationFilter ? ( + + ) : undefined + } extraActions={ {scanUiMode === 'idle' ? ( @@ -485,16 +592,20 @@ const InventorySearch: React.FC = ({ inventories, printerCombo }) => { totalCount={inventoryLotLinesTotalCount} inventory={selectedInventory} filterLotNo={lotNoFilter} + warehouseDisplay={enableLocationFilter ? "slot" : "full"} printerCombo={printerCombo ?? []} onStockTransferSuccess={() => refetchInventoryLotLineData( selectedInventory?.itemId ?? null, 'search', inventoryLotLinesPagingController, + location, + inventoryStockUomId(selectedInventory), ) } onStockAdjustmentSuccess={async () => { const itemId = selectedInventory?.itemId ?? null; + const uomId = inventoryStockUomId(selectedInventory); // Refresh both blocks: // - middle: InventoryTable (inventories list) @@ -510,11 +621,17 @@ const InventorySearch: React.FC = ({ inventories, printerCombo }) => { itemId, 'search', inventoryLotLinesPagingController, + location, + uomId, ); // If inventory becomes available again after OPEN/ADJ, sync selected row. if (itemId != null && invRes?.records?.length) { - const target = invRes.records.find((r) => r.itemId === itemId); + const target = invRes.records.find( + (r) => + r.itemId === itemId && + (uomId == null || inventoryStockUomId(r) === uomId), + ); if (target) setSelectedInventory(target); } }} diff --git a/src/components/InventorySearch/InventorySearchPage.tsx b/src/components/InventorySearch/InventorySearchPage.tsx new file mode 100644 index 00000000..b494bde6 --- /dev/null +++ b/src/components/InventorySearch/InventorySearchPage.tsx @@ -0,0 +1,54 @@ +"use client"; + +import { InventoryResult } from "@/app/api/inventory"; +import { PrinterCombo } from "@/app/api/settings/printer"; +import { WarehouseResult } from "@/app/api/warehouse"; +import { Box, Tab, Tabs } from "@mui/material"; +import { useCallback, useState } from "react"; +import { useTranslation } from "react-i18next"; +import InventorySearch from "./InventorySearch"; + +type TabValue = "item" | "location"; + +interface Props { + inventories: InventoryResult[]; + printerCombo?: PrinterCombo[]; + warehouses?: WarehouseResult[]; +} + +/** FP-MTMS Version Checklist | Functions Ref. No. 79 | v1.0.0 | 2026-09-10 */ +const InventorySearchPage: React.FC = ({ + inventories, + printerCombo, + warehouses = [], +}) => { + const { t } = useTranslation("inventory"); + const [tab, setTab] = useState("item"); + + const handleTabChange = useCallback((_: React.SyntheticEvent, value: string) => { + setTab(value as TabValue); + }, []); + + return ( + + + + + + + {tab === "item" && ( + + )} + {tab === "location" && ( + + )} + + ); +}; + +export default InventorySearchPage; diff --git a/src/components/InventorySearch/InventorySearchWrapper.tsx b/src/components/InventorySearch/InventorySearchWrapper.tsx index cffbfc52..6b9227bd 100644 --- a/src/components/InventorySearch/InventorySearchWrapper.tsx +++ b/src/components/InventorySearch/InventorySearchWrapper.tsx @@ -1,20 +1,29 @@ import React from "react"; import GeneralLoading from "../General/GeneralLoading"; import { fetchInventories } from "@/app/api/inventory"; -import InventorySearch from "./InventorySearch"; +import InventorySearchPage from "./InventorySearchPage"; import { fetchPrinterCombo } from "@/app/api/settings/printer"; +import { fetchWarehouseList } from "@/app/api/warehouse"; interface SubComponents { Loading: typeof GeneralLoading; } +/** FP-MTMS Version Checklist | Functions Ref. No. 79 | v1.0.0 | 2026-09-10 */ const InventorySearchWrapper: React.FC & SubComponents = async () => { - const [inventories, printerCombo] = await Promise.all([ + const [inventories, printerCombo, warehouses] = await Promise.all([ fetchInventories(), fetchPrinterCombo(), + fetchWarehouseList().catch(() => []), ]); - return ; + return ( + + ); }; InventorySearchWrapper.Loading = GeneralLoading; diff --git a/src/components/InventorySearch/LocationFilterBar.tsx b/src/components/InventorySearch/LocationFilterBar.tsx new file mode 100644 index 00000000..469b6b17 --- /dev/null +++ b/src/components/InventorySearch/LocationFilterBar.tsx @@ -0,0 +1,229 @@ +"use client"; + +import { WarehouseResult } from "@/app/api/warehouse"; +import { Box, ToggleButton, ToggleButtonGroup, Typography } from "@mui/material"; +import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; + +export const LOCATION_ALL = "ALL"; +const BUTTONS_PER_ROW = 15; +const BUTTON_WIDTH = 80; + +export type LocationFilterValue = { + storeId: string; + warehouse: string; + area: string; +}; + +const EMPTY_LOCATION: LocationFilterValue = { + storeId: "", + warehouse: "", + area: "", +}; + +export const emptyLocationFilter = (): LocationFilterValue => ({ ...EMPTY_LOCATION }); + +export const isLocationAll = (value?: string) => !value || value === LOCATION_ALL; + +const warehouseSegments = (w: WarehouseResult) => { + const parts = (w.code || "").split("-"); + return { + storeId: w.store_id?.trim() || parts[0] || "", + warehouse: w.warehouse?.trim() || parts[1] || "", + area: w.area?.trim() || parts[2] || "", + }; +}; + +const compareAlphanumeric = (a: string, b: string) => + a.localeCompare(b, undefined, { numeric: true, sensitivity: "base" }); + +const chunk = (items: T[], size: number): T[][] => { + const rows: T[][] = []; + for (let i = 0; i < items.length; i += size) { + rows.push(items.slice(i, i + size)); + } + return rows; +}; + +const withAllOption = (options: string[]) => + options.length > 1 ? [LOCATION_ALL, ...options] : options; + +interface Props { + warehouses: WarehouseResult[]; + value: LocationFilterValue; + onChange: (next: LocationFilterValue) => void; +} + +/** FP-MTMS Version Checklist | Functions Ref. No. 79 | v1.0.0 | 2026-09-10 */ +const LocationFilterBar: React.FC = ({ warehouses, value, onChange }) => { + const { t } = useTranslation("inventory"); + + const floors = useMemo(() => { + const set = new Set(); + warehouses.forEach((w) => { + const storeId = warehouseSegments(w).storeId; + if (storeId) set.add(storeId); + }); + return Array.from(set).sort(compareAlphanumeric); + }, [warehouses]); + + const warehouseEnabled = Boolean(value.storeId); + const areaEnabled = Boolean(value.storeId && value.warehouse); + + const warehouseZones = useMemo(() => { + if (!value.storeId) return []; + const set = new Set(); + warehouses.forEach((w) => { + const seg = warehouseSegments(w); + if (!isLocationAll(value.storeId) && seg.storeId !== value.storeId) return; + if (seg.warehouse) set.add(seg.warehouse); + }); + return Array.from(set).sort(compareAlphanumeric); + }, [warehouses, value.storeId]); + + const areas = useMemo(() => { + if (!value.storeId || !value.warehouse) return []; + const set = new Set(); + warehouses.forEach((w) => { + const seg = warehouseSegments(w); + if (!isLocationAll(value.storeId) && seg.storeId !== value.storeId) return; + if (!isLocationAll(value.warehouse) && seg.warehouse !== value.warehouse) return; + if (seg.area) set.add(seg.area); + }); + return Array.from(set).sort(compareAlphanumeric); + }, [warehouses, value.storeId, value.warehouse]); + + const floorOptions = useMemo(() => withAllOption(floors), [floors]); + + const warehouseRows = useMemo( + () => chunk(withAllOption(warehouseZones), BUTTONS_PER_ROW), + [warehouseZones], + ); + + const areaRows = useMemo( + () => chunk(withAllOption(areas), BUTTONS_PER_ROW), + [areas], + ); + + return ( + + + + {t("Floor")} + + { + onChange({ storeId: next ?? "", warehouse: "", area: "" }); + }} + > + {floorOptions.map((floor) => ( + + {floor === LOCATION_ALL ? t("All") : floor} + + ))} + + + + + + {t("Warehouse")} + + + {warehouseRows.map((row, rowIndex) => ( + { + if (next == null) return; + onChange({ ...value, warehouse: next, area: "" }); + }} + sx={{ + "& .MuiToggleButtonGroup-grouped": { + width: BUTTON_WIDTH, + minWidth: BUTTON_WIDTH, + maxWidth: BUTTON_WIDTH, + px: 0, + boxSizing: "border-box", + }, + }} + > + {row.map((zone) => ( + + {zone === LOCATION_ALL ? t("All") : zone} + + ))} + + ))} + + {!warehouseEnabled && ( + + {t("Select floor first")} + + )} + + + + + {t("Area")} + + + {areaRows.map((row, rowIndex) => ( + { + if (next == null) return; + onChange({ ...value, area: next }); + }} + sx={{ + "& .MuiToggleButtonGroup-grouped": { + width: BUTTON_WIDTH, + minWidth: BUTTON_WIDTH, + maxWidth: BUTTON_WIDTH, + px: 0, + boxSizing: "border-box", + }, + }} + > + {row.map((area) => ( + + {area === LOCATION_ALL ? t("All") : area} + + ))} + + ))} + + {!areaEnabled && ( + + {value.storeId ? t("Select warehouse first") : t("Select floor first")} + + )} + + + ); +}; + +export default LocationFilterBar; diff --git a/src/components/PoDetail/PoDetail.tsx b/src/components/PoDetail/PoDetail.tsx index 36bffeb5..4b16fc53 100644 --- a/src/components/PoDetail/PoDetail.tsx +++ b/src/components/PoDetail/PoDetail.tsx @@ -9,9 +9,7 @@ import { Box, Button, ButtonProps, - Collapse, Grid, - IconButton, Paper, Stack, Tab, @@ -29,16 +27,14 @@ import { FormControlLabel, Card, CardContent, - Radio, - alpha, Dialog, DialogActions, DialogContent, DialogTitle, } from "@mui/material"; import { useTranslation } from "react-i18next"; -import { submitDialogWithWarning } from "../Swal/CustomAlerts"; import PrinterSelect from "@/components/common/PrinterSelect"; +import { PoDetailRow } from "./PoDetailRow"; // import InputDataGrid, { TableRow } from "../InputDataGrid/InputDataGrid"; import { GridColDef, @@ -52,9 +48,6 @@ import { fetchPoSummariesClient, startPo, } from "@/app/api/po/actions"; -import { - createStockInLine -} from "@/app/api/stockIn/actions"; import { useCallback, useContext, @@ -63,20 +56,16 @@ import { useRef, useState, } from "react"; -import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown"; -import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp"; import PoInputGrid from "./PoInputGrid"; // import { QcItemWithChecks } from "@/app/api/qc"; import { useRouter, useSearchParams, usePathname } from "next/navigation"; import { WarehouseResult } from "@/app/api/warehouse"; -import { calculateWeight, dateStringToDayjs, dayjsToDateString, OUTPUT_DATE_FORMAT, outputDateStringToInputDateString, returnWeightUnit } from "@/app/utils/formatUtil"; +import { dateStringToDayjs, dayjsToDateString, OUTPUT_DATE_FORMAT, outputDateStringToInputDateString, decimalFormatter, arrayToDateString } from "@/app/utils/formatUtil"; import { CameraContext } from "../Cameras/CameraProvider"; import QrModal from "./QrModal"; import { PlayArrow } from "@mui/icons-material"; import DoneIcon from "@mui/icons-material/Done"; import { downloadFile, getCustomWidth } from "@/app/utils/commonUtil"; -import { decimalFormatter, integerFormatter } from "@/app/utils/formatUtil"; -import { arrayToDateString } from "@/app/utils/formatUtil"; import { List, ListItem, ListItemButton, ListItemText, Divider } from "@mui/material"; import { Controller, FormProvider, useForm } from "react-hook-form"; import dayjs, { Dayjs } from "dayjs"; @@ -100,41 +89,6 @@ type Props = { printerCombo: PrinterCombo[]; }; -/** PO stock-in lines still in pre-complete workflow (align with nav alert: pending / receiving). */ -const PURCHASE_STOCK_IN_ALERT_STATUSES = new Set(["pending", "receiving"]); - -/** Sum of put-away in stock units (matches StockInForm「已上架數量」stockQty). */ -function totalPutAwayStockQtyForPol(row: PurchaseOrderLine): number { - return row.stockInLine - .filter((sil) => sil.purchaseOrderLineId === row.id) - .reduce((acc, sil) => { - const lineSum = - sil.putAwayLines?.reduce( - (s, p) => s + Number(p.stockQty ?? p.qty ?? 0), - 0, - ) ?? 0; - return acc + lineSum; - }, 0); -} - -/** POL order demand in stock units (same basis as PoDetail processed / backend PO detail). */ -function polOrderStockQty(row: PurchaseOrderLine): number { - return Number(row.stockUom?.stockQty ?? row.qty ?? 0); -} - -function purchaseOrderLineHasIncompleteStockIn(row: PurchaseOrderLine): boolean { - const orderStock = polOrderStockQty(row); - const putAway = totalPutAwayStockQtyForPol(row); - if (orderStock > 0 && putAway >= orderStock) { - return false; - } - return row.stockInLine - .filter((sil) => sil.purchaseOrderLineId === row.id) - .some((sil) => - PURCHASE_STOCK_IN_ALERT_STATUSES.has((sil.status ?? "").toLowerCase().trim()), - ); -} - type EntryError = | { [field in keyof StockInLine]?: string; @@ -253,7 +207,7 @@ interface PolInputResult { dnQty: string, } -/** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.2 | 2026-09-07 */ +/** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.4 | 2026-09-10 */ const PoDetail: React.FC = ({ po, warehouse, printerCombo }) => { const cameras = useContext(CameraContext); const { data: session } = useSession(); @@ -567,6 +521,48 @@ const PoDetail: React.FC = ({ po, warehouse, printerCombo }) => { setPurchaseOrder(newPo); }, [purchaseOrder.id]); + const getDnValues = useCallback(() => dnFormProps.getValues(), [dnFormProps]); + + const formatReceiptDate = useCallback((receiptDate?: string) => { + return outputDateStringToInputDateString(receiptDate ?? ""); + }, []); + + const handleSelectPol = useCallback((row: PurchaseOrderLine) => { + selectedPolIdRef.current = row.id; + setSelectedRow(row); + setStockInLine(row.stockInLine ?? []); + setProcessedQty(row.processed); + patchPoEditQuery((params) => { + params.set("polId", String(row.id)); + params.delete("stockInLineId"); + }); + }, [patchPoEditQuery]); + + const handleRowInputBlur = useCallback((rowId: number, lotNo: string, dnQty: string) => { + setPolInputList((prev) => { + const current = prev[rowId] ?? { lotNo: "", dnQty: "" }; + if (current.lotNo === lotNo && current.dnQty === dnQty) return prev; + return { + ...prev, + [rowId]: { lotNo, dnQty }, + }; + }); + }, []); + + const handleRowSubmitted = useCallback((row: PurchaseOrderLine) => { + setPolInputList((prev) => ({ + ...prev, + [row.id]: { lotNo: "", dnQty: "" }, + })); + selectedPolIdRef.current = row.id; + setSelectedRow(row); + patchPoEditQuery((params) => { + params.set("polId", String(row.id)); + params.delete("stockInLineId"); + }); + fetchPoDetail(selectedPoId.toString(), true, row.id); + }, [fetchPoDetail, patchPoEditQuery, selectedPoId]); + const handleMailTemplateForStockInLine = useCallback(async (stockInLineId: number) => { const response = await getMailTemplatePdfForStockInLine(stockInLineId) if (response) { @@ -582,322 +578,6 @@ const PoDetail: React.FC = ({ po, warehouse, printerCombo }) => { // setStockInLine([]) // }, []); - function Row(props: { row: PurchaseOrderLine }) { - const { row } = props; - // const [firstReceiveQty, setFirstReceiveQty] = useState() - // const [secondReceiveQty, setSecondReceiveQty] = useState() - // const [open, setOpen] = useState(false); - const [processedQty, setProcessedQty] = useState(row.processed); - const [currStatus, setCurrStatus] = useState(row.status); - const [lotNoInput, setLotNoInput] = useState(polInputList[row.id]?.lotNo ?? ""); - const [dnQtyInput, setDnQtyInput] = useState(polInputList[row.id]?.dnQty ?? ""); - // const [stockInLine, setStockInLine] = useState(row.stockInLine); - const totalWeight = useMemo( - () => calculateWeight(row.qty, row.uom), - [row.qty, row.uom], - ); - const weightUnit = useMemo( - () => returnWeightUnit(row.uom), - [row.uom], - ); - useEffect(() => { - // `processedQty` comes from putAwayLines (stock unit). - // After the fix, `row.qty` is qtyM18 (M18 unit), so compare using stockUom demand. - const targetStockQty = Number(row.stockUom?.stockQty ?? row.qty ?? 0); - if (targetStockQty > 0 && processedQty >= targetStockQty) { - setCurrStatus("completed".toUpperCase()); - } else if (processedQty > 0) { - setCurrStatus("receiving".toUpperCase()); - } else { - setCurrStatus("pending".toUpperCase()); - } - }, [processedQty, row.qty, row.stockUom?.stockQty]); - - useEffect(() => { - setLotNoInput(polInputList[row.id]?.lotNo ?? ""); - setDnQtyInput(polInputList[row.id]?.dnQty ?? ""); - }, [polInputList, row.id]); - - const changeStockInLines = useCallback( - (id: number) => { - const target = rows.find((r) => r.id === id); - if (!target) return; - selectedPolIdRef.current = id; - setSelectedRow(target); - setStockInLine(target.stockInLine ?? []); - setProcessedQty(target.processed); - - // history.replaceState: keep URL in sync without scrolling to top - patchPoEditQuery((params) => { - params.set("polId", String(id)); - params.delete("stockInLineId"); - }); - }, - [rows, patchPoEditQuery], - ); - - const handleStart = useCallback( - () => { - const orderQty = Number(row?.qty) ?? 0; - const acceptedQty = Number(dnQtyInput.trim()); - - if (isNaN(acceptedQty) || acceptedQty <= 0) { - alert("來貨數量必須大於0!"); - return; - } - const doSubmit = () => { - setTimeout(async () => { - const currentDnNo = dnFormProps.watch("dnNo"); - const postData = { - dnNo: dnFormProps.watch("dnNo"), - receiptDate: outputDateStringToInputDateString(dnFormProps.watch("receiptDate")), - itemId: row.itemId, - itemNo: row.itemNo, - itemName: row.itemName, - purchaseOrderLineId: row.id, - acceptedQty: acceptedQty, - productLotNo: lotNoInput || "", - }; - const res = await createStockInLine(postData); - if (res) { - setLotNoInput(""); - setDnQtyInput(""); - setPolInputList((prev) => ({ - ...prev, - [row.id]: { lotNo: "", dnQty: "" }, - })); - selectedPolIdRef.current = row.id; - setSelectedRow(row); - patchPoEditQuery((params) => { - params.set("polId", String(row.id)); - params.delete("stockInLineId"); - }); - fetchPoDetail(selectedPoId.toString(), true, row.id); - } - console.log(res); - }, 200); - }; - - const sils = row.stockInLine ?? []; - const alreadyM18 = sils.reduce( - (acc, sil) => acc + Number(sil.purchaseAcceptedQty ?? 0), - 0, - ); - const alreadyStock = sils.reduce( - (acc, sil) => acc + Number(sil.acceptedQty ?? 0), - 0, - ); - const stockDemand = Number(row.stockUom?.stockQty ?? 0); - const thisBatchStock = - orderQty > 0 && stockDemand > 0 - ? acceptedQty * (stockDemand / orderQty) - : acceptedQty; - const exceedByOrderUnit = - orderQty > 0 && alreadyM18 + acceptedQty > orderQty * 1.1; - const exceedByStockUnit = - stockDemand > 0 && - alreadyStock + thisBatchStock > stockDemand * 1.1; - if (exceedByOrderUnit || exceedByStockUnit) { - submitDialogWithWarning(doSubmit, t, { - title: t("Confirm submit"), - html: t("qtyExceedsOrderConfirm"), - confirmButtonText: t("Submit"), - }); - } else { - doSubmit(); - } - }, - [dnQtyInput, row, dnFormProps, selectedPoId, fetchPoDetail, t, lotNoInput, patchPoEditQuery], - ); - - const syncRowInputToParent = useCallback((lotNo: string, dnQty: string) => { - setPolInputList((prev) => { - const current = prev[row.id] ?? { lotNo: "", dnQty: "" }; - if (current.lotNo === lotNo && current.dnQty === dnQty) return prev; - return { - ...prev, - [row.id]: { lotNo, dnQty }, - }; - }); - }, [row.id]); - - // const [focusField, setFocusField] = useState(); - - // 本批收貨數量(訂單單位): 使用者在該行輸入的 dnQty - const batchPurchaseQty = Number(dnQtyInput.trim()) || 0; - - // 已來貨總數(庫存單位): 同一 POL 底下所有 stock_in_line.acceptedQty 的合計 - const totalStockReceived = row.stockInLine - .filter((sil) => sil.purchaseOrderLineId === row.id) - .reduce((acc, cur) => acc + (cur.acceptedQty ?? 0), 0); - const receivedTotalText = decimalFormatter.format(totalStockReceived); - const highlightColor = - Number(receivedTotalText.replace(/,/g, "")) <= 0 ? "red" : "inherit"; - const needsStockInAttention = - canSeeStockInReminders && purchaseOrderLineHasIncompleteStockIn(row); - return ( - <> - - - *": { borderBottom: "unset" }, - color: "black", - ...(needsStockInAttention - ? (theme) => ({ - boxShadow: `inset 4px 0 0 ${theme.palette.error.main}`, - backgroundColor: alpha(theme.palette.error.main, 0.07), - }) - : {}), - }} - onClick={() => changeStockInLines(row.id)} - > - - {/* - setOpen(!open)} - > - {open ? : } - - */} - - {needsStockInAttention && ( - `0 0 0 1px ${alpha(theme.palette.error.main, 0.45)}`, - zIndex: 1, - }} - /> - )} - - - - {row.itemNo} - - - {row.itemName} - - {integerFormatter.format(row.qty)} - {integerFormatter.format(row.processed)} - {row.uom?.udfudesc} - {/* {decimalFormatter.format(row.stockUom.stockQty)} */} - {/* {receivedTotal} */} - - {decimalFormatter.format(totalStockReceived)} - - {row.stockUom.stockUomDesc} - {/* - {decimalFormatter.format(totalWeight)} {weightUnit} - */} - {/* {weightUnit} */} - {/* {decimalFormatter.format(row.price)} */} - {/* {row.expiryDate} */} - {t(`${row.status.toLowerCase()}`)} - {/* {t(`${currStatus.toLowerCase()}`)} */} - {/* {integerFormatter.format(row.receivedQty)} */} - - setLotNoInput(e.target.value)} - onBlur={() => syncRowInputToParent(lotNoInput, dnQtyInput)} - onClick={(e) => e.stopPropagation()} - // onFocus={(e) => {setFocusField(e.target as HTMLInputElement);}} - /> - - - setDnQtyInput(e.target.value)} - onBlur={() => syncRowInputToParent(lotNoInput, dnQtyInput)} - onClick={(e) => e.stopPropagation()} - InputProps={{ - inputProps: { - min: 0, // Optional: set a minimum value - step: "any", - inputMode: "decimal", - } - }} - /> - - - - - - {/* */} - {/* */} - {/* */} - {/* */} - {/* */} - {/* - - - - - - - - - -
*/} - {/*
*/} - {/*
*/} - {/*
*/} - - ); - } -// ROW END - const [tabIndex, setTabIndex] = useState(0); const handleTabChange = useCallback>( (_e, newValue) => { @@ -1019,7 +699,7 @@ const PoDetail: React.FC = ({ po, warehouse, printerCombo }) => { return ( <> - + {/* Area1: title */} @@ -1031,7 +711,7 @@ const PoDetail: React.FC = ({ po, warehouse, printerCombo }) => { {/* area2: dn info */} - + {/* left side select po */} @@ -1045,8 +725,8 @@ const PoDetail: React.FC = ({ po, warehouse, printerCombo }) => { {/* right side po info */} - - + + @@ -1176,10 +856,10 @@ const PoDetail: React.FC = ({ po, warehouse, printerCombo }) => { {/* Area4: Main Table */} - - - - + + + +
@@ -1201,7 +881,20 @@ const PoDetail: React.FC = ({ po, warehouse, printerCombo }) => { {rows.map((row) => ( - + ))}
@@ -1210,13 +903,13 @@ const PoDetail: React.FC = ({ po, warehouse, printerCombo }) => {
{/* area5: selected item info */} - + {selectedRow ? `已選擇貨品: ${selectedRow?.itemNo ? selectedRow.itemNo : 'N/A'} - ${selectedRow?.itemName ? selectedRow?.itemName : 'N/A'}` : "未選擇貨品"} - + {selectedRow && ( sil.purchaseOrderLineId === row.id) + .reduce((acc, sil) => { + const lineSum = + sil.putAwayLines?.reduce( + (s, p) => s + Number(p.stockQty ?? p.qty ?? 0), + 0, + ) ?? 0; + return acc + lineSum; + }, 0); +} + +function polOrderStockQty(row: PurchaseOrderLine): number { + return Number(row.stockUom?.stockQty ?? row.qty ?? 0); +} + +function purchaseOrderLineHasIncompleteStockIn(row: PurchaseOrderLine): boolean { + const orderStock = polOrderStockQty(row); + const putAway = totalPutAwayStockQtyForPol(row); + if (orderStock > 0 && putAway >= orderStock) { + return false; + } + return row.stockInLine + .filter((sil) => sil.purchaseOrderLineId === row.id) + .some((sil) => + PURCHASE_STOCK_IN_ALERT_STATUSES.has((sil.status ?? "").toLowerCase().trim()), + ); +} + +export type PoDetailRowDnValues = { + dnNo?: string; + receiptDate?: string; +}; + +type Props = { + row: PurchaseOrderLine; + selected: boolean; + canSeeStockInReminders: boolean; + showDnQty: boolean; + savedLotNo: string; + savedDnQty: string; + onSelect: (row: PurchaseOrderLine) => void; + onInputBlur: (rowId: number, lotNo: string, dnQty: string) => void; + getDnValues: () => PoDetailRowDnValues; + formatReceiptDate: (receiptDate?: string) => string | undefined; + onSubmitted: (row: PurchaseOrderLine) => void; +}; + +/** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.4 | 2026-09-10 */ +export const PoDetailRow = memo(function PoDetailRow({ + row, + selected, + canSeeStockInReminders, + showDnQty, + savedLotNo, + savedDnQty, + onSelect, + onInputBlur, + getDnValues, + formatReceiptDate, + onSubmitted, +}: Props) { + const { t } = useTranslation("purchaseOrder"); + const [lotNoInput, setLotNoInput] = useState(savedLotNo); + const [dnQtyInput, setDnQtyInput] = useState(savedDnQty); + const submitInFlightRef = useRef(false); + const [isStarting, setIsStarting] = useState(false); + + useEffect(() => { + setLotNoInput(savedLotNo); + setDnQtyInput(savedDnQty.replace(/[^\d]/g, "")); + }, [savedLotNo, savedDnQty]); + + const handleStart = useCallback( + () => { + if (submitInFlightRef.current || isStarting) return; + const orderQty = Number(row?.qty) ?? 0; + const acceptedQty = Number(dnQtyInput.trim()); + + if (!Number.isInteger(acceptedQty) || acceptedQty <= 0) { + alert("來貨數量必須為大於0的整數!"); + return; + } + + const previewStockQty = previewPoBatchStockQty( + orderQty, + Number(row.stockUom?.stockQty ?? 0), + acceptedQty, + ); + + const doSubmit = () => { + if (submitInFlightRef.current) return; + submitInFlightRef.current = true; + setIsStarting(true); + void (async () => { + try { + const dn = getDnValues(); + const postData = { + dnNo: dn.dnNo, + receiptDate: formatReceiptDate(dn.receiptDate), + itemId: row.itemId, + itemNo: row.itemNo, + itemName: row.itemName, + purchaseOrderLineId: row.id, + acceptedQty: acceptedQty, + productLotNo: lotNoInput || "", + }; + const res = await createStockInLine(postData); + if (res) { + setLotNoInput(""); + setDnQtyInput(""); + onSubmitted(row); + } + console.log(res); + } finally { + setIsStarting(false); + submitInFlightRef.current = false; + } + })(); + }; + + const sils = row.stockInLine ?? []; + const alreadyM18 = sils.reduce( + (acc, sil) => acc + Number(sil.purchaseAcceptedQty ?? 0), + 0, + ); + const alreadyStock = sils.reduce( + (acc, sil) => acc + Number(sil.acceptedQty ?? 0), + 0, + ); + const stockDemand = Number(row.stockUom?.stockQty ?? 0); + const thisBatchStock = previewStockQty; + const exceedByOrderUnit = + orderQty > 0 && alreadyM18 + acceptedQty > orderQty * 1.1; + const exceedByStockUnit = + stockDemand > 0 && alreadyStock + thisBatchStock > stockDemand * 1.1; + if (exceedByOrderUnit || exceedByStockUnit) { + submitDialogWithWarning(doSubmit, t, { + title: t("Confirm submit"), + html: t("qtyExceedsOrderConfirm"), + confirmButtonText: t("Submit"), + }); + } else { + doSubmit(); + } + }, + [ + dnQtyInput, + formatReceiptDate, + getDnValues, + isStarting, + lotNoInput, + onSubmitted, + row, + t, + ], + ); + + const totalStockReceived = row.stockInLine + .filter((sil) => sil.purchaseOrderLineId === row.id) + .reduce((acc, cur) => acc + (cur.acceptedQty ?? 0), 0); + const receivedTotalText = decimalFormatter.format(totalStockReceived); + const highlightColor = + Number(receivedTotalText.replace(/,/g, "")) <= 0 ? "red" : "inherit"; + const needsStockInAttention = + canSeeStockInReminders && purchaseOrderLineHasIncompleteStockIn(row); + + return ( + *": { borderBottom: "unset" }, + color: "black", + ...(needsStockInAttention + ? (theme) => ({ + boxShadow: `inset 4px 0 0 ${theme.palette.error.main}`, + backgroundColor: alpha(theme.palette.error.main, 0.07), + }) + : {}), + }} + onClick={() => onSelect(row)} + > + + {needsStockInAttention && ( + `0 0 0 1px ${alpha(theme.palette.error.main, 0.45)}`, + zIndex: 1, + }} + /> + )} + + + + {row.itemNo} + + + {row.itemName} + + {integerFormatter.format(row.qty)} + {integerFormatter.format(row.processed)} + {row.uom?.udfudesc} + + {decimalFormatter.format(totalStockReceived)} + + + {row.stockUom.stockUomDesc} + + + {t(`${row.status.toLowerCase()}`)} + + + setLotNoInput(e.target.value)} + onBlur={() => onInputBlur(row.id, lotNoInput, dnQtyInput)} + onClick={(e) => e.stopPropagation()} + /> + + {showDnQty ? ( + + setDnQtyInput(e.target.value.replace(/[^\d]/g, ""))} + onBlur={() => onInputBlur(row.id, lotNoInput, dnQtyInput)} + onClick={(e) => e.stopPropagation()} + InputProps={{ + inputProps: { + min: 1, + step: 1, + inputMode: "numeric", + pattern: "[0-9]*", + }, + }} + /> + + ) : null} + + + + + ); +}); diff --git a/src/components/PoDetail/PoInputGrid.tsx b/src/components/PoDetail/PoInputGrid.tsx index 37becb17..b4572b58 100644 --- a/src/components/PoDetail/PoInputGrid.tsx +++ b/src/components/PoDetail/PoInputGrid.tsx @@ -35,7 +35,7 @@ import ShoppingCartIcon from "@mui/icons-material/ShoppingCart"; import PlayArrowIcon from "@mui/icons-material/PlayArrow"; import { PurchaseOrderLine } from "@/app/api/po"; import { StockInLine } from "@/app/api/stockIn"; -import { createStockInLine, deleteStockInLine, QcResult } from "@/app/api/stockIn/actions"; +import { createStockInLine, deleteStockInLine, updateStockInLine, QcResult } from "@/app/api/stockIn/actions"; import { usePathname, useSearchParams } from "next/navigation"; import { returnWeightUnit, @@ -69,39 +69,23 @@ import { SessionWithTokens } from "@/config/authConfig"; import { EscalationCombo } from "@/app/api/user"; import { deleteDialog } from "../Swal/CustomAlerts"; import StockInLineRowActions from "./StockInLineRowActions"; +import { StockQtyRoundMode, needsPoQcStockQtyRound } from "./stockQtyRound"; -/** Sum of fixed column widths (desktop) so the grid can scroll horizontally without squeezing cells. */ -const STOCK_IN_GRID_MIN_WIDTH_DESKTOP = 1062; +// 3 buttons after QC (view + print QR + delete): 176*3 + gap + cell padding +const ACTIONS_COLUMN_WIDTH = 580; +const PURCHASE_QTY_COLUMN_WIDTH = 72; +const UOM_COLUMN_WIDTH = 124; +const STOCK_QTY_COLUMN_WIDTH = 110; +const STOCK_IN_ROW_HEIGHT = 58; -const ACTIONS_COLUMN_WIDTH = 168; - -const ACTION_BUTTON_HEIGHT = 38; -const ACTION_BUTTON_GAP = 6; -/** Extra space for cell padding + outlined button borders */ -const ACTION_ROW_EXTRA_PADDING = 36; - -function getActionRowHeight(buttonCount: number): number { - return ( - buttonCount * ACTION_BUTTON_HEIGHT + - Math.max(0, buttonCount - 1) * ACTION_BUTTON_GAP + - ACTION_ROW_EXTRA_PADDING - ); -} - -function countActionButtonsForRow(row: StockInLineRow): number { - let count = 1; - const status = (row.status ?? "").toLowerCase(); - if (status === "rejected" || status === "partially_completed") { - count += 1; - } - if (status === "received") { - count += 1; - } - if (canDeleteStockInLine(row)) { - count += 1; - } - return count; -} +/** Extra table width is shared by text columns; qty / status / actions stay tight. */ +const COLUMN_GROW: Record = { + dnNo: 1, + productLotNo: 1, + uom: 1.5, + stockQty: 1, + stockUom: 1.5, +}; /** Tighter horizontal padding for narrow data columns (headers unchanged). */ const COMPACT_STOCK_IN_CELL_FIELDS = [ @@ -170,7 +154,7 @@ class ProcessRowUpdateError extends Error { } } -/** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.2 | 2026-09-07 */ +/** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.4 | 2026-09-10 */ function PoInputGrid({ // qc, setRows, @@ -188,8 +172,6 @@ function PoInputGrid({ const theme = useTheme(); /** Narrow phones: hide low-priority columns. */ const isCompact = useMediaQuery(theme.breakpoints.down("md"), { noSsr: true }); - /** Tablet / sub-desktop (< xl): flex columns to fill available width. Desktop (≥ xl) keeps fixed widths. */ - const isTablet = useMediaQuery(theme.breakpoints.down("xl"), { noSsr: true }); const apiRef = useGridApiRef(); const [rowModesModel, setRowModesModel] = useState({}); const getRowId = useCallback>( @@ -215,6 +197,8 @@ function PoInputGrid({ const [btnIsLoading, setBtnIsLoading] = useState(false); const [isDeleting, setIsDeleting] = useState(false); const deleteInFlightRef = useRef(false); + const roundInFlightRef = useRef(false); + const [roundingSilId, setRoundingSilId] = useState(null); const [currQty, setCurrQty] = useState(() => { const total = entries.reduce( // remaining qty (M18 unit) @@ -248,6 +232,16 @@ function PoInputGrid({ const handleSoftDelete = useCallback( (row: StockInLineRow) => { if (deleteInFlightRef.current || isDeleting) return; + if ( + needsPoQcStockQtyRound( + row.purchaseOrderLineId, + row.status, + row.acceptedQty, + ) + ) { + alert("請先在換算庫存數量選擇向上或向下取整"); + return; + } const rowId = row.id as number; const isDraft = row._isNew || row.status === "draft"; @@ -280,6 +274,50 @@ function PoInputGrid({ }, [fetchPoDetail, handleDelete, isDeleting, itemDetail.id, itemDetail.purchaseOrderId, t], ); + + const handleRoundStockQty = useCallback( + (row: StockInLineRow, mode: StockQtyRoundMode) => { + if (roundInFlightRef.current) return; + const silId = row.id; + const itemId = row.itemId; + const purchaseQty = Number(row.purchaseAcceptedQty ?? 0); + if (!silId || !itemId || purchaseQty <= 0) return; + + const doRound = async () => { + if (roundInFlightRef.current) return; + roundInFlightRef.current = true; + setRoundingSilId(silId); + try { + const res = await updateStockInLine({ + id: silId, + itemId, + purchaseOrderLineId: row.purchaseOrderLineId, + acceptedQty: purchaseQty, + dnNo: row.dnNo, + productLotNo: row.productLotNo, + stockQtyRoundMode: mode, + stockQtyRoundSource: "CREATE", + }); + if (res) { + await fetchPoDetail( + String(itemDetail.purchaseOrderId), + true, + itemDetail.id, + ); + } + } catch (error) { + console.error("Failed to round stock qty:", error); + alert(t("Please choose a rounding method")); + } finally { + setRoundingSilId(null); + roundInFlightRef.current = false; + } + }; + + void doRound(); + }, + [fetchPoDetail, itemDetail.id, itemDetail.purchaseOrderId, t], + ); const closeQcModal = useCallback(() => { setQcOpen(false); @@ -434,6 +472,16 @@ function PoInputGrid({ const handleNewQC = useCallback( (id: GridRowId, params: any) => async() => { if (!params?.row) return; + if ( + needsPoQcStockQtyRound( + params.row.purchaseOrderLineId, + params.row.status, + params.row.acceptedQty, + ) + ) { + alert("請先在換算庫存數量選擇向上或向下取整"); + return; + } setRowModesModel((prev) => ({ ...prev, [id]: { mode: GridRowModes.View }, @@ -645,7 +693,8 @@ function PoInputGrid({ { field: "dnNo", headerName: t("dnNo"), - width: 92, + width: 100, + minWidth: 100, }, { field: "receiptDate", @@ -656,12 +705,15 @@ function PoInputGrid({ { field: "productLotNo", headerName: t("productLotNo"), - width: 100, + width: 110, + minWidth: 110, }, { field: "purchaseAcceptedQty", headerName: t("acceptedQty"), - width: 84, + width: PURCHASE_QTY_COLUMN_WIDTH, + minWidth: PURCHASE_QTY_COLUMN_WIDTH, + flex: 0, align: "right", headerAlign: "right", type: "number", @@ -673,7 +725,9 @@ function PoInputGrid({ { field: "uom", headerName: t("uom"), - width: 156, + width: UOM_COLUMN_WIDTH, + minWidth: UOM_COLUMN_WIDTH, + flex: 0, renderCell: () => { const text = itemDetail.uom?.udfudesc ?? "-"; return ( @@ -694,17 +748,23 @@ function PoInputGrid({ { field: "stockQty", headerName: t("Stock In Qty"), - width: 125, + width: STOCK_QTY_COLUMN_WIDTH, + minWidth: STOCK_QTY_COLUMN_WIDTH, + flex: 0, type: "number", + align: "left", + headerAlign: "left", renderCell: (params) => { - const stockQty = params.row.acceptedQty ?? 0; + const stockQty = Number(params.row.acceptedQty ?? 0); return decimalFormatter.format(stockQty); }, }, { field: "stockUom", headerName: t("Stock UoM"), - width: 124, + width: UOM_COLUMN_WIDTH, + minWidth: UOM_COLUMN_WIDTH, + flex: 0, renderCell: () => { const text = itemDetail.stockUom.stockUomDesc ?? "-"; return ( @@ -748,6 +808,8 @@ function PoInputGrid({ field: "actions", headerName: "操作", width: ACTIONS_COLUMN_WIDTH, + minWidth: ACTIONS_COLUMN_WIDTH, + flex: 0, sortable: false, filterable: false, disableColumnMenu: true, @@ -760,6 +822,11 @@ function PoInputGrid({ status === "rejected" || status === "partially_completed"; const canPrint = status === "received"; const canDelete = canDeleteStockInLine(data); + const needsStockQtyRound = needsPoQcStockQtyRound( + data.purchaseOrderLineId, + data.status, + data.acceptedQty, + ); return ( handleSoftDelete(data)} btnIsLoading={btnIsLoading} isDeleting={isDeleting} + needsStockQtyRound={needsStockQtyRound} + stockQty={Number(data.acceptedQty ?? 0)} + isRounding={roundingSilId === data.id} + onRound={(mode) => handleRoundStockQty(data, mode)} /> ); }, }, ]; - if (!isTablet) { - return baseColumns; - } - return baseColumns.map((col) => { - if (col.field === "actions") { - return { ...col, flex: 0, width: ACTIONS_COLUMN_WIDTH }; + const grow = COLUMN_GROW[col.field] ?? 0; + if (grow > 0) { + return { + ...col, + flex: grow, + minWidth: col.minWidth ?? col.width, + width: undefined, + }; } - const minWidth = col.width ?? 80; - return { ...col, flex: 1, minWidth, width: undefined }; + return { + ...col, + flex: 0, + width: col.width, + minWidth: col.minWidth ?? col.width, + }; }); }, [ t, - isTablet, itemDetail, handleNewQC, handleMailTemplateForStockInLine, printQrcode, handleSoftDelete, + handleRoundStockQty, + roundingSilId, btnIsLoading, isDeleting, sessionToken?.id, @@ -917,13 +995,7 @@ function PoInputGrid({ ); - const getRowHeight = useCallback( - (params: { model: StockInLineRow }) => { - const count = countActionButtonsForRow(params.model); - return getActionRowHeight(count); - }, - [], - ); + const getRowHeight = useCallback(() => STOCK_IN_ROW_HEIGHT, []); return ( <> @@ -945,7 +1017,7 @@ function PoInputGrid({ columnVisibilityModel={columnVisibilityModel} sx={{ width: "100%", - minWidth: isTablet ? undefined : STOCK_IN_GRID_MIN_WIDTH_DESKTOP, + minWidth: 0, "--DataGrid-overlayHeight": "100px", ".MuiDataGrid-row .MuiDataGrid-cell.hasError": { border: "1px solid", @@ -957,14 +1029,39 @@ function PoInputGrid({ }, "& .MuiDataGrid-cell.actions": { overflow: "visible", - alignItems: "flex-start", - py: 0.75, + alignItems: "center", + py: 0.5, lineHeight: "normal", }, + "& .MuiDataGrid-cell[data-field='stockQty']": { + overflow: "visible", + alignItems: "center", + justifyContent: "flex-start", + py: 0.5, + px: 0.75, + }, "& .MuiDataGrid-cell[data-field='actions']": { - py: 0.75, + py: 0.5, px: 1, }, + "& .MuiDataGrid-columnHeader[data-field='purchaseAcceptedQty']": { + whiteSpace: "normal", + lineHeight: 1.2, + px: 0.5, + }, + "& .MuiDataGrid-cell[data-field='purchaseAcceptedQty']": { + px: 0.5, + }, + "& .MuiDataGrid-columnHeader[data-field='uom']": { + whiteSpace: "nowrap", + px: 0.75, + }, + "& .MuiDataGrid-cell[data-field='uom']": { + px: 0.75, + }, + "& .MuiDataGrid-columnHeader[data-field='stockQty']": { + px: 0.75, + }, ...Object.fromEntries( COMPACT_STOCK_IN_CELL_FIELDS.flatMap((field) => [ [ diff --git a/src/components/PoDetail/QcStockInModal.tsx b/src/components/PoDetail/QcStockInModal.tsx index 3cda4ba7..56d5a87a 100644 --- a/src/components/PoDetail/QcStockInModal.tsx +++ b/src/components/PoDetail/QcStockInModal.tsx @@ -71,7 +71,7 @@ interface CommonProps extends Omit { interface Props extends CommonProps { // itemDetail: StockInLine & { qcResult?: PurchaseQcResult[] } & { escResult?: EscalationResult[] }; } -/** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.2 | 2026-09-07 */ +/** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.4 | 2026-09-10 */ const PoQcStockInModalVer2: React.FC = ({ open, onClose, diff --git a/src/components/PoDetail/StockInLineRowActions.tsx b/src/components/PoDetail/StockInLineRowActions.tsx index f422cba4..c1431741 100644 --- a/src/components/PoDetail/StockInLineRowActions.tsx +++ b/src/components/PoDetail/StockInLineRowActions.tsx @@ -2,6 +2,7 @@ import { Box, Button } from "@mui/material"; import { useTranslation } from "react-i18next"; +import { roundStockQty, StockQtyRoundMode } from "./stockQtyRound"; export type StockInLineActionStyle = { label: string; @@ -19,8 +20,13 @@ type Props = { onDelete: () => void; btnIsLoading: boolean; isDeleting: boolean; + needsStockQtyRound?: boolean; + stockQty?: number; + isRounding?: boolean; + onRound?: (mode: StockQtyRoundMode) => void; }; +/** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.4 | 2026-09-10 */ export default function StockInLineRowActions({ btnSx, onPrimaryClick, @@ -32,83 +38,119 @@ export default function StockInLineRowActions({ onDelete, btnIsLoading, isDeleting, + needsStockQtyRound = false, + stockQty = 0, + isRounding = false, + onRound, }: Props) { const { t } = useTranslation("purchaseOrder"); const buttonSx = { whiteSpace: "nowrap" as const, - fontSize: 14, + flexShrink: 0, + width: 176, + minWidth: 176, + maxWidth: 176, px: 1.5, - py: 0.75, - minHeight: 34, - width: "100%", + py: 1, + minHeight: 44, + height: 44, + fontSize: 16, + fontWeight: 700, justifyContent: "center", + boxSizing: "border-box" as const, }; return ( e.stopPropagation()} > - - {canEmail && ( - - )} - {canPrint && ( - - )} - {canDelete && ( - + {needsStockQtyRound ? ( + <> + + + + ) : ( + <> + + {canEmail && ( + + )} + {canPrint && ( + + )} + {canDelete && ( + + )} + )} ); diff --git a/src/components/PoDetail/askStockQtyRoundDialog.ts b/src/components/PoDetail/askStockQtyRoundDialog.ts new file mode 100644 index 00000000..cdcef384 --- /dev/null +++ b/src/components/PoDetail/askStockQtyRoundDialog.ts @@ -0,0 +1,64 @@ +import Swal from "sweetalert2"; +import { TFunction } from "i18next"; +import { + isNotIntegerQty, + roundStockQty, + StockQtyRoundChoice, + StockQtyRoundMode, +} from "./stockQtyRound"; + +type Translate = TFunction<["translation", ...string[]], undefined>; + +export async function askStockQtyRoundDialog( + before: number, + t: Translate, + stockUomDesc?: string, +): Promise { + if (!isNotIntegerQty(before)) return null; + + const ceiling = roundStockQty(before, "CEILING"); + const floor = roundStockQty(before, "FLOOR"); + const uom = stockUomDesc?.trim() ? ` (${stockUomDesc.trim()})` : ""; + const beforeText = before.toFixed(2); + + const result = await Swal.fire({ + icon: "warning", + title: t("Stock qty is not an integer"), + html: ` +
+

${t("Converted stock qty is")} ${beforeText}${uom}${t("Choose rounding method")}

+ + +
+ `, + showCancelButton: true, + confirmButtonText: t("Confirm"), + cancelButtonText: t("Cancel"), + customClass: { + container: "swal-container-class", + popup: "swal-popup-class", + }, + preConfirm: () => { + const popup = Swal.getPopup(); + const selected = popup?.querySelector('input[name="stockQtyRoundMode"]:checked')?.value as StockQtyRoundMode | undefined; + if (selected !== "CEILING" && selected !== "FLOOR") { + Swal.showValidationMessage(t("Please choose a rounding method")); + return false; + } + return { + mode: selected, + before, + after: roundStockQty(before, selected), + } satisfies StockQtyRoundChoice; + }, + }); + + if (!result.isConfirmed) return null; + return (result.value as StockQtyRoundChoice) ?? null; +} diff --git a/src/components/PoDetail/stockQtyRound.ts b/src/components/PoDetail/stockQtyRound.ts new file mode 100644 index 00000000..f3dcab3a --- /dev/null +++ b/src/components/PoDetail/stockQtyRound.ts @@ -0,0 +1,46 @@ +export type StockQtyRoundMode = "CEILING" | "FLOOR"; +export type StockQtyRoundSource = "CREATE" | "QC"; + +export type StockQtyRoundChoice = { + mode: StockQtyRoundMode; + before: number; + after: number; +}; + +/** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.4 | 2026-09-10 */ +export function previewPoBatchStockQty( + orderM18Qty: number, + orderStockQty: number, + batchM18Qty: number, +): number { + if (!Number.isFinite(orderM18Qty) || orderM18Qty === 0) { + return Number(batchM18Qty.toFixed(2)); + } + return Number(((batchM18Qty * orderStockQty) / orderM18Qty).toFixed(2)); +} + +export function isNotIntegerQty(qty: number): boolean { + if (!Number.isFinite(qty)) return false; + return Math.abs(qty - Math.round(qty)) > 1e-9; +} + +/** + * FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.4 | 2026-09-10 + * PO 收貨/品檢:pending 或 escalated 且庫存數量非整數時需選擇 CEILING / FLOOR + */ +export function needsPoQcStockQtyRound( + purchaseOrderLineId: number | null | undefined, + status: string | null | undefined, + acceptedQty: number | null | undefined, +): boolean { + if (!purchaseOrderLineId) return false; + const silStatus = (status ?? "").toLowerCase().trim(); + if (silStatus !== "pending" && silStatus !== "escalated") return false; + return isNotIntegerQty(Number(acceptedQty ?? 0)); +} + +/** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.4 | 2026-09-10 */ +export function roundStockQty(before: number, mode: StockQtyRoundMode): number { + if (mode === "CEILING") return Math.ceil(before); + return Math.floor(before); +} diff --git a/src/components/PoSearch/PoSearch.tsx b/src/components/PoSearch/PoSearch.tsx index 3502c5e9..45294d56 100644 --- a/src/components/PoSearch/PoSearch.tsx +++ b/src/components/PoSearch/PoSearch.tsx @@ -261,8 +261,15 @@ const PoSearch: React.FC = ({ ); const onReset = useCallback(() => { - setFilteredPo(po); - }, [po]); + const today = dayjsToDateString(dayjs(), "input"); + setSelectedPoIds([]); + setSelectAll(false); + setPagingController(defaultPagingController); + setFilterArgs({ + estimatedArrivalDate: today, + estimatedArrivalDateTo: today, + }); + }, []); const [autoSyncStatus, setAutoSyncStatus] = useState(null); const [isM18LookupLoading, setIsM18LookupLoading] = useState(false); @@ -287,93 +294,97 @@ const PoSearch: React.FC = ({ if (typeof v === "string" && (v as string).trim() === "") return; cleanedQuery[k] = String(v); }); - const baseListResp = await clientAuthFetch( - `${NEXT_PUBLIC_API_URL}/po/list?${new URLSearchParams(cleanedQuery).toString()}`, - { method: "GET" }, - ); - if (!baseListResp.ok) { - throw new Error(`PO list fetch failed: ${baseListResp.status}`); - } - const res = await baseListResp.json(); - if (!res) return; - - if (res.records && res.records.length > 0) { - setFilteredPo(res.records); - setTotalCount(res.total); - return; - } - - const searchedCodeRaw = (filterArgs as any)?.code; - const searchedCode = - typeof searchedCodeRaw === "string" ? searchedCodeRaw.trim() : ""; - - const shouldAutoSyncFromM18 = - searchedCode.length > 14 && - (searchedCode.startsWith("PP") || searchedCode.startsWith("PF")); - - if (!shouldAutoSyncFromM18 || autoSyncInProgressRef.current) { - setFilteredPo(res.records); - setTotalCount(res.total); - return; - } - try { - autoSyncInProgressRef.current = true; - setIsM18LookupLoading(true); - setAutoSyncStatus("正在從M18找尋PO..."); - const syncResp = await clientAuthFetch( - `${NEXT_PUBLIC_API_URL}/m18/test/po-by-code?code=${encodeURIComponent( - searchedCode, - )}`, + const baseListResp = await clientAuthFetch( + `${NEXT_PUBLIC_API_URL}/po/list?${new URLSearchParams(cleanedQuery).toString()}`, { method: "GET" }, ); - - if (!syncResp.ok) { - throw new Error(`M18 sync failed: ${syncResp.status}`); + if (!baseListResp.ok) { + throw new Error(`PO list fetch failed: ${baseListResp.status}`); } + const res = await baseListResp.json(); + if (!res) return; - let syncJson: any = null; - try { - syncJson = await syncResp.json(); - } catch { - // Some endpoints may respond with plain text - const txt = await syncResp.text(); - syncJson = { raw: txt }; + if (res.records && res.records.length > 0) { + setFilteredPo(res.records); + setTotalCount(res.total); + return; } - const syncOk = Boolean(syncJson?.totalSuccess && syncJson.totalSuccess > 0); - if (syncOk) { - setAutoSyncStatus("成功找到PO"); + const searchedCodeRaw = (filterArgs as any)?.code; + const searchedCode = + typeof searchedCodeRaw === "string" ? searchedCodeRaw.trim() : ""; + + const shouldAutoSyncFromM18 = + searchedCode.length > 14 && + (searchedCode.startsWith("PP") || searchedCode.startsWith("PF")); - const listResp = await clientAuthFetch( - `${NEXT_PUBLIC_API_URL}/po/list?${new URLSearchParams( - cleanedQuery, - ).toString()}`, + if (!shouldAutoSyncFromM18 || autoSyncInProgressRef.current) { + setFilteredPo(res.records); + setTotalCount(res.total); + return; + } + + try { + autoSyncInProgressRef.current = true; + setIsM18LookupLoading(true); + setAutoSyncStatus("正在從M18找尋PO..."); + const syncResp = await clientAuthFetch( + `${NEXT_PUBLIC_API_URL}/m18/test/po-by-code?code=${encodeURIComponent( + searchedCode, + )}`, { method: "GET" }, ); - if (listResp.ok) { - const listJson = await listResp.json(); - setFilteredPo(listJson.records ?? []); - setTotalCount(listJson.total ?? 0); + + if (!syncResp.ok) { + throw new Error(`M18 sync failed: ${syncResp.status}`); + } + + let syncJson: any = null; + try { + syncJson = await syncResp.json(); + } catch { + // Some endpoints may respond with plain text + const txt = await syncResp.text(); + syncJson = { raw: txt }; + } + + const syncOk = Boolean(syncJson?.totalSuccess && syncJson.totalSuccess > 0); + if (syncOk) { setAutoSyncStatus("成功找到PO"); - return; + + const listResp = await clientAuthFetch( + `${NEXT_PUBLIC_API_URL}/po/list?${new URLSearchParams( + cleanedQuery, + ).toString()}`, + { method: "GET" }, + ); + if (listResp.ok) { + const listJson = await listResp.json(); + setFilteredPo(listJson.records ?? []); + setTotalCount(listJson.total ?? 0); + setAutoSyncStatus("成功找到PO"); + return; + } + setAutoSyncStatus("找不到PO"); + } else { + setAutoSyncStatus("找不到PO"); } + + // Ensure UI updates even if sync didn't change results + setFilteredPo(res.records); + setTotalCount(res.total ?? 0); + } catch (e) { + console.error("Auto sync error:", e); setAutoSyncStatus("找不到PO"); - } else { - setAutoSyncStatus("找不到PO"); + setFilteredPo(res.records); + setTotalCount(res.total ?? 0); + } finally { + setIsM18LookupLoading(false); + autoSyncInProgressRef.current = false; } - - // Ensure UI updates even if sync didn't change results - setFilteredPo(res.records); - setTotalCount(res.total ?? 0); } catch (e) { - console.error("Auto sync error:", e); - setAutoSyncStatus("找不到PO"); - setFilteredPo(res.records); - setTotalCount(res.total ?? 0); - } finally { - setIsM18LookupLoading(false); - autoSyncInProgressRef.current = false; + console.error("PO list fetch error:", e); } }, [], diff --git a/src/components/PoSearch/PoSearchWrapper.tsx b/src/components/PoSearch/PoSearchWrapper.tsx index de505552..06f238ea 100644 --- a/src/components/PoSearch/PoSearchWrapper.tsx +++ b/src/components/PoSearch/PoSearchWrapper.tsx @@ -1,45 +1,12 @@ -import { fetchAllItems } from "@/app/api/settings/item"; -// import ItemsSearch from "./ItemsSearch"; -// import ItemsSearchLoading from "./ItemsSearchLoading"; -import { SearchParams } from "@/app/utils/fetchUtil"; -import { TypeEnum } from "@/app/utils/typeEnum"; -import { notFound } from "next/navigation"; import PoSearchLoading from "./PoSearchLoading"; import PoSearch from "./PoSearch"; -import { fetchPoList, PoResult } from "@/app/api/po"; -import dayjs from "dayjs"; -import arraySupport from "dayjs/plugin/arraySupport"; -import { OUTPUT_DATE_FORMAT } from "@/app/utils/formatUtil"; -import { defaultPagingController } from "../SearchResults/SearchResults"; -dayjs.extend(arraySupport); interface SubComponents { Loading: typeof PoSearchLoading; } -type Props = { - // type: TypeEnum; -}; - -const PoSearchWrapper: React.FC & SubComponents = async ( - { - // type, - }, -) => { - // console.log(defaultPagingController) - const po = await fetchPoList({ - pageNum: 1, - pageSize: 10, - }); - const fixPoDate = po.records.map((p) => { - return { - ...p, - orderDate: dayjs(p.orderDate).add(-1, "month").format(OUTPUT_DATE_FORMAT), - }; - }); - return ( - - ); +const PoSearchWrapper: React.FC & SubComponents = () => { + return ; }; PoSearchWrapper.Loading = PoSearchLoading; diff --git a/src/components/Qc/QcComponent.tsx b/src/components/Qc/QcComponent.tsx index 3c48e137..3c111f7d 100644 --- a/src/components/Qc/QcComponent.tsx +++ b/src/components/Qc/QcComponent.tsx @@ -150,9 +150,9 @@ const QcComponent: React.FC = ({ itemDetail, disabled = false, compactLay if (isNaN(accQty) || accQty === undefined || accQty === null || typeof(accQty) != "number") { setError("acceptQty", { message: t("value must be a number") }); } else - if (!isJobOrder && accQty > itemDetail.acceptedQty) { + if (!isJobOrder && accQty > Math.ceil(itemDetail.acceptedQty)) { setError("acceptQty", { message: `${t("acceptQty must not greater than")} ${ - itemDetail.acceptedQty}` }); + Math.ceil(itemDetail.acceptedQty)}` }); } else if (accQty <= 0) { setError("acceptQty", { message: t("minimal value is 1") }); @@ -163,8 +163,8 @@ const QcComponent: React.FC = ({ itemDetail, disabled = false, compactLay },[setError, qcDecision, accQty, itemDetail, isJobOrder]) useEffect(() => { // W I P // ----- if (qcDecision == 1) { - if (!isJobOrder && validateFieldFail("acceptQty", accQty > itemDetail.acceptedQty, `${t("acceptQty must not greater than")} ${ - itemDetail.acceptedQty}`)) return; + if (!isJobOrder && validateFieldFail("acceptQty", accQty > Math.ceil(itemDetail.acceptedQty), `${t("acceptQty must not greater than")} ${ + Math.ceil(itemDetail.acceptedQty)}`)) return; if (validateFieldFail("acceptQty", accQty <= 0, t("minimal value is 1"))) return; if (validateFieldFail("acceptQty", isNaN(accQty), t("value must be a number"))) return; @@ -616,7 +616,7 @@ useEffect(() => { } e.target.value = r; }} - inputProps={isJobOrder ? { min: 0.01, step: 0.01 } : { min: 0.01, max: itemDetail.acceptedQty, step: 0.01 }} + inputProps={isJobOrder ? { min: 0.01, step: 0.01 } : { min: 0.01, max: Math.ceil(itemDetail.acceptedQty), step: 0.01 }} // onChange={(e) => { // const inputValue = e.target.value; // if (inputValue === '' || /^[0-9]*$/.test(inputValue)) { diff --git a/src/components/Qc/QcStockInModal.tsx b/src/components/Qc/QcStockInModal.tsx index 29f8dd65..c6b2b53d 100644 --- a/src/components/Qc/QcStockInModal.tsx +++ b/src/components/Qc/QcStockInModal.tsx @@ -14,7 +14,7 @@ import { TextField, Typography, } from "@mui/material"; -import { Dispatch, SetStateAction, useCallback, useEffect, useMemo, useState } from "react"; +import { Dispatch, SetStateAction, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { FormProvider, SubmitErrorHandler, SubmitHandler, useForm } from "react-hook-form"; import { StockInLineRow } from "../PoDetail/PoInputGrid"; import { useTranslation } from "react-i18next"; @@ -23,6 +23,7 @@ import QcComponent from "./QcComponent"; import PutAwayForm from "../PoDetail/PutAwayForm"; import { GridRowModes, GridRowSelectionModel, useGridApiRef } from "@mui/x-data-grid"; import {msg, submitDialogWithWarning} from "../Swal/CustomAlerts"; +import { needsPoQcStockQtyRound } from "../PoDetail/stockQtyRound"; import { INPUT_DATE_FORMAT, arrayToDateString, dayjsToDateTimeString } from "@/app/utils/formatUtil"; import dayjs from "dayjs"; import { fetchPoQrcode } from "@/app/api/pdf/actions"; @@ -72,7 +73,7 @@ interface Props extends CommonProps { // itemDetail: StockInLine & { qcResult?: PurchaseQcResult[] } & { escResult?: EscalationResult[] }; } -/** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.2 | 2026-09-07 */ +/** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.4 | 2026-09-10 */ const QcStockInModal: React.FC = ({ open, onClose, @@ -99,6 +100,7 @@ const QcStockInModal: React.FC = ({ const [stockInLineInfo, setStockInLineInfo] = useState(); const [isLoading, setIsLoading] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false); + const qcSubmitInFlightRef = useRef(false); // const [skipQc, setSkipQc] = useState(false); // const [viewOnly, setViewOnly] = useState(false); @@ -241,6 +243,22 @@ const QcStockInModal: React.FC = ({ ...defaultNewValue, }, }); + const qcDecision = formProps.watch("qcDecision"); + const roundChoiceRequired = useMemo(() => { + const needsRound = needsPoQcStockQtyRound( + stockInLineInfo?.purchaseOrderLineId, + stockInLineInfo?.status, + stockInLineInfo?.acceptedQty, + ); + const willAcceptStock = Boolean(skipQc) || qcDecision == 1; + return needsRound && willAcceptStock; + }, [ + stockInLineInfo?.purchaseOrderLineId, + stockInLineInfo?.status, + stockInLineInfo?.acceptedQty, + skipQc, + qcDecision, + ]); const closeWithResult = useCallback( (updatedStockInLine?: StockInLine) => { @@ -410,6 +428,20 @@ const QcStockInModal: React.FC = ({ return; } + if (qcSubmitInFlightRef.current || isSubmitting) return; + + const storedStockQty = Number(stockInLineInfo?.acceptedQty ?? 0); + const needsStockQtyRound = needsPoQcStockQtyRound( + stockInLineInfo?.purchaseOrderLineId, + stockInLineInfo?.status, + storedStockQty, + ); + const willAcceptStock = Boolean(skipQc) || qcAcceptLocal; + if (willAcceptStock && needsStockQtyRound) { + alert("請先在換算庫存數量選擇向上或向下取整"); + return; + } + const isJobOrderSource = Boolean(stockInLineInfo?.jobOrderId) || printSource === "productionProcess"; const qcData = { dnNo : data.dnNo? data.dnNo : "DN00000", @@ -431,7 +463,7 @@ const QcStockInModal: React.FC = ({ // qcDescription: item.qcDescription, qcPassed: item.qcPassed? item.qcPassed : false, failQty: (item.failQty && !item.qcPassed) ? item.failQty : 0, - // failedQty: (typeof item.failedQty === "number" && !item.isPassed) ? item.failedQty : 0, + // failedQty: (typeof item.failedQty === "number" && !item.isPassed) ? item.failQty : 0, remarks: item.remarks || '', ...(QC_MEASUREMENT_ENABLED && isMeasurableQcItem(item) ? { measurement: buildQcMeasurementPayload(item) } @@ -457,11 +489,13 @@ const QcStockInModal: React.FC = ({ } console.log("Escalation Data for submission", escalationLog); + qcSubmitInFlightRef.current = true; setIsSubmitting(true); const resEscalate = await postStockInLine({...qcData, escalationLog}); qcRes = Array.isArray(resEscalate.entity) ? resEscalate.entity[0] : (resEscalate.entity as StockInLine); } else { + qcSubmitInFlightRef.current = true; setIsSubmitting(true); const resNormal = await postStockInLine(qcData); qcRes = Array.isArray(resNormal.entity) ? resNormal.entity[0] : (resNormal.entity as StockInLine); @@ -553,7 +587,8 @@ const QcStockInModal: React.FC = ({ } else { closeWithResult(qcRes); } - setIsSubmitting(false); + setIsSubmitting(false); + qcSubmitInFlightRef.current = false; msg("已更新來貨狀態", { position: typeof window !== "undefined" && @@ -815,7 +850,7 @@ const printQrcode = useCallback( color="primary" sx={{ mt: 1 }} onClick={formProps.handleSubmit(onSubmitQc, onSubmitErrorQc)} - disabled={isSubmitting || isLoading} + disabled={isSubmitting || isLoading || roundChoiceRequired} > {isSubmitting ? (t("submitting")) : (skipQc ? t("confirm") : t("confirm qc result"))} )} diff --git a/src/components/SearchBox/SearchBox.tsx b/src/components/SearchBox/SearchBox.tsx index 45331726..5ecec020 100644 --- a/src/components/SearchBox/SearchBox.tsx +++ b/src/components/SearchBox/SearchBox.tsx @@ -124,15 +124,19 @@ interface Props { onReset?: () => void; /** Optional actions rendered in the same row as Reset/Search (e.g. Download, Upload buttons) */ extraActions?: React.ReactNode; + /** Optional filters rendered above the standard criteria fields */ + extraCriteria?: React.ReactNode; /** Disable inputs/actions while external task is running */ disabled?: boolean; } +/** FP-MTMS Version Checklist | Functions Ref. No. 79 | v1.0.0 | 2026-09-10 */ function SearchBox({ criteria, onSearch, onReset, extraActions, + extraCriteria, disabled = false, }: Props) { const { t } = useTranslation("common"); @@ -295,6 +299,7 @@ function SearchBox({ {t("Search Criteria")} + {extraCriteria} {criteria.map((c) => { return ( diff --git a/src/components/StockIn/StockInForm.tsx b/src/components/StockIn/StockInForm.tsx index 7894f678..3de51d0d 100644 --- a/src/components/StockIn/StockInForm.tsx +++ b/src/components/StockIn/StockInForm.tsx @@ -10,15 +10,13 @@ import { CardContent, Grid, InputAdornment, - Stack, TextField, Tooltip, - Typography, } from "@mui/material"; import { Controller, useFormContext } from "react-hook-form"; import { useTranslation } from "react-i18next"; import StyledDataGrid from "../StyledDataGrid"; -import { useCallback, useEffect, useState, useMemo } from "react"; +import { useCallback, useEffect, useState } from "react"; import { useGridApiRef, } from "@mui/x-data-grid"; @@ -114,6 +112,8 @@ const StockInForm: React.FC = ({ const productionDate = watch("productionDate"); const expiryDate = watch("expiryDate"); const uom = watch("uom"); + const displayedAcceptedQty = watch("acceptedQty"); + const originalAcceptedQty = Number(itemDetail.acceptedQty ?? 0); const [openModal, setOpenModal] = useState(false); const [openExpDatePicker, setOpenExpDatePicker] = useState(false); @@ -401,17 +401,20 @@ const StockInForm: React.FC = ({ ) : ( + <> + )} {/* ({ + label, + name, + type: "select", + required: false, + multiple: true, + allowInput: true, + asyncSearch: true, + placeholder: "e.g. FA0591", +}); + export const REPORTS: ReportDefinition[] = [ //{ // id: "rep-001", @@ -86,7 +104,7 @@ export const REPORTS: ReportDefinition[] = [ fields: [ { label: "入倉日期:由 Last In Date Start", name: "lastInDateStart", type: "date", required: false }, { label: "入倉日期:至 Last In Date End", name: "lastInDateEnd", type: "date", required: false }, - { label: "貨品編號 Item Code", name: "itemCode", type: "text", required: false}, + asyncItemCodeField(), { label: "樓層 Store ID", name: "storeId", @@ -125,7 +143,7 @@ export const REPORTS: ReportDefinition[] = [ { label: "出貨日期:由 Last Out Date Start", name: "lastOutDateStart", type: "date", required: false }, { label: "出貨日期:至 Last Out Date End", name: "lastOutDateEnd", type: "date", required: false }, { label: "年份 Year", name: "year", type: "text", required: false, placeholder: "e.g. 2026" }, - { label: "貨品編號 Item Code", name: "itemCode", type: "text", required: false}, + asyncItemCodeField(), ] }, /* @@ -154,7 +172,7 @@ export const REPORTS: ReportDefinition[] = [ dynamicOptionsEndpoint: `${NEXT_PUBLIC_API_URL}/report/stock-take-rounds`, options: [] }, - { label: "貨品編號", name: "itemCode", type: "text", required: false}, + asyncItemCodeField("貨品編號"), { label: "倉庫樓層", name: "store_id", @@ -232,7 +250,7 @@ export const REPORTS: ReportDefinition[] = [ apiEndpoint: `${NEXT_PUBLIC_API_URL}/report/print-stock-lot-onhand`, responseType: "excel", fields: [ - { label: "貨品編號 Item Code", name: "itemCode", type: "text", required: false }, + asyncItemCodeField(), { label: "樓層 Store ID", name: "storeId", @@ -279,7 +297,7 @@ export const REPORTS: ReportDefinition[] = [ fields: [ { label: "庫存日期:由 Last In Date Start", name: "lastInDateStart", type: "date", required: false }, { label: "庫存日期:至 Last In Date End", name: "lastInDateEnd", type: "date", required: false }, - { label: "貨品編號 Item Code", name: "itemCode", type: "text", required: false}, + asyncItemCodeField(), ] }, /* Hidden for now: 庫存流水帳報告 (rep-020) @@ -314,14 +332,14 @@ export const REPORTS: ReportDefinition[] = [ ] }, */ + /** FP-MTMS Version Checklist | Functions Ref. No. 82 | v1.0.0 | 2026-09-10 */ { id: "rep-007", title: "庫存結餘報告", apiEndpoint: `${NEXT_PUBLIC_API_URL}/report/print-stock-balance`, fields: [ { label: "庫存日期: Stock Date", name: "stockDate", type: "date", required: true }, - - { label: "貨品編號 Item Code", name: "itemCode", type: "text", required: false}, + asyncItemCodeField(), ] }, @@ -334,7 +352,7 @@ export const REPORTS: ReportDefinition[] = [ fields: [ { label: "收貨日期:由 Receipt Date Start", name: "receiptDateStart", type: "date", required: false }, { label: "收貨日期:至 Receipt Date End", name: "receiptDateEnd", type: "date", required: false }, - { label: "貨品編號 Item Code", name: "itemCode", type: "text", required: false }, + asyncItemCodeField(), ], }, @@ -344,7 +362,7 @@ export const REPORTS: ReportDefinition[] = [ fields: [ { label: "出貨日期:由 Last Out Date Start", name: "lastOutDateStart", type: "date", required: false }, { label: "出貨日期:至 Last Out Date End", name: "lastOutDateEnd", type: "date", required: false }, - { label: "貨品編號 Item Code", name: "itemCode", type: "text", required: false}, + asyncItemCodeField(), { label: "提料員 Handler", name: "handler", type: "select", required: false, multiple: true, dynamicOptions: true, @@ -367,7 +385,7 @@ export const REPORTS: ReportDefinition[] = [ { label: "IQC(採購)", value: "IQC" }, { label: "EPQC(工單)", value: "EPQC" }, ] }, - { label: "貨品編號 Item Code", name: "itemCode", type: "text", required: false}, + asyncItemCodeField(), { label: "QC 項目範圍", name: "qcItemScope", @@ -386,7 +404,7 @@ export const REPORTS: ReportDefinition[] = [ fields: [ { label: "出倉日期:由 Last Out Date Start", name: "lastOutDateStart", type: "date", required: false }, { label: "出倉日期:至 Last Out Date End", name: "lastOutDateEnd", type: "date", required: false }, - { label: "貨品編號 Item Code", name: "itemCode", type: "text", required: false}, + asyncItemCodeField(), { label: "提料人 Handler", name: "handler", type: "select", required: false, multiple: true, dynamicOptions: true, @@ -455,7 +473,7 @@ export const REPORTS: ReportDefinition[] = [ fields: [ { label: "同步日期:由 Sync Date Start", name: "syncDateStart", type: "date", required: false }, { label: "同步日期:至 Sync Date End", name: "syncDateEnd", type: "date", required: false }, - { label: "成品貨號 Finished Item Code", name: "finishedItemCode", type: "text", required: false }, + asyncItemCodeField("成品貨號 Finished Item Code", "finishedItemCode"), { label: "同步狀態 Sync Status", name: "syncStatus", @@ -487,7 +505,7 @@ export const REPORTS: ReportDefinition[] = [ options: [], }, { label: "提票號碼", name: "ticketNo", type: "text", required: false }, - { label: "貨品編號 Item Code", name: "itemCode", type: "text", required: false }, + asyncItemCodeField(), { label: "樓層", name: "storeId", diff --git a/src/i18n/en/common.json b/src/i18n/en/common.json index 61a464d3..a3b15c4e 100644 --- a/src/i18n/en/common.json +++ b/src/i18n/en/common.json @@ -1,7 +1,7 @@ { "Actions": "操作", "Add Document": "新增文件", - "All": "全部", + "All": "All", "Allergic Substances": "過敏原", "An error has occurred. Please try again later.": "An error has occurred. Please try again later.", "Are you sure you want to delete this item?": "您確定要刪除此項目嗎?", @@ -75,15 +75,15 @@ "Remarks": "備註", "Remove Document": "移除文件", "Report": "報告", - "Reset": "重置", + "Reset": "Reset", "Row per page": "每頁行數", "Rows per page": "每頁行數", "Sales Qty": "銷售數量", "Sales UOM": "銷售單位", "Save": "儲存", "Saving": "儲存中", - "Search": "搜索", - "Search Criteria": "搜索條件", + "Search": "Search", + "Search Criteria": "Search Criteria", "Select Date": "選擇日期", "Session expired or unauthorized.": "工作階段已過期或未經授權。", "Sign out": "Sign out", diff --git a/src/i18n/en/inventory.json b/src/i18n/en/inventory.json index 3dcb7bdf..5d170b12 100644 --- a/src/i18n/en/inventory.json +++ b/src/i18n/en/inventory.json @@ -1,5 +1,6 @@ { "Action": "Action", + "All": "All", "Add": "Add", "Add entry": "Add entry", "Add entry for items without inventory": "Add entry for items without inventory", @@ -15,7 +16,14 @@ "Download QR Code": "Download QR Code", "Edit mode": "Edit mode", "Enter item code or name to search": "Enter item code or name to search", + "Area": "Area", "Expiry Date": "Expiry Date", + "Floor": "Floor", + "Item Search": "Item Search", + "Location Search": "Location Search", + "Select a floor to search by location.": "Select a floor to search by location.", + "Select floor first": "Select a floor first", + "Select warehouse first": "Select a warehouse first", "FG": "Finished good", "Failed to transfer stock": "Failed to transfer stock", "Failed to transfer stock. Please try again.": "Failed to transfer stock. Please try again.", @@ -52,6 +60,7 @@ "Remove": "Remove", "Reset": "Reset", "SFG": "Semi-finished good", + "Slot": "Slot", "Save": "Save", "Save failed": "Save failed", "Saved successfully": "Saved successfully", diff --git a/src/i18n/en/purchaseOrder.json b/src/i18n/en/purchaseOrder.json index 7b143fb7..dd920e44 100644 --- a/src/i18n/en/purchaseOrder.json +++ b/src/i18n/en/purchaseOrder.json @@ -22,6 +22,7 @@ "Start PO": "Start PO", "Do you want to complete?": "Do you want to complete?", "Cancel": "Cancel", + "Confirm": "Confirm", "Complete": "Complete", "Complete Success": "Complete Success", "Complete Fail": "Complete Fail", @@ -51,7 +52,14 @@ "acceptedPutawayQty": "Put Away Qty (This Batch)", "putawayQty": "Put Away Qty", "Confirm submit": "Confirm Submit", + "This batch quantity exceeds order quantity. Do you still want to submit?": "This batch quantity exceeds order quantity. Do you still want to submit?", "qtyExceedsOrderConfirm": "Cumulative received quantity exceeds order quantity. Do you still want to submit?", + "Stock qty is not an integer": "Stock quantity is not an integer", + "Converted stock qty is": "Converted stock qty is", + "Choose rounding method": ". Please choose a rounding method:", + "Round ceiling": "Round up", + "Round floor": "Round down", + "Please choose a rounding method": "Please choose a rounding method", "acceptQty": "Accept Qty", "printQty": "Print Qty", "qcResult": "QC Result", diff --git a/src/i18n/en/report.json b/src/i18n/en/report.json index c80ad1a9..17af3c26 100644 --- a/src/i18n/en/report.json +++ b/src/i18n/en/report.json @@ -16,8 +16,12 @@ "noDataFoundHint": "No inventory records match your search. Please try adjusting the filters.", "ok": "OK", "missingRequired": "Missing required fields:\n- {{fields}}", + "requiredField": "This is a required field", "dateNotBeforeToday": "Date cannot be before today:\n- {{fields}}", "selectOrEnterItemCode": "Select or enter item code", + "typeToSearchItemCode": "Enter at least {{min}} characters to search", + "noItemCodeMatches": "No matching item codes", + "searchingItemCodes": "Searching...", "cancel": "Cancel", "confirmDownloadPdf": "Confirm download PDF", "confirmDownloadExcel": "Confirm download Excel", diff --git a/src/i18n/zh/inventory.json b/src/i18n/zh/inventory.json index fae7d823..8513855b 100644 --- a/src/i18n/zh/inventory.json +++ b/src/i18n/zh/inventory.json @@ -1,5 +1,6 @@ { "Action": "操作", + "All": "全部", "Add": "新增", "Add entry": "新增倉存", "Add entry for items without inventory": "為無庫存貨品新增倉存", @@ -15,7 +16,14 @@ "Download QR Code": "下載", "Edit mode": "編輯模式", "Enter item code or name to search": "輸入貨品編號或名稱以搜索", + "Area": "區域", "Expiry Date": "到期日", + "Floor": "樓層", + "Item Search": "貨品搜尋", + "Location Search": "倉位搜尋", + "Select a floor to search by location.": "請先選擇樓層以搜尋倉位。", + "Select floor first": "請先選擇樓層", + "Select warehouse first": "請先選擇倉庫", "FG": "成品", "Failed to transfer stock": "轉倉失敗", "Failed to transfer stock. Please try again.": "轉倉失敗,請重試。", @@ -52,6 +60,7 @@ "Remove": "移除", "Reset": "重置", "SFG": "半成品", + "Slot": "儲位", "Save": "儲存", "Save failed": "儲存失敗", "Saved successfully": "儲存成功", diff --git a/src/i18n/zh/purchaseOrder.json b/src/i18n/zh/purchaseOrder.json index 0e82764d..0cac29af 100644 --- a/src/i18n/zh/purchaseOrder.json +++ b/src/i18n/zh/purchaseOrder.json @@ -22,6 +22,7 @@ "Start PO": "開始採購訂單", "Do you want to complete?": "確定完成嗎?", "Cancel": "取消", + "Confirm": "確認", "Complete": "完成", "Complete Success": "完成成功", "Complete Fail": "完成失敗", @@ -51,7 +52,14 @@ "acceptedPutawayQty": "本批上架數量", "putawayQty": "上架數量", "Confirm submit": "確定提交", + "This batch quantity exceeds order quantity. Do you still want to submit?": "本批收貨數量超出訂單數量。仍要提交嗎?", "qtyExceedsOrderConfirm": "累計收貨數量超出訂單數量。仍要提交嗎?", + "Stock qty is not an integer": "換算庫存數量不是整數", + "Converted stock qty is": "換算庫存數量為", + "Choose rounding method": "。請選擇進位方式:", + "Round ceiling": "向上取整", + "Round floor": "向下取整", + "Please choose a rounding method": "請選擇進位方式", "acceptQty": "揀收數量", "printQty": "列印數量", "qcResult": "品檢結果", diff --git a/src/i18n/zh/report.json b/src/i18n/zh/report.json index 3812c6a3..52a2a638 100644 --- a/src/i18n/zh/report.json +++ b/src/i18n/zh/report.json @@ -16,8 +16,12 @@ "noDataFoundHint": "找不到符合您搜尋條件的庫存資料,請嘗試調整篩選條件。", "ok": "確定", "missingRequired": "缺少必填條件:\n- {{fields}}", + "requiredField": "此為必填欄位", "dateNotBeforeToday": "日期不可早於今天:\n- {{fields}}", "selectOrEnterItemCode": "選擇或輸入物料編號", + "typeToSearchItemCode": "請輸入至少 {{min}} 個字元以搜尋貨品編號", + "noItemCodeMatches": "沒有符合的貨品編號", + "searchingItemCodes": "搜尋中...", "cancel": "取消", "confirmDownloadPdf": "確認下載 PDF", "confirmDownloadExcel": "確認下載 Excel",