From 7a49aec0c3eb83a7a8a003d17a193e432dc5e479 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Tue, 11 Aug 2026 12:33:58 +0200 Subject: [PATCH] feat(inbox): show the purchases that have no underlag (#1518) The page has always listed documents, so a purchase with no document at all could not appear on it. The one thing a user most wants from a document inbox, "what am I still missing", was the one thing it could not answer. A "Saknar underlag" filter now lists them, from the endpoint added alongside. Selecting one shows the purchase and, when the directory knows the supplier, a link to where the invoice lives. We never log in for anyone: knowing where it is costs no password and is most of the value. The pill appears only when there is something behind it. A company that keeps every receipt should not be shown a permanent empty accusation. Purchases live beside `items` rather than inside it. Widening InboxItem to cover a bank row would push a null document, a null extraction and a null status through every consumer of that type, to describe a row that never becomes an inbox item. The middle pane says why there is no file instead of rendering an empty frame, and the two selections clear each other: the panes show one kind of row at a time, so a stale selection from the other kind would outlive its list. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) --- .../general/InvoiceInboxWorkspace.tsx | 235 ++++++++++++++++-- 1 file changed, 217 insertions(+), 18 deletions(-) diff --git a/components/extensions/general/InvoiceInboxWorkspace.tsx b/components/extensions/general/InvoiceInboxWorkspace.tsx index 0d9d560f..f8d79a58 100644 --- a/components/extensions/general/InvoiceInboxWorkspace.tsx +++ b/components/extensions/general/InvoiceInboxWorkspace.tsx @@ -30,6 +30,8 @@ import { ArrowRight, Plus, Link2, + ExternalLink, + FileQuestion, Search, Circle, X, @@ -249,7 +251,9 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) { // Defaults to 'todo': the active inbox (everything not yet booked), so // booked underlag drop out of the default view while attached-but-unbooked // ones stay visible. - const [filter, setFilter] = useState<'todo' | 'linked' | 'booked' | 'error' | 'all'>('todo') + // 'missing' is the odd one out: it lists bank purchases, not inbox items, so + // the list and both panes branch on it. + const [filter, setFilter] = useState<'todo' | 'linked' | 'booked' | 'error' | 'all' | 'missing'>('todo') 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 @@ -437,6 +441,33 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) { // ── List filter + search (client-side over the fetched list) ─ + // Purchases with no underlag at all. They are not inbox items and never + // become them, so they live beside `items` rather than inside it: widening + // InboxItem to cover a bank row would put a null document, a null extraction + // and a null status through every consumer of that type. + const [purchases, setPurchases] = useState([]) + const [selectedPurchaseId, setSelectedPurchaseId] = useState(null) + + const fetchPurchases = useCallback(async () => { + try { + const res = await fetch('/api/extensions/ext/invoice-inbox/purchases') + if (!res.ok) return + const json = (await res.json()) as { data: { purchases: PurchaseWithoutUnderlag[] } } + setPurchases(json.data.purchases ?? []) + } catch { + // A missing count is better than an error beside the user's documents. + } + }, []) + + useEffect(() => { + void fetchPurchases() + }, [fetchPurchases]) + + const selectedPurchase = useMemo( + () => purchases.find((p) => p.id === selectedPurchaseId) ?? null, + [purchases, selectedPurchaseId], + ) + // Per-status counts for the filter pills. Computed once over the full list. const statusCounts = useMemo(() => { const counts = { todo: 0, linked: 0, booked: 0, error: 0, all: items.length } @@ -458,15 +489,29 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) { { key: 'linked', label: 'Kopplade', count: statusCounts.linked }, { key: 'booked', label: 'Bokförda', count: statusCounts.booked }, ] + // Only worth a pill when there is something behind it: a company that + // keeps every receipt should not be shown a permanent empty accusation. + if (purchases.length > 0 || filter === 'missing') { + list.push({ key: 'missing', label: 'Saknar underlag', count: purchases.length }) + } if (statusCounts.error > 0 || filter === 'error') { list.push({ key: 'error', label: 'Fel', count: statusCounts.error }) } list.push({ key: 'all', label: 'Alla', count: statusCounts.all }) return list - }, [statusCounts, filter]) + }, [statusCounts, filter, purchases.length]) + + const filteredPurchases = useMemo(() => { + const term = searchTerm.trim().toLowerCase() + if (term === '') return purchases + return purchases.filter((p) => + [p.merchant_name, p.description].some((v) => v?.toLowerCase().includes(term)), + ) + }, [purchases, searchTerm]) const filteredItems = useMemo(() => { const term = searchTerm.trim().toLowerCase() + if (filter === 'missing') return [] return items.filter((item) => { // Status filter. "todo" is the active inbox: everything except booked. const status = deriveInboxStatus(item) @@ -540,6 +585,7 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) { const handleSelect = useCallback(async (id: string) => { setSelectedId(id) + setSelectedPurchaseId(null) setSelected(null) setDocUrl(null) setDocMime(null) @@ -922,7 +968,13 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) { + + ) +} + +function PurchaseRail({ purchase }: { purchase: PurchaseWithoutUnderlag }) { + return ( +
+
+

+ {purchase.merchant_name || purchase.description || 'Okänt köp'} +

+

Köp utan underlag

+
+ +
+
+
Datum
+
{formatDate(purchase.date)}
+
+
+
Belopp
+
+ {formatCurrency(Math.abs(purchase.amount), purchase.currency ?? undefined)} +
+
+ {purchase.description && ( +
+
Kontotext
+
{purchase.description}
+
+ )} +
+ + {purchase.portal ? ( +
+

+ {purchase.portal.note ?? + `${purchase.portal.vendor} skickar ingen fil. Fakturan ligger bakom en inloggning.`} +

+ {/* We never log in for anyone. Knowing where the invoice is costs no + password and is most of the value. */} + +
+ ) : ( +

+ Vi hittade ingen bilaga och känner inte till någon portal för den här leverantören. Ladda upp + kvittot här, eller vidarebefordra det till inkorgsadressen. +

+ )} +
+ ) +} + // ── Proposed kontering ─────────────────────────────────────── /**