Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions client/src/pages/AutoItemizePage/AutoItemizePage.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
align-items: start;
overflow-y: auto;
flex: 1;
min-height: 0;
}

@media (max-width: 860px) {
Expand All @@ -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;
}
}

Expand Down Expand Up @@ -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);
Expand Down
131 changes: 131 additions & 0 deletions client/src/pages/AutoItemizePage/AutoItemizePage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)', () => {
Expand Down
91 changes: 67 additions & 24 deletions client/src/pages/AutoItemizePage/AutoItemizePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,8 @@ export function AutoItemizePage() {
> | null>(null);
const [extractedInvoiceDate, setExtractedInvoiceDate] = useState<string | undefined>(undefined);
const [extractedDueDate, setExtractedDueDate] = useState<string | undefined>(undefined);
const [extractedInvoiceNumber, setExtractedInvoiceNumber] = useState<string | undefined>(undefined);
const [extractedNotes, setExtractedNotes] = useState<string | undefined>(undefined);

// Metadata edits
const [metadataEdits, setMetadataEdits] = useState<MetadataEdits>({
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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'));
Expand Down Expand Up @@ -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],
Expand Down Expand Up @@ -631,18 +654,28 @@ export function AutoItemizePage() {

<div className={styles.fieldRow}>
<label htmlFor="invoice-number">{t('autoItemize.invoiceNumber')}</label>
<div className={styles.fieldControl}>
<input
id="invoice-number"
type="text"
value={metadataEdits.invoiceNumber ?? ''}
onChange={(e) =>
setMetadataEdits((prev) => ({
...prev,
invoiceNumber: e.target.value || null,
}))
}
/>
<div>
<div className={styles.fieldControl}>
<input
id="invoice-number"
type="text"
value={metadataEdits.invoiceNumber ?? ''}
onChange={(e) =>
setMetadataEdits((prev) => ({
...prev,
invoiceNumber: e.target.value || null,
}))
}
/>
</div>
{invoiceNumberSuggestion && (
<SuggestionBadge
suggestedValue={invoiceNumberSuggestion}
fieldLabel={t('autoItemize.invoiceNumber')}
displayValue={invoiceNumberSuggestion}
onApply={() => handleApplySuggestion('invoiceNumber', invoiceNumberSuggestion)}
/>
)}
</div>
</div>

Expand Down Expand Up @@ -730,18 +763,28 @@ export function AutoItemizePage() {

<div className={styles.fieldRow}>
<label htmlFor="notes">{t('autoItemize.notes')}</label>
<div className={styles.fieldControl}>
<textarea
id="notes"
value={metadataEdits.notes ?? ''}
onChange={(e) =>
setMetadataEdits((prev) => ({
...prev,
notes: e.target.value || null,
}))
}
rows={3}
/>
<div>
<div className={styles.fieldControl}>
<textarea
id="notes"
value={metadataEdits.notes ?? ''}
onChange={(e) =>
setMetadataEdits((prev) => ({
...prev,
notes: e.target.value || null,
}))
}
rows={3}
/>
</div>
{notesSuggestion && (
<SuggestionBadge
suggestedValue={notesSuggestion}
fieldLabel={t('autoItemize.notes')}
displayValue={notesSuggestion}
onApply={() => handleApplySuggestion('notes', notesSuggestion)}
/>
)}
</div>
</div>

Expand Down
34 changes: 27 additions & 7 deletions e2e/pages/AutoItemizePage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
* - pageTitle: h1 with t('autoItemize.title') = "Auto-Itemize Invoice"
* - breadcrumb: <a class="breadcrumb"> with t('autoItemize.backToInvoice') = "Back to Invoice"
* - metadataCard: invoice metadata form with inputs #invoice-number, #amount, #date, #due-date, #notes
* - SuggestionBadge fields: invoiceNumber, amount, date, dueDate, notes (all use same badge pattern)
* - statusSelect: <select id="invoice-status"> with status options
* - lineList: <ul role="list" aria-label="Extracted line items"> containing <li class*="lineCard">
* - Each lineCard:
Expand Down Expand Up @@ -75,6 +76,7 @@ export class AutoItemizePage {
readonly retryButton: Locator;

// Metadata form inputs
readonly invoiceNumberInput: Locator;
readonly totalAmountInput: Locator;
readonly invoiceDateInput: Locator;
readonly dueDateInput: Locator;
Expand Down Expand Up @@ -205,6 +207,7 @@ export class AutoItemizePage {
this.retryButton = page.getByRole('button', { name: /^Retry$/i });

// Metadata inputs
this.invoiceNumberInput = page.locator('#invoice-number');
this.totalAmountInput = page.locator('#amount');
this.invoiceDateInput = page.locator('#date');
this.dueDateInput = page.locator('#due-date');
Expand Down Expand Up @@ -313,12 +316,21 @@ export class AutoItemizePage {
* We locate via the SuggestionBadge component's className which uses CSS Modules.
*
* Supported fields:
* 'amount' → scoped to the #amount field's parent container
* 'date' → scoped to the #date field's parent container
* 'dueDate' → scoped to the #due-date field's parent container
* 'amount' → scoped to the #amount field's parent container
* 'date' → scoped to the #date field's parent container
* 'dueDate' → scoped to the #due-date field's parent container
* 'invoiceNumber' → scoped to the #invoice-number field's parent container
* 'notes' → scoped to the #notes field's parent container
*/
suggestionBadge(field: 'amount' | 'date' | 'dueDate'): Locator {
const inputId = field === 'dueDate' ? 'due-date' : field;
suggestionBadge(field: 'amount' | 'date' | 'dueDate' | 'invoiceNumber' | 'notes'): Locator {
let inputId: string;
if (field === 'dueDate') {
inputId = 'due-date';
} else if (field === 'invoiceNumber') {
inputId = 'invoice-number';
} else {
inputId = field;
}
// The badge is a sibling of the input, inside a field-control wrapper div.
// Use ancestor traversal: input → parent div (fieldControl) → parent div → badge span.
return this.page
Expand All @@ -330,9 +342,17 @@ export class AutoItemizePage {

/**
* Returns the Apply button inside a SuggestionBadge for a given field.
* Accepts the same field names as suggestionBadge().
*/
applyBadgeButton(field: 'amount' | 'date' | 'dueDate'): Locator {
const inputId = field === 'dueDate' ? 'due-date' : field;
applyBadgeButton(field: 'amount' | 'date' | 'dueDate' | 'invoiceNumber' | 'notes'): Locator {
let inputId: string;
if (field === 'dueDate') {
inputId = 'due-date';
} else if (field === 'invoiceNumber') {
inputId = 'invoice-number';
} else {
inputId = field;
}
return this.page
.locator(`#${inputId}`)
.locator('xpath=ancestor::div[contains(@class,"fieldRow") or contains(@class,"field")]')
Expand Down
Loading
Loading