feat(inbox): document-type badge and filter, +lev/+ver plus-addressing (#2129) (#2148)

* feat(inbox): document-type badge and filter, +lev/+ver plus-addressing (#2129)

Phase 1: every inbox row shows its document kind (Kvitto, Leverantorsfaktura, Myndighetsbrev, Ovrigt) from the existing AI documentKind, and a second menu next to the status filter narrows the list to leverantorsfakturor or underlag. Pure predicate in lib/documents/inbox-kind.ts with tests.

Phase 2: the shared inbox address accepts RFC 5233 plus-addressing. The webhook splits the local part at the first + and looks up the base, so <local>+anything@ now reaches the company instead of 404ing. +lev and +ver land in the new nullable invoice_inbox_items.kind_hint column (CHECK supplier_invoice | receipt), threaded through EmailMeta into both inbox inserts and returned by GET /items. kind_hint wins over documentKind for the badge and the filter and survives re-extraction because it is a column. The sources panel shows both tagged addresses with a one-line hint (sv + en).

Tests: filter predicate per kind and null; parser and tag mapping; webhook routes +LEV and an unknown tag; pg test pins the CHECK and NULL default.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hzv2Z2eCq8iJAAe8XC1hNr

* fix(inbox): honest empty state under a type filter, detail pane shares the row's kind resolution

Skeptic findings on #2148: with a type filter narrowing 'Att göra' to zero the empty state claimed 'allt är bearbetat' while the status trigger still counted pending rows; it now says no items of that type are here (sv + en). The fields rail printed the AI documentKind only, so a +lev hint could disagree with the row badge; it now uses resolveInboxKind like the list.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hzv2Z2eCq8iJAAe8XC1hNr

* fix(inbox): keep the type-filter empty state off purchase lists, carry kind_hint onto rejected attachment rows

CodeRabbit on #2148: the purchase lists (Saknar underlag, Hämta från portal) ignore the type menu, so a leftover kind filter must not pick their empty-state copy. A rejected attachment (unsupported MIME, too large) now keeps the sender's +lev / +ver hint on its error row like every other inbox insert; the allowlist test covers it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hzv2Z2eCq8iJAAe8XC1hNr

* fix(inbox): set the +lev/+ver kind hint only when the shared address resolved the company

CodeRabbit on #2148: the hint was computed before recipient resolution, so a tag on an unknown or retired shared address could ride along onto a custom-domain match. It is now assigned inside the active shared-inbox branch only; regression test covers the multi-recipient case.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hzv2Z2eCq8iJAAe8XC1hNr

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-09-02 08:50:42 +02:00
committed by GitHub
co-authored by Claude Fable 5.1
parent 4c76fb10d7
commit 867767a22f
14 changed files with 644 additions and 47 deletions
@@ -66,6 +66,12 @@ import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registr
import type { InboxChannelContext, InvoiceExtractionResult, InboxItemSource } from '@/types'
import { renderChannelParticipant } from '@/lib/documents/channel-context-notes'
import { selectInboxFields } from '@/lib/documents/inbox-field-visibility'
import {
matchesInboxKindFilter,
resolveInboxKind,
INBOX_KIND_FILTERS,
type InboxKindFilter,
} from '@/lib/documents/inbox-kind'
import BookDirectlyDialog from '@/components/extensions/general/BookDirectlyDialog'
import NewSupplierInvoiceDialog from '@/components/supplier-invoices/NewSupplierInvoiceDialog'
import BulkBookInboxDialog from '@/components/extensions/general/BulkBookInboxDialog'
@@ -171,6 +177,10 @@ interface InboxItem {
email_body_text: string | null
document_id: string | null
extracted_data: InvoiceExtractionResult | null
// Sender-declared kind from the +lev / +ver plus-address tag. A column, so
// it survives re-extraction; wins over extracted_data.documentKind for the
// row badge and the type filter. Absent on client-side placeholders.
kind_hint?: 'supplier_invoice' | 'receipt' | null
matched_supplier_id: string | null
matched_transaction_id: string | null
created_supplier_invoice_id: string | null
@@ -214,6 +224,15 @@ interface InboxAddress {
status: string
}
// `acme-x7f2@inbox.example` + 'lev' → `acme-x7f2+lev@inbox.example`. The
// webhook splits the local part at the first `+` and looks up what is before
// it, so the tag never changes which company the mail reaches.
function plusAddress(address: string, tag: string): string {
const at = address.indexOf('@')
if (at === -1) return address
return `${address.slice(0, at)}+${tag}${address.slice(at)}`
}
// How far the underlag behind the selected row got.
//
// `none` is the only state that may claim "Inget underlag bifogat": it means the
@@ -407,6 +426,9 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
const [filter, setFilter] = useState<
'todo' | 'linked' | 'booked' | 'error' | 'all' | 'missing' | 'portal'
>('todo')
// Document-type filter (#2129): leverantörsfakturor vs underlag, on top of
// the status filter. Not persisted, same as the status filter.
const [kindFilter, setKindFilter] = useState<InboxKindFilter>('all')
const [searchTerm, setSearchTerm] = useState('')
// Bulk selection. Items linked to a supplier invoice are skipped at delete
// time (server returns 409); we still allow them to be selected so the
@@ -755,8 +777,7 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
)
}, [portalPurchases, otherPurchases, filter, searchTerm])
const filteredItems = useMemo(() => {
const term = searchTerm.trim().toLowerCase()
const statusFilteredItems = useMemo(() => {
if (filter === 'missing' || filter === 'portal') return []
return items.filter((item) => {
// Status filter. "todo" is the active inbox: everything except booked.
@@ -766,6 +787,39 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
if (filter === 'booked' && status !== 'booked') return false
if (filter === 'error' && status !== 'error') return false
// 'all' → no status narrowing
return true
})
}, [items, filter])
// Per-kind counts for the type menu, over the status-filtered list so the
// numbers match what picking an entry would show.
const kindCounts = useMemo(() => {
const counts: Record<InboxKindFilter, number> = {
all: statusFilteredItems.length,
supplier_invoice: 0,
underlag: 0,
}
for (const item of statusFilteredItems) {
const kind = resolveInboxKind(item)
if (matchesInboxKindFilter(kind, 'supplier_invoice')) counts.supplier_invoice += 1
else if (matchesInboxKindFilter(kind, 'underlag')) counts.underlag += 1
}
return counts
}, [statusFilteredItems])
// The type menu only earns its row once something is classified (or the
// user has already narrowed): an inbox of unclassified rows has nothing to
// split.
const showKindFilter =
filter !== 'missing' &&
filter !== 'portal' &&
(kindFilter !== 'all' || kindCounts.supplier_invoice > 0 || kindCounts.underlag > 0)
const filteredItems = useMemo(() => {
const term = searchTerm.trim().toLowerCase()
return statusFilteredItems.filter((item) => {
// Type filter: sender hint first, then the AI classification.
if (!matchesInboxKindFilter(resolveInboxKind(item), kindFilter)) return false
// Search filter: supplier name, email subject/from, placeholder filename
if (term === '') return true
@@ -780,7 +834,7 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
.toLowerCase()
return haystack.includes(term)
})
}, [items, filter, searchTerm])
}, [statusFilteredItems, kindFilter, searchTerm])
// ── Selection ──────────────────────────────────────────────
@@ -1475,6 +1529,15 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
<Mail className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<div className="min-w-0 flex-1">
<span className="tabular-nums">{inboxAddress.address}</span>
{/* Plus-addressing (#2129): the sender sorts the mail by
writing +lev or +ver before the @. Both variants spelled
out, since a tag is easier to copy than to construct. */}
<p className="mt-1 text-muted-foreground break-all">
{t('address_plus_hint', {
lev: plusAddress(inboxAddress.address, 'lev'),
ver: plusAddress(inboxAddress.address, 'ver'),
})}
</p>
</div>
<InboxAddressBar
address={inboxAddress.address}
@@ -1612,6 +1675,48 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
))}
</DropdownMenuContent>
</DropdownMenu>
{/* Document type (#2129): the Fortnox-style split between
leverantörsfakturor and bokföringsunderlag, as a second
menu in the same shape as the status one. */}
{showKindFilter && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="w-full justify-between h-8 px-2.5 text-xs font-normal"
>
<span className="flex items-center gap-1.5 min-w-0">
<span className="truncate">{t(`kind_filter_${kindFilter}`)}</span>
<span className="tabular-nums text-muted-foreground">
{kindCounts[kindFilter]}
</span>
</span>
<ChevronDown className="h-3.5 w-3.5 opacity-60 shrink-0" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-[--radix-dropdown-menu-trigger-width]">
{INBOX_KIND_FILTERS.map((key) => (
<DropdownMenuItem
key={key}
onSelect={() => setKindFilter(key)}
className="justify-between text-xs"
>
<span className="flex items-center gap-2">
<Check
className={cn(
'h-3.5 w-3.5',
kindFilter === key ? 'opacity-100' : 'opacity-0',
)}
/>
{t(`kind_filter_${key}`)}
</span>
<span className="tabular-nums text-muted-foreground">{kindCounts[key]}</span>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
)}
{selectedIds.size > 0 && (
@@ -1736,13 +1841,19 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
beside it reads 50. */}
{searchTerm.trim() !== ''
? `Inga träffar på ”${searchTerm.trim()}”.`
: filter === 'todo'
? 'Inget att åtgärda; allt är bearbetat.'
: filter === 'portal'
? 'Inga köp väntar på en faktura från en portal.'
: filter === 'missing'
? 'Varje köp har sitt underlag.'
: 'Inga poster matchar filtret.'}
: kindFilter !== 'all' && filter !== 'missing' && filter !== 'portal'
// Same trap as the search term: "allt är bearbetat" would be
// false while the status trigger above still counts rows the
// type menu is hiding. Purchase lists ignore the type menu,
// so a leftover kind filter must not speak for them.
? t('empty_no_kind_hits')
: filter === 'todo'
? 'Inget att åtgärda; allt är bearbetat.'
: filter === 'portal'
? 'Inga köp väntar på en faktura från en portal.'
: filter === 'missing'
? 'Varje köp har sitt underlag.'
: 'Inga poster matchar filtret.'}
</div>
) : (
<ul>
@@ -2145,6 +2256,7 @@ function InboxRow({
const supplierName = pickSupplierName(item)
const invoiceDate = pickInvoiceDate(item)
const isPlaceholder = !!item.isPlaceholder
const kind = resolveInboxKind(item)
const status = deriveInboxStatus(item)
const isErrored = status === 'error'
const isBooked = status === 'booked'
@@ -2246,28 +2358,34 @@ function InboxRow({
<div className="flex items-center justify-between gap-2 text-xs text-muted-foreground">
{isPlaceholder ? (
<span className="italic">Tolkar dokument med AI…</span>
) : isExtracting ? (
) : (
<span className="flex items-center gap-1.5 min-w-0">
<Badge variant="outline" className="font-normal">
<Loader2 className="h-2.5 w-2.5 mr-1 animate-spin" />
{t('processing_chip')}
</Badge>
{receivedMeta}
</span>
) : item.extraction_skipped || hasUnansweredQuestion ? (
<span className="flex items-center gap-1.5 min-w-0">
{item.extraction_skipped && (
<Badge variant="outline" className="font-normal">Inte AI-tolkad</Badge>
)}
{hasUnansweredQuestion && (
<Badge variant="outline" className="font-normal text-attn border-attn/40">
{t('wa_question_badge')}
{/* Document kind (#2129): sender's +lev / +ver hint first, then
the AI classification. Nothing when neither is known. */}
{kind && (
<Badge variant="outline" className="font-normal shrink-0">
{t(`doc_kind_${kind}`)}
</Badge>
)}
{isExtracting ? (
<Badge variant="outline" className="font-normal">
<Loader2 className="h-2.5 w-2.5 mr-1 animate-spin" />
{t('processing_chip')}
</Badge>
) : (
<>
{item.extraction_skipped && (
<Badge variant="outline" className="font-normal">Inte AI-tolkad</Badge>
)}
{hasUnansweredQuestion && (
<Badge variant="outline" className="font-normal text-attn border-attn/40">
{t('wa_question_badge')}
</Badge>
)}
</>
)}
{receivedMeta}
</span>
) : (
receivedMeta
)}
{!isPlaceholder && amount != null && (
<span className="tabular-nums shrink-0">
@@ -2769,6 +2887,7 @@ function FieldsRail({
const hasAi = useCapability(CAPABILITY.ai)
const { appName } = useBranding()
const data = item.extracted_data
const resolvedKind = resolveInboxKind(item)
const [proposal, setProposal] = useState<SuggestedBooking | null>(null)
const [editOpen, setEditOpen] = useState(false)
// A proposal belongs to one item; carrying it to the next would offer the
@@ -2930,16 +3049,18 @@ function FieldsRail({
{/* AI classification: what kind of document this is and how it was
paid. Read-only context above the editable fields; absent for
extractions from before the fields existed. */}
{(data?.documentKind ||
{(resolvedKind ||
data?.payment?.method ||
data?.pages ||
(data?.totals?.total == null &&
(data?.prominentAmounts ?? []).some((a) => Number.isFinite(a.amount) && a.amount !== 0))) && (
<div className="border-b px-4 py-3 text-xs space-y-1">
{data?.documentKind && (
{/* Same resolution as the list row (sender's +lev / +ver hint first,
then the AI), so the pane never contradicts the badge. */}
{resolvedKind && (
<div className="flex gap-2">
<span className="text-muted-foreground w-14 shrink-0">{t('doc_kind_label')}</span>
<span>{t(`doc_kind_${data.documentKind}`)}</span>
<span>{t(`doc_kind_${resolvedKind}`)}</span>
</div>
)}
{data?.payment?.method && (