diff --git a/app/(dashboard)/layout.tsx b/app/(dashboard)/layout.tsx index fbb7606a..cffba063 100644 --- a/app/(dashboard)/layout.tsx +++ b/app/(dashboard)/layout.tsx @@ -15,6 +15,7 @@ import { CompanyProvider } from '@/contexts/CompanyContext' import { getActiveCompanyId } from '@/lib/company/context' import { getBranding } from '@/lib/branding/service' import { ensureSandboxAgentProfile } from '@/lib/sandbox/ensure-agent' +import { countPendingOperations, countUnbookedTransactions } from '@/lib/worklist' import type { EntityType, CompanyRole, Team } from '@/types' /** @@ -176,8 +177,8 @@ export default async function DashboardLayout({ const [ { data: settings }, - { count: uncategorizedCount }, - { count: pendingOpsCount }, + uncategorizedCount, + pendingOpsCount, { data: agentProfileIdentity }, { data: userProfile }, ] = await Promise.all([ @@ -186,16 +187,11 @@ export default async function DashboardLayout({ .select('company_name, onboarding_complete, entity_type, is_sandbox') .eq('company_id', companyId) .single(), - supabase - .from('transactions') - .select('*', { count: 'exact', head: true }) - .eq('company_id', companyId) - .is('is_business', null), - supabase - .from('pending_operations') - .select('*', { count: 'exact', head: true }) - .eq('company_id', companyId) - .eq('status', 'pending'), + // Shared worklist predicates (lib/worklist) — the badge must show the + // same number as every other "att göra" surface. Notably this excludes + // is_ignored rows, which the old inline query here did not. + countUnbookedTransactions(supabase, companyId), + countPendingOperations(supabase, companyId), // Agent identity — name + avatar — surfaced on the FAB and chat // surfaces. Null when no agent_profile exists yet (banner CTA path). supabase @@ -275,8 +271,8 @@ export default async function DashboardLayout({ ('all') const { toast } = useToast() - const { dialogProps: deleteDialogProps, confirm: confirmDelete } = useDestructiveConfirm() + const { dialogProps: confirmDialogProps, confirm } = useDestructiveConfirm() // Bank transaction whose title is being edited (null = dialog closed). const [editTitleTarget, setEditTitleTarget] = useState(null) const supabase = createClient() @@ -212,7 +212,7 @@ export default function TransactionsPage() { // Computed lists const uncategorizedTransactions = transactions - .filter((t) => t.is_business === null && !exitingIds.has(t.id)) + .filter((t) => t.is_business === null && !t.is_ignored && !exitingIds.has(t.id)) .sort((a, b) => { const aHasMatch = a.potential_invoice || a.potential_supplier_invoice ? 1 : 0 const bHasMatch = b.potential_invoice || b.potential_supplier_invoice ? 1 : 0 @@ -296,7 +296,10 @@ export default function TransactionsPage() { .from('transactions') .select('*', { count: 'exact', head: true }) .eq('company_id', company.id) - .is('is_business', null), + .is('is_business', null) + // Same predicate as lib/worklist countUnbookedTransactions — ignored + // rows are handled, not pending. + .eq('is_ignored', false), ]) if (txError) { @@ -864,6 +867,76 @@ export default function TransactionsPage() { } } + async function handleIgnoreTransaction(tx: TransactionWithInvoice) { + // Mirrors BankReconciliationView's ignore flow: Ignorera is fully + // reversible, but the row vanishes immediately — confirmation before the + // write plus an Ångra toast gives two recovery affordances. The + // "Ignorerade transaktioner" card on Rapporter → Bankavstämning is the + // standing third. + const ok = await confirm({ + title: 'Ignorera transaktionen?', + description: `${tx.description} — ${formatCurrency(tx.amount, tx.currency)} (${formatDate(tx.date)}) försvinner från listan utan att bokföras. Använd bara för poster som inte är affärshändelser, t.ex. dubbletter eller överföringar mellan egna konton — riktiga köp och betalningar ska bokföras. Du kan återställa den under Bankavstämning när som helst.`, + confirmLabel: 'Ignorera', + cancelLabel: 'Avbryt', + variant: 'warning', + }) + if (!ok) return + + setTemplatePickerOpen(false) + try { + const res = await fetch(`/api/transactions/${tx.id}/ignore`, { method: 'POST' }) + const result = await res.json() + if (!res.ok || result.error) { + toast({ + title: 'Kunde inte ignorera transaktionen', + description: typeof result.error === 'string' ? result.error : undefined, + variant: 'destructive', + }) + return + } + setExitingIds((prev) => new Set(prev).add(tx.id)) + setTotalUncategorizedCount((prev) => Math.max(0, (prev ?? 1) - 1)) + setTimeout(() => { + setTransactions((prev) => + prev.map((t) => (t.id === tx.id ? { ...t, is_ignored: true } : t)) + ) + setExitingIds((prev) => { + const next = new Set(prev) + next.delete(tx.id) + return next + }) + }, 350) + toast({ + title: 'Transaktionen ignorerad', + description: `${tx.description} — ${formatCurrency(tx.amount, tx.currency)}`, + action: ( + void handleUnignoreTransaction(tx.id)}> + Ångra + + ), + }) + } catch { + toast({ title: 'Kunde inte ignorera transaktionen', variant: 'destructive' }) + } + } + + async function handleUnignoreTransaction(transactionId: string) { + try { + const res = await fetch(`/api/transactions/${transactionId}/ignore`, { method: 'DELETE' }) + const result = await res.json() + if (!res.ok || result.error) { + toast({ title: 'Kunde inte återställa transaktionen', variant: 'destructive' }) + return + } + setTransactions((prev) => + prev.map((t) => (t.id === transactionId ? { ...t, is_ignored: false } : t)) + ) + setTotalUncategorizedCount((prev) => (prev ?? 0) + 1) + } catch { + toast({ title: 'Kunde inte återställa transaktionen', variant: 'destructive' }) + } + } + async function handleConfirmInvoiceMatch(opts?: { force?: boolean expected_journal_entry_id?: string @@ -1247,7 +1320,7 @@ export default function TransactionsPage() { const transaction = transactions.find((t) => t.id === id) if (!transaction) return - const ok = await confirmDelete({ + const ok = await confirm({ title: 'Ta bort transaktion', description: `Är du säker på att du vill ta bort "${transaction.description}"? Åtgärden kan inte ångras.`, confirmLabel: 'Ta bort', @@ -1400,7 +1473,7 @@ export default function TransactionsPage() { async function handleBatchDelete() { const ids = Array.from(selectedIds) - const ok = await confirmDelete({ + const ok = await confirm({ title: `Ta bort ${ids.length} transaktioner?`, description: 'Åtgärden kan inte ångras.', confirmLabel: 'Ta bort', @@ -1960,6 +2033,16 @@ export default function TransactionsPage() { Matcha med faktura… )} + {templatePickerTransaction && ( + + )} - + , total } } + */ +export const GET = withRouteContext('worklist.counts', async (_request, ctx) => { + const { supabase, companyId } = ctx + const data = await getWorklistCounts(supabase, companyId) + return NextResponse.json({ data }) +}) diff --git a/lib/worklist/__tests__/aggregate.test.ts b/lib/worklist/__tests__/aggregate.test.ts new file mode 100644 index 00000000..430658d2 --- /dev/null +++ b/lib/worklist/__tests__/aggregate.test.ts @@ -0,0 +1,43 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import type { SupabaseClient } from '@supabase/supabase-js' + +vi.mock('../categories', () => ({ + countUnbookedTransactions: vi.fn().mockResolvedValue(4), + countInboxDocuments: vi.fn().mockResolvedValue(6), + countSuggestedMatches: vi.fn().mockResolvedValue(2), + countSupplierInvoicesAwaitingApproval: vi.fn().mockResolvedValue(1), + countVerifikatMissingDocument: vi.fn().mockResolvedValue(3), + countOverdueInvoices: vi.fn().mockResolvedValue(5), + countDeadlinesNeedingAction: vi.fn().mockResolvedValue(1), + countPendingOperations: vi.fn().mockResolvedValue(2), +})) + +import { getWorklistCounts } from '../aggregate' + +const supabase = {} as SupabaseClient + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('getWorklistCounts', () => { + it('aggregates every category', async () => { + const { counts } = await getWorklistCounts(supabase, 'company-1') + expect(counts).toEqual({ + book_transaction: 4, + inbox_document: 6, + suggested_match: 2, + supplier_invoice_approval: 1, + verifikat_missing_document: 3, + overdue_invoice: 5, + deadline_action: 1, + pending_operations: 2, + }) + }) + + it('excludes suggested_match from the total (subset of book_transaction)', async () => { + const { total } = await getWorklistCounts(supabase, 'company-1') + // 4 + 6 + 1 + 3 + 5 + 1 + 2 — without the 2 suggested matches. + expect(total).toBe(22) + }) +}) diff --git a/lib/worklist/__tests__/categories.test.ts b/lib/worklist/__tests__/categories.test.ts new file mode 100644 index 00000000..0c467f48 --- /dev/null +++ b/lib/worklist/__tests__/categories.test.ts @@ -0,0 +1,241 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import type { SupabaseClient } from '@supabase/supabase-js' +import { createQueuedMockSupabase } from '@/tests/helpers' +import { + countDeadlinesNeedingAction, + countInboxDocuments, + countOverdueInvoices, + countPendingOperations, + countSuggestedMatches, + countSupplierInvoicesAwaitingApproval, + countUnbookedTransactions, + countVerifikatMissingDocument, + listSuggestedMatches, +} from '../categories' + +const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase() +const supabase = mockSupabase as unknown as SupabaseClient +const COMPANY = 'company-1' + +beforeEach(() => { + vi.clearAllMocks() + reset() +}) + +describe('countUnbookedTransactions', () => { + it('returns the head count from transactions', async () => { + enqueue({ count: 4 }) + await expect(countUnbookedTransactions(supabase, COMPANY)).resolves.toBe(4) + expect(mockSupabase.from).toHaveBeenCalledWith('transactions') + }) + + it('soft-fails to 0 on query error', async () => { + enqueue({ error: { message: 'boom' } }) + await expect(countUnbookedTransactions(supabase, COMPANY)).resolves.toBe(0) + }) +}) + +describe('countInboxDocuments', () => { + it('counts only items whose document is still unlinked', async () => { + enqueue({ + data: [ + { id: 'i1', document_id: 'd1' }, + { id: 'i2', document_id: 'd2' }, + { id: 'i3', document_id: 'd3' }, + ], + }) + enqueue({ count: 2 }) // one of the three docs is already linked elsewhere + await expect(countInboxDocuments(supabase, COMPANY)).resolves.toBe(2) + expect(mockSupabase.from).toHaveBeenCalledWith('invoice_inbox_items') + expect(mockSupabase.from).toHaveBeenCalledWith('document_attachments') + }) + + it('returns 0 without a document query when no unconsumed items exist', async () => { + enqueue({ data: [] }) + await expect(countInboxDocuments(supabase, COMPANY)).resolves.toBe(0) + expect(mockSupabase.from).not.toHaveBeenCalledWith('document_attachments') + }) + + it('chunks the document id filter so large inboxes stay under URL limits', async () => { + // 200 deduped ids → two .in() chunks of 150 + 50, counts summed. + enqueue({ + data: Array.from({ length: 200 }, (_, i) => ({ + id: `item-${i}`, + document_id: `doc-${i}`, + })), + }) + enqueue({ count: 140 }) + enqueue({ count: 45 }) + await expect(countInboxDocuments(supabase, COMPANY)).resolves.toBe(185) + // 1 inbox query + 2 chunked document queries. + expect(mockSupabase.from).toHaveBeenCalledTimes(3) + }) + + it('soft-fails to 0 on query error', async () => { + enqueue({ error: { message: 'boom' } }) + await expect(countInboxDocuments(supabase, COMPANY)).resolves.toBe(0) + }) +}) + +describe('countVerifikatMissingDocument', () => { + it('counts posted document-requiring entries with neither document nor exemption', async () => { + // 6 posted entries: je-1 documented+exempt, je-2 documented, je-3 exempt + // → je-4, je-5, je-6 missing. + enqueue({ + data: [ + { id: 'je-1' }, + { id: 'je-2' }, + { id: 'je-3' }, + { id: 'je-4' }, + { id: 'je-5' }, + { id: 'je-6' }, + ], + }) + enqueue({ + data: [ + { journal_entry_id: 'je-1' }, + { journal_entry_id: 'je-1' }, // second doc on the same entry — still one entry + { journal_entry_id: 'je-2' }, + ], + }) + enqueue({ + data: [{ journal_entry_id: 'je-1' }, { journal_entry_id: 'je-3' }], + }) + await expect(countVerifikatMissingDocument(supabase, COMPANY)).resolves.toBe(3) + }) + + it('ignores documents attached to entries outside the document-requiring set', async () => { + // The doc on je-99 (e.g. a VAT-settlement entry) must not shrink the count. + enqueue({ data: [{ id: 'je-1' }] }) + enqueue({ data: [{ journal_entry_id: 'je-99' }] }) + enqueue({ data: [] }) + await expect(countVerifikatMissingDocument(supabase, COMPANY)).resolves.toBe(1) + }) + + it('soft-fails to 0 when a paginated read errors', async () => { + enqueue({ error: { message: 'boom' } }) + enqueue({ data: [] }) + enqueue({ data: [] }) + await expect(countVerifikatMissingDocument(supabase, COMPANY)).resolves.toBe(0) + }) + + it('soft-fails to 0 (never a silent partial) when pagination errors mid-stream', async () => { + // First page of entries is full (1000 = fetchAllRows page size), so a + // second page is requested and errors. fetchAllRows must throw — the + // count drops to a logged 0 rather than computing from a truncated set. + enqueue({ + data: Array.from({ length: 1000 }, (_, i) => ({ id: `je-${i}` })), + }) + enqueue({ data: [] }) // document_attachments page 1 + enqueue({ data: [] }) // exemptions page 1 + enqueue({ error: { message: 'mid-stream failure' } }) // entries page 2 + await expect(countVerifikatMissingDocument(supabase, COMPANY)).resolves.toBe(0) + }) +}) + +describe('simple head counts', () => { + it.each([ + ['countSuggestedMatches', countSuggestedMatches, 'transactions'], + ['countSupplierInvoicesAwaitingApproval', countSupplierInvoicesAwaitingApproval, 'supplier_invoices'], + ['countOverdueInvoices', countOverdueInvoices, 'invoices'], + ['countDeadlinesNeedingAction', countDeadlinesNeedingAction, 'deadlines'], + ['countPendingOperations', countPendingOperations, 'pending_operations'], + ] as const)('%s returns the count and targets the right table', async (_name, fn, table) => { + enqueue({ count: 3 }) + await expect(fn(supabase, COMPANY)).resolves.toBe(3) + expect(mockSupabase.from).toHaveBeenCalledWith(table) + }) +}) + +describe('listSuggestedMatches', () => { + it('maps invoice and supplier-invoice hints to confirmable rows', async () => { + enqueue({ + data: [ + { + id: 'tx-1', + date: '2026-06-01', + description: 'ICA BANKEN', + amount: 423, + currency: 'SEK', + potential_invoice_id: 'inv-1', + potential_supplier_invoice_id: null, + }, + { + id: 'tx-2', + date: '2026-05-30', + description: 'TELIA', + amount: -549, + currency: 'SEK', + potential_invoice_id: null, + potential_supplier_invoice_id: 'sinv-1', + }, + ], + }) + enqueue({ + data: [ + { id: 'inv-1', invoice_number: 'F-2026-12', total: 423, customer: { name: 'Kund AB' } }, + ], + }) + enqueue({ + data: [ + { + id: 'sinv-1', + supplier_invoice_number: 'TEL-99', + total: 549, + supplier: { name: 'Telia AB' }, + }, + ], + }) + + const matches = await listSuggestedMatches(supabase, COMPANY) + expect(matches).toEqual([ + { + transaction_id: 'tx-1', + transaction_date: '2026-06-01', + transaction_description: 'ICA BANKEN', + transaction_amount: 423, + transaction_currency: 'SEK', + kind: 'invoice', + candidate_id: 'inv-1', + candidate_number: 'F-2026-12', + counterparty_name: 'Kund AB', + candidate_total: 423, + }, + { + transaction_id: 'tx-2', + transaction_date: '2026-05-30', + transaction_description: 'TELIA', + transaction_amount: -549, + transaction_currency: 'SEK', + kind: 'supplier_invoice', + candidate_id: 'sinv-1', + candidate_number: 'TEL-99', + counterparty_name: 'Telia AB', + candidate_total: 549, + }, + ]) + }) + + it('drops rows whose hinted candidate no longer exists', async () => { + enqueue({ + data: [ + { + id: 'tx-1', + date: '2026-06-01', + description: 'X', + amount: 100, + currency: 'SEK', + potential_invoice_id: 'inv-gone', + potential_supplier_invoice_id: null, + }, + ], + }) + enqueue({ data: [] }) // invoice lookup finds nothing (deleted candidate) + await expect(listSuggestedMatches(supabase, COMPANY)).resolves.toEqual([]) + }) + + it('returns [] on transaction query error', async () => { + enqueue({ error: { message: 'boom' } }) + await expect(listSuggestedMatches(supabase, COMPANY)).resolves.toEqual([]) + }) +}) diff --git a/lib/worklist/aggregate.ts b/lib/worklist/aggregate.ts new file mode 100644 index 00000000..c9c376ae --- /dev/null +++ b/lib/worklist/aggregate.ts @@ -0,0 +1,67 @@ +import type { SupabaseClient } from '@supabase/supabase-js' +import type { WorklistCounts } from './types' +import { + countDeadlinesNeedingAction, + countInboxDocuments, + countOverdueInvoices, + countPendingOperations, + countSuggestedMatches, + countSupplierInvoicesAwaitingApproval, + countUnbookedTransactions, + countVerifikatMissingDocument, +} from './categories' + +/** + * All worklist counts in one round-trip burst. Every count is a cheap + * head-only query (categories.ts) and individually soft-fails to 0, so this + * is safe to call from layouts and server components on every render. + * + * `total` is the number of distinct actionable items: suggested_match is a + * fast path over transactions already counted in book_transaction, so it is + * excluded to avoid double-counting (see lib/worklist/types.ts). + */ +export async function getWorklistCounts( + supabase: SupabaseClient, + companyId: string, +): Promise { + const [ + bookTransaction, + inboxDocument, + suggestedMatch, + supplierInvoiceApproval, + verifikatMissingDocument, + overdueInvoice, + deadlineAction, + pendingOperations, + ] = await Promise.all([ + countUnbookedTransactions(supabase, companyId), + countInboxDocuments(supabase, companyId), + countSuggestedMatches(supabase, companyId), + countSupplierInvoicesAwaitingApproval(supabase, companyId), + countVerifikatMissingDocument(supabase, companyId), + countOverdueInvoices(supabase, companyId), + countDeadlinesNeedingAction(supabase, companyId), + countPendingOperations(supabase, companyId), + ]) + + return { + counts: { + book_transaction: bookTransaction, + inbox_document: inboxDocument, + suggested_match: suggestedMatch, + supplier_invoice_approval: supplierInvoiceApproval, + verifikat_missing_document: verifikatMissingDocument, + overdue_invoice: overdueInvoice, + deadline_action: deadlineAction, + pending_operations: pendingOperations, + }, + total: + bookTransaction + + inboxDocument + + supplierInvoiceApproval + + verifikatMissingDocument + + overdueInvoice + + deadlineAction + + pendingOperations, + } +} diff --git a/lib/worklist/categories.ts b/lib/worklist/categories.ts new file mode 100644 index 00000000..67f1f21d --- /dev/null +++ b/lib/worklist/categories.ts @@ -0,0 +1,387 @@ +/** + * Per-category worklist queries — the single owner of every pending-work + * predicate. Surfaces (dashboard, sidebar badges, /api/worklist, MCP tools) + * must call these instead of inlining their own Supabase queries; see + * lib/worklist/types.ts for each category's pending/done definition. + * + * Counts soft-fail to 0 with a logged error: a broken badge must never take + * down the dashboard layout or the home page. + */ + +import type { SupabaseClient } from '@supabase/supabase-js' +import { createLogger } from '@/lib/logger' +import { fetchAllRows } from '@/lib/supabase/fetch-all' +import type { SuggestedMatch } from './types' + +const log = createLogger('worklist') + +/** + * Journal-entry source types that require underlag (BFL 5 kap 7§). Source + * types representing system-generated entries (VAT settlement, year-end, + * currency revaluation, …) are exempt by omission. + */ +export const NEEDS_DOC_SOURCE_TYPES = [ + 'manual', + 'bank_transaction', + 'supplier_invoice_registered', + 'supplier_invoice_paid', + 'supplier_invoice_cash_payment', + 'import', +] as const + +/** + * Upper bound on the unconsumed-inbox scan in countInboxDocuments. An inbox + * with more than this many unhandled items is pathological; the count clamps + * there rather than scanning unbounded rows on every badge render. + */ +const INBOX_SCAN_CAP = 1000 + +/** + * Max ids per PostgREST .in() filter. Ids travel in the GET query string; + * 150 UUIDs ≈ 5.6 KB, comfortably under common 8 KB proxy URL limits. + */ +const IN_CLAUSE_CHUNK = 150 + +function logAndZero( + category: string, + companyId: string, + error: { message?: string } | null, +): number { + // companyId is a structured field so repeated failures can be correlated + // to a tenant in monitoring. + log.error(`worklist count failed: ${category}`, { companyId, reason: error?.message }) + return 0 +} + +/** + * Unbooked bank transactions — the canonical "att bokföra" predicate. + * All booking flows (incl. the bulk-book RPCs) set is_business = true, so + * is_business IS NULL is sufficient; is_ignored excludes the user's + * explicitly-suppressed rows. Served by the partial index + * idx_transactions_company_unbooked. + */ +export async function countUnbookedTransactions( + supabase: SupabaseClient, + companyId: string, +): Promise { + const { count, error } = await supabase + .from('transactions') + .select('id', { count: 'exact', head: true }) + .eq('company_id', companyId) + .is('is_business', null) + .eq('is_ignored', false) + if (error) return logAndZero('book_transaction', companyId, error) + return count ?? 0 +} + +/** + * Unconsumed inbox documents. Mirrors /api/documents/inbox-available: + * items with a file that have not become a supplier invoice, a journal + * entry, or a transaction match — and whose document is still unlinked + * (the stale-column backstop). + */ +export async function countInboxDocuments( + supabase: SupabaseClient, + companyId: string, +): Promise { + const { data: rows, error } = await supabase + .from('invoice_inbox_items') + .select('id, document_id') + .eq('company_id', companyId) + .not('document_id', 'is', null) + .is('created_supplier_invoice_id', null) + .is('created_journal_entry_id', null) + .is('matched_transaction_id', null) + .limit(INBOX_SCAN_CAP) + if (error) return logAndZero('inbox_document', companyId, error) + + const docIds = [ + ...new Set( + (rows ?? []) + .map((r) => r.document_id as string | null) + .filter((id): id is string => !!id), + ), + ] + if (docIds.length === 0) return 0 + + // PostgREST serialises .in() into the GET query string — chunk the id list + // so a large inbox can't push the URL past proxy limits (HTTP 414, which + // would silently zero the badge via the error branch). + let total = 0 + for (let i = 0; i < docIds.length; i += IN_CLAUSE_CHUNK) { + const { count, error: docError } = await supabase + .from('document_attachments') + .select('id', { count: 'exact', head: true }) + .eq('company_id', companyId) + .in('id', docIds.slice(i, i + IN_CLAUSE_CHUNK)) + .is('journal_entry_id', null) + .eq('is_current_version', true) + if (docError) return logAndZero('inbox_document', companyId, docError) + total += count ?? 0 + } + return total +} + +/** Shared predicate for transactions carrying a match hint. */ +const SUGGESTED_MATCH_OR = + 'potential_invoice_id.not.is.null,potential_supplier_invoice_id.not.is.null' + +/** Unbooked transactions with an invoice/supplier-invoice match hint. */ +export async function countSuggestedMatches( + supabase: SupabaseClient, + companyId: string, +): Promise { + const { count, error } = await supabase + .from('transactions') + .select('id', { count: 'exact', head: true }) + .eq('company_id', companyId) + .is('is_business', null) + .eq('is_ignored', false) + .or(SUGGESTED_MATCH_OR) + if (error) return logAndZero('suggested_match', companyId, error) + return count ?? 0 +} + +/** Supplier invoices awaiting approval ("attestera"). */ +export async function countSupplierInvoicesAwaitingApproval( + supabase: SupabaseClient, + companyId: string, +): Promise { + const { count, error } = await supabase + .from('supplier_invoices') + .select('id', { count: 'exact', head: true }) + .eq('company_id', companyId) + .eq('status', 'registered') + if (error) return logAndZero('supplier_invoice_approval', companyId, error) + return count ?? 0 +} + +/** + * Posted verifikat without underlag: posted entries of document-requiring + * source types that have neither a current-version document nor a + * journal_entry_no_doc_required exemption. + * + * Computed as an exact per-entry set difference (the home page previously + * subtracted set SIZES, which both let documents on non-document-requiring + * entries shrink the count and silently truncated at the PostgREST row cap). + * All three reads paginate via fetchAllRows; row volume is bounded by the + * company's posted-entry history (id-only columns). + */ +export async function countVerifikatMissingDocument( + supabase: SupabaseClient, + companyId: string, +): Promise { + try { + const [entries, docs, exemptions] = await Promise.all([ + fetchAllRows<{ id: string }>(({ from, to }) => + supabase + .from('journal_entries') + .select('id') + .eq('company_id', companyId) + .eq('status', 'posted') + .in('source_type', [...NEEDS_DOC_SOURCE_TYPES]) + .order('id') + .range(from, to), + ), + fetchAllRows<{ journal_entry_id: string }>(({ from, to }) => + supabase + .from('document_attachments') + .select('journal_entry_id') + .eq('company_id', companyId) + .eq('is_current_version', true) + .not('journal_entry_id', 'is', null) + .order('id') + .range(from, to), + ), + fetchAllRows<{ journal_entry_id: string }>(({ from, to }) => + supabase + .from('journal_entry_no_doc_required') + .select('journal_entry_id') + .eq('company_id', companyId) + .order('journal_entry_id') + .range(from, to), + ), + ]) + + const withDoc = new Set(docs.map((d) => d.journal_entry_id)) + const exempt = new Set(exemptions.map((e) => e.journal_entry_id)) + let missing = 0 + for (const entry of entries) { + if (!withDoc.has(entry.id) && !exempt.has(entry.id)) missing++ + } + return missing + } catch (err) { + return logAndZero( + 'verifikat_missing_document', + companyId, + err instanceof Error ? { message: err.message } : null, + ) + } +} + +/** Overdue customer invoices (not credited). */ +export async function countOverdueInvoices( + supabase: SupabaseClient, + companyId: string, +): Promise { + const { count, error } = await supabase + .from('invoices') + .select('id', { count: 'exact', head: true }) + .eq('company_id', companyId) + .eq('status', 'overdue') + .is('credited_invoice_id', null) + if (error) return logAndZero('overdue_invoice', companyId, error) + return count ?? 0 +} + +/** + * Deadlines needing attention — same predicate as + * lib/deadlines/status-engine.ts getDeadlinesNeedingAttention(), as a + * head-count so badges don't fetch rows. + */ +export async function countDeadlinesNeedingAction( + supabase: SupabaseClient, + companyId: string, +): Promise { + const { count, error } = await supabase + .from('deadlines') + .select('id', { count: 'exact', head: true }) + .eq('company_id', companyId) + .eq('is_completed', false) + .in('status', ['action_needed', 'overdue']) + if (error) return logAndZero('deadline_action', companyId, error) + return count ?? 0 +} + +/** Agent-staged operations awaiting review. */ +export async function countPendingOperations( + supabase: SupabaseClient, + companyId: string, +): Promise { + const { count, error } = await supabase + .from('pending_operations') + .select('id', { count: 'exact', head: true }) + .eq('company_id', companyId) + .eq('status', 'pending') + if (error) return logAndZero('pending_operations', companyId, error) + return count ?? 0 +} + +interface SuggestedMatchTxRow { + id: string + date: string + description: string | null + amount: number + currency: string | null + potential_invoice_id: string | null + potential_supplier_invoice_id: string | null +} + +/** + * Suggested transaction↔invoice matches with enough candidate context for a + * one-click confirm row. Confirm endpoints: + * kind 'invoice' → POST /api/transactions/{id}/match-invoice + * kind 'supplier_invoice' → POST /api/transactions/{id}/match-supplier-invoice + */ +export async function listSuggestedMatches( + supabase: SupabaseClient, + companyId: string, + limit = 20, +): Promise { + const { data: txRows, error } = await supabase + .from('transactions') + .select( + 'id, date, description, amount, currency, potential_invoice_id, potential_supplier_invoice_id', + ) + .eq('company_id', companyId) + .is('is_business', null) + .eq('is_ignored', false) + .or(SUGGESTED_MATCH_OR) + .order('date', { ascending: false }) + .limit(limit) + if (error) { + log.error('worklist listSuggestedMatches failed', { reason: error.message }) + return [] + } + + const txs = (txRows ?? []) as SuggestedMatchTxRow[] + const invoiceIds = txs.map((t) => t.potential_invoice_id).filter((x): x is string => !!x) + const supplierInvoiceIds = txs + .map((t) => t.potential_supplier_invoice_id) + .filter((x): x is string => !!x) + + const [invoiceRes, supplierRes] = await Promise.all([ + invoiceIds.length > 0 + ? supabase + .from('invoices') + .select('id, invoice_number, total, customer:customers(name)') + .eq('company_id', companyId) + .in('id', invoiceIds) + : Promise.resolve({ data: [], error: null }), + supplierInvoiceIds.length > 0 + ? supabase + .from('supplier_invoices') + .select('id, supplier_invoice_number, total, supplier:suppliers(name)') + .eq('company_id', companyId) + .in('id', supplierInvoiceIds) + : Promise.resolve({ data: [], error: null }), + ]) + + type CandidateRow = { + id: string + invoice_number?: string | null + supplier_invoice_number?: string | null + total: number | null + customer?: { name: string | null } | null + supplier?: { name: string | null } | null + } + const invoiceById = new Map( + ((invoiceRes.data ?? []) as unknown as CandidateRow[]).map((r) => [r.id, r]), + ) + const supplierById = new Map( + ((supplierRes.data ?? []) as unknown as CandidateRow[]).map((r) => [r.id, r]), + ) + + const matches: SuggestedMatch[] = [] + for (const tx of txs) { + const base = { + transaction_id: tx.id, + transaction_date: tx.date, + transaction_description: tx.description ?? '', + transaction_amount: tx.amount, + transaction_currency: tx.currency ?? 'SEK', + } + // Mirror the transactions page: an invoice hint wins over a supplier hint + // when both are present (income matches are rarer and higher-signal). + const invoice = tx.potential_invoice_id + ? invoiceById.get(tx.potential_invoice_id) + : undefined + if (invoice) { + matches.push({ + ...base, + kind: 'invoice', + candidate_id: invoice.id, + candidate_number: invoice.invoice_number ?? null, + counterparty_name: invoice.customer?.name ?? null, + candidate_total: invoice.total ?? null, + }) + continue + } + const supplierInvoice = tx.potential_supplier_invoice_id + ? supplierById.get(tx.potential_supplier_invoice_id) + : undefined + if (supplierInvoice) { + matches.push({ + ...base, + kind: 'supplier_invoice', + candidate_id: supplierInvoice.id, + candidate_number: supplierInvoice.supplier_invoice_number ?? null, + counterparty_name: supplierInvoice.supplier?.name ?? null, + candidate_total: supplierInvoice.total ?? null, + }) + } + // Hint pointing at a deleted/foreign candidate → drop the row rather + // than render an unconfirmable suggestion. + } + return matches +} diff --git a/lib/worklist/index.ts b/lib/worklist/index.ts new file mode 100644 index 00000000..a905e1e8 --- /dev/null +++ b/lib/worklist/index.ts @@ -0,0 +1,3 @@ +export * from './types' +export * from './categories' +export * from './aggregate' diff --git a/lib/worklist/types.ts b/lib/worklist/types.ts new file mode 100644 index 00000000..27f79996 --- /dev/null +++ b/lib/worklist/types.ts @@ -0,0 +1,106 @@ +/** + * Worklist — the unified "Att göra" pending-work model. + * + * One source of truth for what the user still has to do, shared by the + * dashboard "Att göra" section, the sidebar badges, and (eventually) the + * MCP list tools. Every surface that shows a pending-work count MUST read + * it from lib/worklist so the numbers can never diverge — divergent counts + * are exactly the "vampire transactions" problem this module exists to fix. + * + * Each category documents its "done" condition: the status field or link + * whose write makes an item drop out of the count, everywhere, at once. + */ + +export const WORKLIST_CATEGORIES = [ + /** + * Unbooked bank transactions ("N st att bokföra"). + * Pending: is_business IS NULL AND is_ignored = false. + * Done: any booking flow (categorize, match-invoice, bulk-book RPC, + * manual booking) sets is_business = true — including the + * multi-tx flows, whose RPCs set is_business on every linked tx — + * or the user ignores the transaction (is_ignored = true). + * This is the canonical "unbooked" predicate. Do NOT count bare + * journal_entry_id IS NULL: multi-allocation and bulk-booked transactions + * keep journal_entry_id NULL (see lib/transactions/is-booked.ts). + */ + 'book_transaction', + /** + * Unconsumed documents in the inbox ("N st underlag att hantera"). + * Pending: invoice_inbox_items with a document and no + * created_supplier_invoice_id / created_journal_entry_id / + * matched_transaction_id, whose document is still unlinked. + * Done: any of those three columns gets stamped (match, book-direct, + * supplier-invoice conversion) or the document is linked to a + * journal entry. Mirrors /api/documents/inbox-available. + */ + 'inbox_document', + /** + * Suggested transaction↔invoice matches awaiting one-click confirm. + * Pending: unbooked transactions (see book_transaction) carrying a + * potential_invoice_id or potential_supplier_invoice_id hint. + * Done: the match is confirmed (booking clears is_business) or the + * hint column is cleared. NOTE: a subset of book_transaction — + * excluded from `total` to avoid double-counting. + */ + 'suggested_match', + /** + * Supplier invoices awaiting approval ("attestera"). + * Pending: supplier_invoices.status = 'registered'. + * Done: status moves to approved/paid/credited/…. + */ + 'supplier_invoice_approval', + /** + * Posted verifikat without underlag (BFL 5 kap 7§ documentation gap). + * Pending: posted journal_entries of document-requiring source types with + * no current-version document_attachments row and no + * journal_entry_no_doc_required exemption. + * Done: a document is linked or an exemption is recorded. + */ + 'verifikat_missing_document', + /** + * Overdue customer invoices ("förfallna kundfakturor"). + * Pending: invoices.status = 'overdue', not credited. + * Done: paid/credited (status leaves 'overdue'). + */ + 'overdue_invoice', + /** + * Tax/VAT deadlines needing attention. + * Pending: deadlines.is_completed = false AND status IN + * ('action_needed', 'overdue') — same predicate as + * lib/deadlines/status-engine.ts getDeadlinesNeedingAttention(). + * Done: submitted/confirmed (is_completed or status transition). + */ + 'deadline_action', + /** + * Agent-staged operations awaiting review ("Granskning"). + * Pending: pending_operations.status = 'pending'. + * Done: committed or rejected. + */ + 'pending_operations', +] as const + +export type WorklistCategory = (typeof WORKLIST_CATEGORIES)[number] + +export interface WorklistCounts { + counts: Record + /** + * Distinct actionable items. Excludes suggested_match, which is a fast + * path over transactions already counted in book_transaction. + */ + total: number +} + +/** A transaction↔invoice match suggestion, ready for one-click confirm. */ +export interface SuggestedMatch { + transaction_id: string + transaction_date: string + transaction_description: string + transaction_amount: number + transaction_currency: string + /** Which match endpoint confirms it: match-invoice vs match-supplier-invoice. */ + kind: 'invoice' | 'supplier_invoice' + candidate_id: string + candidate_number: string | null + counterparty_name: string | null + candidate_total: number | null +} diff --git a/supabase/migrations/20260617120000_worklist_indexes.sql b/supabase/migrations/20260617120000_worklist_indexes.sql new file mode 100644 index 00000000..0393f6fc --- /dev/null +++ b/supabase/migrations/20260617120000_worklist_indexes.sql @@ -0,0 +1,20 @@ +-- Worklist (Att göra) badge counts run on every dashboard render via the +-- layout, so the two hottest predicates get purpose-built indexes: +-- +-- 1. Unbooked bank transactions (lib/worklist countUnbookedTransactions): +-- company_id WHERE is_business IS NULL AND is_ignored = false. The +-- existing idx_transactions_company_id scans every row of the company; +-- this partial index holds only the (small, shrinking) inbox set. +-- +-- 2. invoice_inbox_items is only indexed by user_id (from before the +-- multi-tenant refactor), but every core read — inbox-available, the +-- worklist count — filters by company_id. + +CREATE INDEX IF NOT EXISTS idx_transactions_company_unbooked + ON public.transactions (company_id) + WHERE is_business IS NULL AND is_ignored = false; + +CREATE INDEX IF NOT EXISTS idx_invoice_inbox_items_company_created + ON public.invoice_inbox_items (company_id, created_at DESC); + +NOTIFY pgrst, 'reload schema';