From 0ab4adcef3120d8ac64b1022a6d24536bc3c5f68 Mon Sep 17 00:00:00 2001 From: Frank Steiler Date: Mon, 25 May 2026 22:36:46 +0200 Subject: [PATCH] fix(auto-itemize): extract invoiceNumber+notes via LLM, fix sticky PDF (#1581) The LLM JSON schema for budget extraction was missing `invoiceNumber` and `notes` fields, so those values were never returned even when the model successfully identified them. The schema in `budgetExtraction.ts`, the prompt in `prompts.ts`, and the provider profiles were updated to include both fields, and the `openAICompatibleProvider` now maps them through to the extraction result. The `invoiceAutoItemizeService` now propagates `invoiceNumber` and `notes` from the extraction result into the auto-itemize response. The `AutoItemizePage` displays both fields in the extraction summary card once results arrive, giving users a quick confirmation that the LLM found the right invoice metadata without having to navigate away. The PDF viewer inside `AutoItemizePage` was not staying fixed while the user scrolled the results panel. The CSS was reworked so the PDF iframe takes a full-viewport sticky column and the results panel scrolls independently, resolving the sticky/full-viewport layout regression introduced in #1577. Fixes #1581 Co-Authored-By: Claude backend-developer (Haiku 4.5) Co-Authored-By: Claude frontend-developer (Haiku 4.5) Co-Authored-By: Claude qa-integration-tester (Sonnet 4.5) Co-Authored-By: Claude e2e-test-engineer (Sonnet 4.5) --- .../AutoItemizePage.module.css | 14 +- .../AutoItemizePage/AutoItemizePage.test.tsx | 131 ++++++++++++++++++ .../pages/AutoItemizePage/AutoItemizePage.tsx | 91 ++++++++---- e2e/pages/AutoItemizePage.ts | 34 ++++- .../invoice-auto-itemize-page.spec.ts | 103 +++++++++++++- .../openAICompatibleProvider.test.ts | 82 +++++++++++ .../openAICompatibleProvider.ts | 14 +- .../src/services/budgetExtraction/prompts.ts | 12 +- .../budgetExtraction/providerProfiles.test.ts | 24 +++- .../budgetExtraction/providerProfiles.ts | 10 +- .../invoiceAutoItemizeService.test.ts | 107 ++++++++++++++ .../src/services/invoiceAutoItemizeService.ts | 2 + shared/src/types/budgetExtraction.ts | 6 +- shared/src/types/invoiceAutoItemize.ts | 4 + 14 files changed, 586 insertions(+), 48 deletions(-) diff --git a/client/src/pages/AutoItemizePage/AutoItemizePage.module.css b/client/src/pages/AutoItemizePage/AutoItemizePage.module.css index c3308d802..1bec9f742 100644 --- a/client/src/pages/AutoItemizePage/AutoItemizePage.module.css +++ b/client/src/pages/AutoItemizePage/AutoItemizePage.module.css @@ -43,6 +43,7 @@ align-items: start; overflow-y: auto; flex: 1; + min-height: 0; } @media (max-width: 860px) { @@ -69,12 +70,15 @@ display: flex; flex-direction: column; position: sticky; - top: calc(var(--auto-itemize-header-height) + var(--spacing-6)); + top: var(--auto-itemize-header-height); + height: calc(100vh - var(--auto-itemize-header-height)); } @media (max-width: 860px) { .previewColumn { position: static; + height: auto; + top: auto; } } @@ -636,12 +640,16 @@ position: relative; border-radius: var(--radius-lg); overflow: hidden; + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; } .pdfIframe { width: 100%; - height: calc(100vh - var(--auto-itemize-header-height) - var(--spacing-12)); - min-height: 480px; + flex: 1; + min-height: 0; border: 1px solid var(--color-border); border-radius: var(--radius-lg); background: var(--color-bg-tertiary); diff --git a/client/src/pages/AutoItemizePage/AutoItemizePage.test.tsx b/client/src/pages/AutoItemizePage/AutoItemizePage.test.tsx index 88d3f7995..067d9a686 100644 --- a/client/src/pages/AutoItemizePage/AutoItemizePage.test.tsx +++ b/client/src/pages/AutoItemizePage/AutoItemizePage.test.tsx @@ -1509,6 +1509,137 @@ describe('AutoItemizePage', () => { }); }); + // ─── Story #1581: SuggestionBadge for invoiceNumber and notes ──────────── + + describe('SuggestionBadge for extracted invoiceNumber and notes (Story #1581)', () => { + it('badge appears for invoiceNumber when extractedInvoiceNumber differs from stored', async () => { + // Invoice has invoiceNumber 'INV-001'; LLM extracts 'RE-2024-001' (different) + mockFetchInvoiceById.mockResolvedValue(makeInvoice({ invoiceNumber: 'INV-001' })); + mockGetPaperlessDocument.mockResolvedValue(makePaperlessDoc()); + mockAutoItemize.mockResolvedValue({ + lines: [], + warnings: [], + extractedInvoiceNumber: 'RE-2024-001', + }); + + renderPage(); + + await waitFor(() => { + expect(screen.getByText(/LLM suggests/i)).toBeInTheDocument(); + }); + }); + + it('badge absent when extractedInvoiceNumber is undefined', async () => { + mockFetchInvoiceById.mockResolvedValue(makeInvoice({ invoiceNumber: 'INV-001' })); + mockGetPaperlessDocument.mockResolvedValue(makePaperlessDoc()); + // No extractedInvoiceNumber in response + mockAutoItemize.mockResolvedValue({ lines: [], warnings: [] }); + + renderPage(); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /^Save$/i })).toBeInTheDocument(); + }); + + expect(screen.queryByText(/LLM suggests/i)).not.toBeInTheDocument(); + }); + + it('badge absent when extractedInvoiceNumber matches stored value', async () => { + // Both invoice and extracted value are 'INV-001' — no difference, no badge + mockFetchInvoiceById.mockResolvedValue(makeInvoice({ invoiceNumber: 'INV-001' })); + mockGetPaperlessDocument.mockResolvedValue(makePaperlessDoc()); + mockAutoItemize.mockResolvedValue({ + lines: [], + warnings: [], + extractedInvoiceNumber: 'INV-001', // same as stored + }); + + renderPage(); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /^Save$/i })).toBeInTheDocument(); + }); + + expect(screen.queryByText(/LLM suggests/i)).not.toBeInTheDocument(); + }); + + it('Apply button updates the #invoice-number field and dismisses the badge', async () => { + mockFetchInvoiceById.mockResolvedValue(makeInvoice({ invoiceNumber: 'INV-001' })); + mockGetPaperlessDocument.mockResolvedValue(makePaperlessDoc()); + mockAutoItemize.mockResolvedValue({ + lines: [], + warnings: [], + extractedInvoiceNumber: 'RE-2024-001', + }); + + renderPage(); + + await waitFor(() => { + expect(screen.getByText(/LLM suggests/i)).toBeInTheDocument(); + }); + + // Click the Apply button for the invoiceNumber suggestion + const applyButtons = screen.getAllByRole('button', { name: /Apply/i }); + fireEvent.click(applyButtons[0]!); + + // The invoice-number field should now hold the extracted value + const invoiceNumberField = document.getElementById('invoice-number') as HTMLInputElement; + expect(invoiceNumberField).not.toBeNull(); + expect(invoiceNumberField.value).toBe('RE-2024-001'); + + // Badge disappears after applying (extracted value now matches the field value) + await waitFor(() => { + expect(screen.queryByText(/LLM suggests/i)).not.toBeInTheDocument(); + }); + }); + + it('badge appears for notes when extractedNotes differs from stored notes', async () => { + // Invoice has notes: null; LLM extracts a non-empty summary + mockFetchInvoiceById.mockResolvedValue(makeInvoice({ notes: null })); + mockGetPaperlessDocument.mockResolvedValue(makePaperlessDoc()); + mockAutoItemize.mockResolvedValue({ + lines: [], + warnings: [], + extractedNotes: 'Kitchen renovation labor and materials.', + }); + + renderPage(); + + await waitFor(() => { + expect(screen.getByText(/LLM suggests/i)).toBeInTheDocument(); + }); + }); + + it('Apply button for notes updates the #notes textarea and dismisses the badge', async () => { + mockFetchInvoiceById.mockResolvedValue(makeInvoice({ notes: null })); + mockGetPaperlessDocument.mockResolvedValue(makePaperlessDoc()); + mockAutoItemize.mockResolvedValue({ + lines: [], + warnings: [], + extractedNotes: 'Kitchen renovation labor and materials.', + }); + + renderPage(); + + await waitFor(() => { + expect(screen.getByText(/LLM suggests/i)).toBeInTheDocument(); + }); + + const applyButtons = screen.getAllByRole('button', { name: /Apply/i }); + fireEvent.click(applyButtons[0]!); + + // The #notes textarea should now hold the extracted value + const notesField = document.getElementById('notes') as HTMLTextAreaElement; + expect(notesField).not.toBeNull(); + expect(notesField.value).toBe('Kitchen renovation labor and materials.'); + + // Badge disappears after applying + await waitFor(() => { + expect(screen.queryByText(/LLM suggests/i)).not.toBeInTheDocument(); + }); + }); + }); + // ─── Story #1576: PDF iframe ───────────────────────────────────────────── describe('PDF iframe in ready state (Story #1576)', () => { diff --git a/client/src/pages/AutoItemizePage/AutoItemizePage.tsx b/client/src/pages/AutoItemizePage/AutoItemizePage.tsx index a13db8c20..22651cfb2 100644 --- a/client/src/pages/AutoItemizePage/AutoItemizePage.tsx +++ b/client/src/pages/AutoItemizePage/AutoItemizePage.tsx @@ -82,6 +82,8 @@ export function AutoItemizePage() { > | null>(null); const [extractedInvoiceDate, setExtractedInvoiceDate] = useState(undefined); const [extractedDueDate, setExtractedDueDate] = useState(undefined); + const [extractedInvoiceNumber, setExtractedInvoiceNumber] = useState(undefined); + const [extractedNotes, setExtractedNotes] = useState(undefined); // Metadata edits const [metadataEdits, setMetadataEdits] = useState({ @@ -158,6 +160,8 @@ export function AutoItemizePage() { setElapsed(0); setPdfLoaded(false); setPdfFailed(false); + setExtractedInvoiceNumber(undefined); + setExtractedNotes(undefined); try { // Load Paperless status for the fallback link @@ -219,6 +223,8 @@ export function AutoItemizePage() { setWarnings(_autoItemizeResult.warnings); setExtractedInvoiceDate(_autoItemizeResult.extractedInvoiceDate ?? undefined); setExtractedDueDate(_autoItemizeResult.extractedDueDate ?? undefined); + setExtractedInvoiceNumber(_autoItemizeResult.extractedInvoiceNumber ?? undefined); + setExtractedNotes(_autoItemizeResult.extractedNotes ?? undefined); setPageStatus('ready'); } else { setPageError(t('autoItemize.unexpectedResponse')); @@ -516,6 +522,23 @@ export function AutoItemizePage() { [extractedDueDate, metadataEdits.dueDate], ); + const invoiceNumberSuggestion = useMemo( + () => + extractedInvoiceNumber && + extractedInvoiceNumber !== (metadataEdits.invoiceNumber ?? '') + ? extractedInvoiceNumber + : undefined, + [extractedInvoiceNumber, metadataEdits.invoiceNumber], + ); + + const notesSuggestion = useMemo( + () => + extractedNotes && extractedNotes !== (metadataEdits.notes ?? '') + ? extractedNotes + : undefined, + [extractedNotes, metadataEdits.notes], + ); + const computedLineTotal = useMemo( () => lines.filter((l) => l.included).reduce((sum, line) => sum + (line.totalAmount ?? 0), 0), [lines], @@ -631,18 +654,28 @@ export function AutoItemizePage() {
-
- - setMetadataEdits((prev) => ({ - ...prev, - invoiceNumber: e.target.value || null, - })) - } - /> +
+
+ + setMetadataEdits((prev) => ({ + ...prev, + invoiceNumber: e.target.value || null, + })) + } + /> +
+ {invoiceNumberSuggestion && ( + handleApplySuggestion('invoiceNumber', invoiceNumberSuggestion)} + /> + )}
@@ -730,18 +763,28 @@ export function AutoItemizePage() {
-
-