Bladeren bron

[Fix] Item search field: codes that weren't selected through dropdown still take effect

production
Harry Groves 10 uur geleden
bovenliggende
commit
a1f430a536
3 gewijzigde bestanden met toevoegingen van 139 en 40 verwijderingen
  1. +49
    -12
      src/app/(main)/report/AsyncItemCodeAutocomplete.tsx
  2. +11
    -5
      src/app/(main)/report/SemiFGProductionAnalysisReport.tsx
  3. +79
    -23
      src/app/(main)/report/page.tsx

+ 49
- 12
src/app/(main)/report/AsyncItemCodeAutocomplete.tsx Bestand weergeven

@@ -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<Props> = ({
label,
value,
onChange,
onInputValueChange,
placeholder,
disabled = false,
minChars = 2,
@@ -73,21 +77,47 @@ const AsyncItemCodeAutocomplete: React.FC<Props> = ({
};
}, [trimmedInput, minChars]);

const selectedCodes = useMemo(
() => new Set(value.map((code) => code.toUpperCase())),
[value],
);

const options = useMemo(() => {
const seen = new Set<string>();
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<Props> = ({
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<Props> = ({
},
}}
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 : [])


+ 11
- 5
src/app/(main)/report/SemiFGProductionAnalysisReport.tsx Bestand weergeven

@@ -30,6 +30,7 @@ import { parseItemCodeTokens } from './parseItemCodeTokens';

interface SemiFGProductionAnalysisReportProps {
criteria: Record<string, string>;
prepareCriteria?: () => Record<string, string>;
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<string, string> = 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);


+ 79
- 23
src/app/(main)/report/page.tsx Bestand weergeven

@@ -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<string>('');
const [criteria, setCriteria] = useState<Record<string, string>>({});
const criteriaRef = useRef<Record<string, string>>({});
const pendingItemCodeInputRef = useRef<Record<string, string>>({});
const updateCriteria = (
updater: Record<string, string> | ((prev: Record<string, string>) => Record<string, string>),
) => {
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<string, string> => {
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<Record<string, { label: string; value: string }[]>>({});
@@ -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() {
<Autocomplete
multiple={field.multiple || false}
freeSolo
filterSelectedOptions
options={options.map(opt => 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' ? (
<SemiFGProductionAnalysisReport
criteria={criteria}
prepareCriteria={flushPendingItemCodeInputs}
requiredFieldLabels={currentReport.fields.filter(f => f.required && !criteria[f.name]).map(f => fieldLabel(currentReport.id, f))}
loading={loading}
setLoading={setLoading}


Laden…
Annuleren
Opslaan