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 ─────────────────────────────────────── /**