feat(invoice-inbox): show receipt fields on receipts, invoice fields on invoices (#1426)
* feat(invoice-inbox): show receipt fields on receipts, invoice fields on invoices A kassakvitto has no due date, no OCR reference, no invoice number and no bankgiro: it is already paid. The rail rendered all four as empty boxes anyway, so a perfectly extracted receipt looked like a failed extraction. The date was mislabelled too: on a receipt the document date is the purchase date, not a "Fakturadatum". When extracted_data.documentKind is 'receipt' the rail now labels the date "Inköpsdatum" and folds the five invoice-only fields behind a quiet "Visa fakturafält (N)" link. Two rules keep it safe when the classification is wrong: a field holding a value is never hidden (a hybrid restaurangnota with an invoice number still shows it), and the fold is one click from being undone. Logic lives in lib/documents/inbox-field-visibility.ts rather than the component: this repo has no jsdom or testing-library, so display rules are only testable as a React-free module. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(invoice-inbox): reset the invoice-field fold when switching documents Review catch (PR Agent on #1426): showAllFields was component state with no per-item reset, and EditableFieldsList stays mounted across selections in the rail. Expanding "Visa fakturafält" on one receipt therefore left the invoice-only fields open on the next document, which reads as if that one carried them too. Reset alongside drafts and edit provenance in the existing itemId effect, and moved the declaration up next to the other state so it is defined above its first use rather than relying on hoisting. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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<Partial<Record<FieldKey, boolean>>>({})
|
||||
// Per-document fold for the invoice-only fields on a receipt.
|
||||
const [showAllFields, setShowAllFields] = useState(false)
|
||||
const timersRef = useRef<Partial<Record<FieldKey, ReturnType<typeof setTimeout>>>>({})
|
||||
// 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 (
|
||||
<div className="space-y-2">
|
||||
{FIELD_DEFS.map((f) => (
|
||||
{shownFields.map((f) => (
|
||||
<div key={f.key} className="flex flex-col gap-0.5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<label
|
||||
@@ -2497,6 +2515,15 @@ export function EditableFieldsList({
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{hiddenCount > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAllFields(true)}
|
||||
className="text-[11px] text-muted-foreground hover:text-foreground hover:underline pt-1"
|
||||
>
|
||||
Visa fakturafält ({hiddenCount})
|
||||
</button>
|
||||
)}
|
||||
{vatRows.length > 0 && (
|
||||
<div className="pt-2 border-t mt-3">
|
||||
<p className="text-[10px] uppercase tracking-wide text-muted-foreground/80 mb-1.5">
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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<string> = 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<Record<string, string>> = {
|
||||
'invoice.invoiceDate': 'Inköpsdatum',
|
||||
}
|
||||
|
||||
export interface VisibleField {
|
||||
key: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export function selectInboxFields<T extends VisibleField>(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 }
|
||||
}
|
||||
Reference in New Issue
Block a user