From a1f430a536175ef20f3d596b6ff7c588a8954111 Mon Sep 17 00:00:00 2001 From: Harry Groves Date: Mon, 28 Sep 2026 15:17:58 +0800 Subject: [PATCH] [Fix] Item search field: codes that weren't selected through dropdown still take effect --- .../report/AsyncItemCodeAutocomplete.tsx | 61 ++++++++--- .../report/SemiFGProductionAnalysisReport.tsx | 16 ++- src/app/(main)/report/page.tsx | 102 ++++++++++++++---- 3 files changed, 139 insertions(+), 40 deletions(-) diff --git a/src/app/(main)/report/AsyncItemCodeAutocomplete.tsx b/src/app/(main)/report/AsyncItemCodeAutocomplete.tsx index 3e1681a1..a1f68151 100644 --- a/src/app/(main)/report/AsyncItemCodeAutocomplete.tsx +++ b/src/app/(main)/report/AsyncItemCodeAutocomplete.tsx @@ -1,14 +1,17 @@ "use client"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { Autocomplete, Chip, CircularProgress, TextField } from "@mui/material"; import { useTranslation } from "react-i18next"; import { searchItemCodes, type ItemCodeSearchHit } from "./itemCodeSearchApi"; +import { parseItemCodeTokens } from "./parseItemCodeTokens"; type Props = { label: string; value: string[]; onChange: (codes: string[]) => void; + /** Live text in the box, including text that has not been picked from the list. */ + onInputValueChange?: (value: string) => void; placeholder?: string; disabled?: boolean; minChars?: number; @@ -17,11 +20,12 @@ type Props = { 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 */ +/** FP-MTMS Version Checklist | Functions Ref. No. 81 | v1.0.1 | 2026-09-28 */ const AsyncItemCodeAutocomplete: React.FC = ({ label, value, onChange, + onInputValueChange, placeholder, disabled = false, minChars = 2, @@ -73,21 +77,47 @@ const AsyncItemCodeAutocomplete: React.FC = ({ }; }, [trimmedInput, minChars]); + const selectedCodes = useMemo( + () => new Set(value.map((code) => code.toUpperCase())), + [value], + ); + const options = useMemo(() => { const seen = new Set(); const codes: string[] = []; suggestions.forEach((hit) => { - if (seen.has(hit.code)) return; - seen.add(hit.code); + const key = hit.code.toUpperCase(); + if (seen.has(key) || selectedCodes.has(key)) return; + seen.add(key); codes.push(hit.code); }); - value.forEach((code) => { - if (seen.has(code)) return; - seen.add(code); - codes.push(code); - }); return codes; - }, [suggestions, value]); + }, [suggestions, selectedCodes]); + + useEffect(() => { + const trimmed = inputValue.trim(); + if (!trimmed) return; + const hit = suggestions.find((item) => item.code.toUpperCase() === trimmed.toUpperCase()); + if (hit) onInputValueChange?.(hit.code); + }, [suggestions, inputValue, onInputValueChange]); + + const inputValueRef = useRef(inputValue); + inputValueRef.current = inputValue; + const previousValueRef = useRef(value); + useEffect(() => { + const previous = previousValueRef.current; + previousValueRef.current = value; + const tokens = parseItemCodeTokens(inputValueRef.current); + if (tokens.length === 0) return; + const previousKeys = new Set(previous.map((code) => code.toUpperCase())); + const nextKeys = new Set(value.map((code) => code.toUpperCase())); + const justCommitted = tokens.every( + (token) => nextKeys.has(token.toUpperCase()) && !previousKeys.has(token.toUpperCase()), + ); + if (!justCommitted) return; + setInputValue(""); + onInputValueChange?.(""); + }, [value, onInputValueChange]); const noOptionsText = needsMoreChars ? t("typeToSearchItemCode", { min: minChars }) @@ -112,9 +142,11 @@ const AsyncItemCodeAutocomplete: React.FC = ({ filterOptions={(opts) => trimmedInput.length < minChars ? [] - : opts.filter((code) => !value.includes(code)) + : opts.filter((code) => !selectedCodes.has(code.toUpperCase())) + } + isOptionEqualToValue={(option, selected) => + option.toUpperCase() === selected.toUpperCase() } - isOptionEqualToValue={(option, selected) => option === selected} autoHighlight noOptionsText={noOptionsText} sx={{ @@ -160,11 +192,16 @@ const AsyncItemCodeAutocomplete: React.FC = ({ }, }} onInputChange={(_, next, reason) => { + if (reason === "blur") return; if (reason === "reset") { setInputValue(""); + onInputValueChange?.(""); return; } setInputValue(next); + const trimmed = next.trim(); + const hit = suggestions.find((item) => item.code.toUpperCase() === trimmed.toUpperCase()); + onInputValueChange?.(hit?.code ?? next); }} onChange={(_, newValue) => { const codes = (Array.isArray(newValue) ? newValue : []) diff --git a/src/app/(main)/report/SemiFGProductionAnalysisReport.tsx b/src/app/(main)/report/SemiFGProductionAnalysisReport.tsx index f676ba66..9a55d1cf 100644 --- a/src/app/(main)/report/SemiFGProductionAnalysisReport.tsx +++ b/src/app/(main)/report/SemiFGProductionAnalysisReport.tsx @@ -30,6 +30,7 @@ import { parseItemCodeTokens } from './parseItemCodeTokens'; interface SemiFGProductionAnalysisReportProps { criteria: Record; + prepareCriteria?: () => Record; requiredFieldLabels: string[]; loading: boolean; setLoading: (loading: boolean) => void; @@ -39,6 +40,7 @@ interface SemiFGProductionAnalysisReportProps { export default function SemiFGProductionAnalysisReport({ criteria, + prepareCriteria, requiredFieldLabels, loading, setLoading, @@ -70,6 +72,7 @@ export default function SemiFGProductionAnalysisReport({ }, [criteria.stockCategory]); const handleExportClick = async (format: 'pdf' | 'excel') => { + const activeCriteria = prepareCriteria?.() ?? criteria; setExportFormat(format); // Validate required fields if (requiredFieldLabels.length > 0) { @@ -78,10 +81,10 @@ export default function SemiFGProductionAnalysisReport({ } const selectedCodes = parseItemCodeTokens( - [criteria.itemCode, criteria.itemCodePaste].filter(Boolean).join(' '), + [activeCriteria.itemCode, activeCriteria.itemCodePaste].filter(Boolean).join(' '), ); if (selectedCodes.length === 0) { - await executeExport(format); + await executeExport(format, activeCriteria); return; } @@ -97,13 +100,16 @@ export default function SemiFGProductionAnalysisReport({ setShowConfirmDialog(true); }; - const executeExport = async (format: 'pdf' | 'excel' = exportFormat) => { + const executeExport = async ( + format: 'pdf' | 'excel' = exportFormat, + activeCriteria: Record = prepareCriteria?.() ?? criteria, + ) => { setLoading(true); try { if (format === 'excel') { - await generateSemiFGProductionAnalysisReportExcel(criteria, reportTitle); + await generateSemiFGProductionAnalysisReportExcel(activeCriteria, reportTitle); } else { - await generateSemiFGProductionAnalysisReport(criteria, reportTitle); + await generateSemiFGProductionAnalysisReport(activeCriteria, reportTitle); } onExportSuccess?.(format); setShowConfirmDialog(false); diff --git a/src/app/(main)/report/page.tsx b/src/app/(main)/report/page.tsx index 89f442e8..1c69d010 100644 --- a/src/app/(main)/report/page.tsx +++ b/src/app/(main)/report/page.tsx @@ -26,7 +26,7 @@ import { import DownloadIcon from '@mui/icons-material/Download'; import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; import { REPORTS } from '@/config/reportConfig'; -import { mergePastedItemCodes, buildItemCodePasteRequestBody } from './parseItemCodeTokens'; +import { mergePastedItemCodes, buildItemCodePasteRequestBody, parseItemCodeTokens } from './parseItemCodeTokens'; import { NEXT_PUBLIC_API_URL } from '@/config/api'; import { clientAuthFetch } from '@/app/utils/clientAuthFetch'; import SemiFGProductionAnalysisReport from './SemiFGProductionAnalysisReport'; @@ -86,8 +86,8 @@ const ITEM_CODE_PASTE_POST_REPORTS = new Set([ /** 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.2 | 2026-09-10 */ -/** FP-MTMS Version Checklist | Functions Ref. No. 82 | v1.0.0 | 2026-09-10 */ +/** FP-MTMS Version Checklist | Functions Ref. No. 69 | v1.0.3 | 2026-09-28 */ +/** FP-MTMS Version Checklist | Functions Ref. No. 82 | v1.0.1 | 2026-09-28 */ export default function ReportPage() { const { data: session } = useSession() as { data: SessionWithTokens | null }; const { t, i18n, reportTitle, fieldLabel, fieldHint, optionLabel } = useReportLabels(); @@ -98,6 +98,37 @@ export default function ReportPage() { const [selectedReportId, setSelectedReportId] = useState(''); const [criteria, setCriteria] = useState>({}); + const criteriaRef = useRef>({}); + const pendingItemCodeInputRef = useRef>({}); + const updateCriteria = ( + updater: Record | ((prev: Record) => Record), + ) => { + const next = typeof updater === 'function' ? updater(criteriaRef.current) : updater; + criteriaRef.current = next; + setCriteria(next); + }; + /** Typed item codes are not in criteria until a dropdown option is chosen. Merge them in before the request. */ + const flushPendingItemCodeInputs = (): Record => { + const pendingEntries = Object.entries(pendingItemCodeInputRef.current).filter(([, raw]) => raw.trim()); + if (pendingEntries.length === 0) return criteriaRef.current; + + const next = { ...criteriaRef.current }; + let changed = false; + for (const [field, raw] of pendingEntries) { + pendingItemCodeInputRef.current[field] = ''; + const merged = parseItemCodeTokens([next[field], raw].filter(Boolean).join(' ')); + const joined = merged.join(','); + if (joined && joined !== (next[field] || '')) { + next[field] = joined; + changed = true; + } + } + if (changed) { + criteriaRef.current = next; + setCriteria(next); + } + return criteriaRef.current; + }; const [loading, setLoading] = useState(false); const excelInFlightRef = useRef(false); const [dynamicOptions, setDynamicOptions] = useState>({}); @@ -124,14 +155,15 @@ export default function ReportPage() { if (reportId === selectedReportId) return; setSelectedReportId(reportId); setFieldErrors({}); + pendingItemCodeInputRef.current = {}; if (reportId === 'rep-010') { - setCriteria({ qcType: 'all', qcItemScope: 'all' }); + updateCriteria({ qcType: 'all', qcItemScope: 'all' }); } else if (reportId === 'rep-004') { - setCriteria({ storeId: 'All', poPrefix: 'All' }); + updateCriteria({ storeId: 'All', poPrefix: 'All' }); } else if (reportId === 'rep-021') { - setCriteria({ storeId: 'All', stockTakeSectionDescription: 'All', lotOrigin: 'All' }); + updateCriteria({ storeId: 'All', stockTakeSectionDescription: 'All', lotOrigin: 'All' }); } else { - setCriteria({}); + updateCriteria({}); } }; @@ -143,7 +175,7 @@ export default function ReportPage() { delete next[name]; return next; }); - setCriteria((prev) => { + updateCriteria((prev) => { const next = { ...prev, [name]: stringValue }; if (currentReport?.id === 'rep-021' && name === 'warehouse') { const m = stringValue.trim().match(/^w(\d)/i); @@ -252,7 +284,7 @@ export default function ReportPage() { // Default "All" (no filter) for stock take variance report conditions. if (selectedReportId === 'rep-012') { - setCriteria({ + updateCriteria({ store_id: 'All', status: 'All', type: 'All', @@ -264,7 +296,7 @@ export default function ReportPage() { useEffect(() => { if (selectedReportId !== 'rep-012' || !rep012MultiRound) return; if (criteria.status === 'completed') return; - setCriteria((prev) => ({ ...prev, status: 'completed' })); + updateCriteria((prev) => ({ ...prev, status: 'completed' })); }, [selectedReportId, rep012MultiRound, criteria.status]); // React 18 Strict Mode (dev) mounts → unmounts → remounts, so effects with [] run twice. @@ -329,19 +361,20 @@ export default function ReportPage() { /** rep-012:單輪送 status;多輪送 stockTakeRoundId 清單且 status=completed */ const buildRep012QueryString = (): string => { + const active = criteriaRef.current; const p = new URLSearchParams(); p.set('stockTakeRoundId', rep012RoundIds.join(',')); - const code = criteria.itemCode?.trim(); + const code = active.itemCode?.trim(); if (code) p.set('itemCode', code); - const store = criteria.store_id?.trim(); + const store = active.store_id?.trim(); if (store && store !== 'All') p.set('store_id', store); if (rep012MultiRound) { p.set('status', 'completed'); } else { - const status = criteria.status?.trim(); + const status = active.status?.trim(); if (status && status !== 'All') p.set('status', status); } - const lotType = criteria.type?.trim(); + const lotType = active.type?.trim(); if (lotType && lotType !== 'All') p.set('type', lotType); return p.toString(); }; @@ -349,15 +382,16 @@ export default function ReportPage() { /** FP-MTMS Version Checklist | Functions Ref. No. 64 | v1.0.0 | 2026-08-11 */ /** rep-010:qcItemScope → includeMeasurable / includeOther;qcType=all 不傳篩選 */ const buildRep010QueryString = (): string => { + const active = criteriaRef.current; const p = new URLSearchParams(); - Object.entries(criteria).forEach(([key, value]) => { + Object.entries(active).forEach(([key, value]) => { if (key === 'qcItemScope') return; if (key === 'qcType' && String(value).trim().toLowerCase() === 'all') return; if (value != null && String(value).trim() !== '') { p.set(key, String(value)); } }); - const scope = (criteria.qcItemScope || 'all').trim().toLowerCase(); + const scope = (active.qcItemScope || 'all').trim().toLowerCase(); if (scope === 'measurable') { p.set('includeMeasurable', 'true'); p.set('includeOther', 'false'); @@ -380,7 +414,7 @@ export default function ReportPage() { if (!currentReport) return ''; if (currentReport.id === 'rep-012') return buildRep012QueryString(); if (currentReport.id === 'rep-010') return buildRep010QueryString(); - const merged = mergePastedItemCodes(criteria, itemCodeFieldName()); + const merged = mergePastedItemCodes(criteriaRef.current, itemCodeFieldName()); const p = new URLSearchParams(merged); if (currentReport.id === 'rep-016') { const day = (merged.dateStart || '').trim(); @@ -393,6 +427,7 @@ export default function ReportPage() { }; const handlePrint = async () => { + flushPendingItemCodeInputs(); if (!currentReport) return; if (!validateRequiredFields()) return; @@ -409,6 +444,7 @@ export default function ReportPage() { }; const handleExcelPrint = async () => { + flushPendingItemCodeInputs(); if (!currentReport) return; if (!validateRequiredFields()) return; await executeExcelReport(); @@ -420,17 +456,18 @@ export default function ReportPage() { excelInFlightRef.current = true; setLoading(true); try { + const activeCriteria = criteriaRef.current; if (currentReport.id === 'rep-014') { await generateGrnReportExcel( - criteria, + activeCriteria, reportTitle(currentReport), includeGrnFinancialColumns, t, ); } else if (currentReport.id === 'rep-015') { - await generateBomShopSyncReportExcel(criteria, reportTitle(currentReport), t); + await generateBomShopSyncReportExcel(activeCriteria, reportTitle(currentReport), t); } else if (currentReport.id === 'rep-017') { - await generateShopOrderReplenishmentReportExcel(criteria, reportTitle(currentReport), t); + await generateShopOrderReplenishmentReportExcel(activeCriteria, reportTitle(currentReport), t); } else { // Backend returns actual .xlsx bytes for this Excel endpoint. const usesItemCodePastePost = isItemCodePastePostReport(); @@ -445,7 +482,7 @@ export default function ReportPage() { Accept: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'Content-Type': 'application/json', }, - body: JSON.stringify(buildItemCodePasteRequestBody(criteria, itemCodeFieldName())), + body: JSON.stringify(buildItemCodePasteRequestBody(criteriaRef.current, itemCodeFieldName())), } : { method: 'GET', @@ -514,7 +551,7 @@ export default function ReportPage() { Accept: 'application/pdf', 'Content-Type': 'application/json', }, - body: JSON.stringify(buildItemCodePasteRequestBody(criteria, itemCodeFieldName())), + body: JSON.stringify(buildItemCodePasteRequestBody(criteriaRef.current, itemCodeFieldName())), } : { method: 'GET', @@ -692,7 +729,13 @@ export default function ReportPage() { label={translatedLabel} placeholder={field.placeholder || "e.g. FA0591"} value={selectedCodes} - onChange={(codes) => handleFieldChange(field.name, codes)} + onInputValueChange={(text) => { + pendingItemCodeInputRef.current[field.name] = text; + }} + onChange={(codes) => { + pendingItemCodeInputRef.current[field.name] = ""; + handleFieldChange(field.name, codes); + }} minChars={field.asyncSearchMinChars ?? 2} disabled={disabledByCheckedCheckbox || disabledRep012Status || !!field.disabled} /> @@ -711,8 +754,20 @@ export default function ReportPage() { opt.value)} value={autocompleteValue} + isOptionEqualToValue={(option, selected) => + String(option).toUpperCase() === String(selected).toUpperCase() + } + onInputChange={(_, input, reason) => { + if (reason === "reset" || reason === "clear") { + pendingItemCodeInputRef.current[field.name] = ""; + return; + } + if (reason === "blur") return; + pendingItemCodeInputRef.current[field.name] = input; + }} onChange={(event, newValue, reason) => { if (field.multiple) { // Handle multiple selection - newValue is an array @@ -895,6 +950,7 @@ export default function ReportPage() { {currentReport.id === 'rep-005' ? ( f.required && !criteria[f.name]).map(f => fieldLabel(currentReport.id, f))} loading={loading} setLoading={setLoading}