diff --git a/components/extensions/general/InvoiceInboxWorkspace.tsx b/components/extensions/general/InvoiceInboxWorkspace.tsx index 27d4a934..62075135 100644 --- a/components/extensions/general/InvoiceInboxWorkspace.tsx +++ b/components/extensions/general/InvoiceInboxWorkspace.tsx @@ -47,6 +47,7 @@ import { CAPABILITY } from '@/lib/entitlements/keys' import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry' import type { InboxChannelContext, InvoiceExtractionResult } from '@/types' import { renderChannelParticipant } from '@/lib/documents/channel-context-notes' +import { selectInboxFields } from '@/lib/documents/inbox-field-visibility' import BookDirectlyDialog from '@/components/extensions/general/BookDirectlyDialog' import NewSupplierInvoiceDialog from '@/components/supplier-invoices/NewSupplierInvoiceDialog' import BulkBookInboxDialog from '@/components/extensions/general/BulkBookInboxDialog' @@ -2334,6 +2335,8 @@ export function EditableFieldsList({ // from the extraction) and flips to user-verified once the user edits it: // mirrors the create form's AiFilledIndicator. Reset when switching items. const [edited, setEdited] = useState>>({}) + // Per-document fold for the invoice-only fields on a receipt. + const [showAllFields, setShowAllFields] = useState(false) const timersRef = useRef>>>({}) // Last-known server values per field. Used to detect when the server // normalises a value (currency upper-cased, whitespace trimmed) so we can @@ -2351,6 +2354,10 @@ export function EditableFieldsList({ setDrafts(seeded) lastServerRef.current = seeded setEdited({}) + // The "Visa fakturafält" fold is per document: without this, expanding + // it on one receipt leaves the invoice fields open on the next one, + // which reads as if that document had them too. + setShowAllFields(false) return () => { for (const t of Object.values(timersRef.current)) { if (t) clearTimeout(t) @@ -2465,9 +2472,20 @@ export function EditableFieldsList({ const vatRows = useMemo(() => data.vatBreakdown ?? [], [data.vatBreakdown]) + const { shown: shownFields, hiddenCount } = useMemo( + () => + selectInboxFields({ + documentKind: data.documentKind ?? null, + fields: FIELD_DEFS, + hasValue: (key) => (drafts[key as FieldKey] ?? '').trim() !== '', + showAll: showAllFields, + }), + [data, drafts, showAllFields] + ) + return (
- {FIELD_DEFS.map((f) => ( + {shownFields.map((f) => (
))} + {hiddenCount > 0 && ( + + )} {vatRows.length > 0 && (

diff --git a/lib/documents/__tests__/inbox-field-visibility.test.ts b/lib/documents/__tests__/inbox-field-visibility.test.ts new file mode 100644 index 00000000..96a92525 --- /dev/null +++ b/lib/documents/__tests__/inbox-field-visibility.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect } from 'vitest' +import { selectInboxFields } from '@/lib/documents/inbox-field-visibility' + +const FIELDS = [ + { key: 'supplier.name', label: 'Leverantör' }, + { key: 'totals.total', label: 'Totalt' }, + { key: 'totals.vatAmount', label: 'Moms' }, + { key: 'supplier.bankgiro', label: 'Bankgiro' }, + { key: 'supplier.plusgiro', label: 'Plusgiro' }, + { key: 'invoice.invoiceNumber', label: 'Fakturanr' }, + { key: 'invoice.paymentReference', label: 'OCR/Referens' }, + { key: 'invoice.invoiceDate', label: 'Fakturadatum' }, + { key: 'invoice.dueDate', label: 'Förfallodatum' }, +] + +const none = () => false +const keys = (r: { shown: { key: string }[] }) => r.shown.map((f) => f.key) +const labelOf = (r: { shown: { key: string; label: string }[] }, key: string) => + r.shown.find((f) => f.key === key)?.label + +describe('selectInboxFields', () => { + it('leaves invoices exactly as they are', () => { + for (const kind of ['supplier_invoice', 'government_letter', 'other', null, undefined]) { + const r = selectInboxFields({ documentKind: kind, fields: FIELDS, hasValue: none, showAll: false }) + expect(keys(r)).toEqual(FIELDS.map((f) => f.key)) + expect(r.hiddenCount).toBe(0) + expect(labelOf(r, 'invoice.invoiceDate')).toBe('Fakturadatum') + } + }) + + it('hides the five invoice-only fields on a receipt and counts them', () => { + const r = selectInboxFields({ documentKind: 'receipt', fields: FIELDS, hasValue: none, showAll: false }) + expect(r.hiddenCount).toBe(5) + expect(keys(r)).toEqual([ + 'supplier.name', + 'totals.total', + 'totals.vatAmount', + 'invoice.invoiceDate', + ]) + }) + + it('relabels the date as the purchase date on a receipt', () => { + const r = selectInboxFields({ documentKind: 'receipt', fields: FIELDS, hasValue: none, showAll: false }) + expect(labelOf(r, 'invoice.invoiceDate')).toBe('Inköpsdatum') + }) + + it('NEVER hides a field that holds a value, even on a receipt', () => { + // Misclassification, or a hybrid restaurangnota carrying an invoice + // number: data must never disappear behind a heuristic. + const r = selectInboxFields({ + documentKind: 'receipt', + fields: FIELDS, + hasValue: (k) => k === 'invoice.invoiceNumber', + showAll: false, + }) + expect(keys(r)).toContain('invoice.invoiceNumber') + expect(r.hiddenCount).toBe(4) + }) + + it('showAll restores every field, still relabelled', () => { + const r = selectInboxFields({ documentKind: 'receipt', fields: FIELDS, hasValue: none, showAll: true }) + expect(keys(r)).toEqual(FIELDS.map((f) => f.key)) + expect(r.hiddenCount).toBe(0) + expect(labelOf(r, 'invoice.invoiceDate')).toBe('Inköpsdatum') + }) + + it('does not mutate the caller’s field list', () => { + const copy = FIELDS.map((f) => ({ ...f })) + selectInboxFields({ documentKind: 'receipt', fields: copy, hasValue: none, showAll: true }) + expect(copy.find((f) => f.key === 'invoice.invoiceDate')?.label).toBe('Fakturadatum') + }) +}) diff --git a/lib/documents/inbox-field-visibility.ts b/lib/documents/inbox-field-visibility.ts new file mode 100644 index 00000000..046883f3 --- /dev/null +++ b/lib/documents/inbox-field-visibility.ts @@ -0,0 +1,61 @@ +/** + * Which extracted fields the Underlag rail should show for a document. + * + * A kassakvitto has no due date, no OCR reference, no invoice number and no + * bankgiro: it is already paid. Rendering those as four empty boxes made the + * rail read as "extraction failed" when in truth the concepts do not exist + * on the document. The date is mislabelled too: on a receipt the document + * date is the purchase date, not a "Fakturadatum". + * + * Two rules keep this safe when the AI classification is wrong: + * 1. A field that HAS a value is never hidden. + * 2. Hiding is reversible in one click (the caller renders a toggle), so + * a receipt that really does carry an invoice number stays reachable. + * + * React-free on purpose: this repo has no jsdom or testing-library, so + * display logic is tested here rather than through the component. + */ + +/** Concepts that exist on an invoice but not on a paid receipt. */ +export const INVOICE_ONLY_FIELD_KEYS: ReadonlySet = new Set([ + 'invoice.dueDate', + 'invoice.invoiceNumber', + 'invoice.paymentReference', + 'supplier.bankgiro', + 'supplier.plusgiro', +]) + +/** Labels that read wrong on a receipt. */ +export const RECEIPT_FIELD_LABELS: Readonly> = { + 'invoice.invoiceDate': 'Inköpsdatum', +} + +export interface VisibleField { + key: string + label: string +} + +export function selectInboxFields(opts: { + /** extracted_data.documentKind; anything but 'receipt' keeps today's list. */ + documentKind: string | null | undefined + fields: readonly T[] + /** True when the field currently holds a value (draft or extracted). */ + hasValue: (key: string) => boolean + /** User clicked "show invoice fields". */ + showAll: boolean +}): { shown: T[]; hiddenCount: number } { + const { documentKind, fields, hasValue, showAll } = opts + if (documentKind !== 'receipt') return { shown: [...fields], hiddenCount: 0 } + + const shown: T[] = [] + let hiddenCount = 0 + for (const field of fields) { + if (!showAll && INVOICE_ONLY_FIELD_KEYS.has(field.key) && !hasValue(field.key)) { + hiddenCount += 1 + continue + } + const label = RECEIPT_FIELD_LABELS[field.key] + shown.push(label ? { ...field, label } : field) + } + return { shown, hiddenCount } +}