feat(worklist): unified pending-work counts + vampire-transaction fix (#673)
* feat(worklist): unified pending-work counts in lib/worklist One source of truth for every "Att göra" count, shared by the sidebar badges, the home page (PR 3), and eventually the MCP list tools. Each category documents its pending/done predicate in types.ts; counts are cheap head-only queries that soft-fail to 0 so a broken badge can never take down a layout. listSuggestedMatches() returns confirmable transaction↔invoice match rows for the upcoming one-click confirm UI. GET /api/worklist/counts exposes the aggregate for client refetch. Partial index on transactions (company_id WHERE unbooked, not ignored) — the badge predicate runs on every dashboard render — and a company-scoped index on invoice_inbox_items, which was only indexed by user_id from before the multi-tenant refactor. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(transactions): unify unbooked predicate, add Ignorera action The "vampire transactions" fix. Three surfaces counted "unbooked" differently — the sidebar badge included ignored rows (is_business IS NULL only), the transactions page count and inbox list disagreed with each other — so old transactions kept haunting the inbox with inflated badges after every sync. All three now share lib/worklist's canonical predicate: is_business IS NULL AND is_ignored = false. The transactions page also gains "Ignorera transaktionen…" in the booking dialog (mirroring BankReconciliationView's flow: confirm → ignore → Ångra toast), so non-business strays can finally be cleared from the worklist without fabricating a verifikation. Recovery remains on Rapporter → Bankavstämning under "Ignorerade transaktioner". Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(worklist): address PR #673 review — exact missing-underlag count, tenant-tagged logs Review findings: - countVerifikatMissingDocument subtracted set SIZES, which both let documents on non-document-requiring entries (e.g. VAT settlements) shrink the count and silently truncated at the PostgREST row cap on large document histories. Now an exact per-entry set difference over fetchAllRows-paginated id-only reads. (greptile, swedish-compliance) - logAndZero gains companyId as a structured log field so repeated count failures can be correlated to a tenant. (compliance-swarm V16) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(worklist): address PR #673 review round 2 — chunked .in(), BFL-aware ignore copy - countInboxDocuments: dedupe + chunk the document-id .in() filter at 150 ids per request — PostgREST serialises it into the GET query string, so a large inbox could exceed proxy URL limits (HTTP 414) and silently zero the badge via the error branch. (greptile) - Ignorera confirm dialog now states the BFL boundary: only for non-affärshändelser (duplicates, own-account transfers) — real purchases and payments must be booked. Persisting a structured ignore reason is noted as follow-up. (swedish-compliance) - New tests: chunked counting sums across batches; a mid-stream pagination error in countVerifikatMissingDocument throws (fetchAllRows propagates page errors) and soft-fails to a logged 0 — never a count computed from a silently truncated set. (swedish-compliance) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+10
-14
@@ -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({
|
||||
<DashboardNav
|
||||
companyName={settings?.company_name || 'Min verksamhet'}
|
||||
entityType={entityType}
|
||||
uncategorizedTransactionCount={uncategorizedCount ?? 0}
|
||||
pendingOperationsCount={pendingOpsCount ?? 0}
|
||||
uncategorizedTransactionCount={uncategorizedCount}
|
||||
pendingOperationsCount={pendingOpsCount}
|
||||
isSandbox={isSandbox}
|
||||
extensionNavItems={getExtensionNavItems()}
|
||||
userName={userProfile?.full_name ?? null}
|
||||
|
||||
@@ -200,7 +200,7 @@ export default function TransactionsPage() {
|
||||
const [sourceFilter, setSourceFilter] = useState<'all' | 'bank' | 'skatteverket'>('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<TransactionWithInvoice | null>(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: (
|
||||
<ToastAction altText="Ångra ignorera" onClick={() => void handleUnignoreTransaction(tx.id)}>
|
||||
Ångra
|
||||
</ToastAction>
|
||||
),
|
||||
})
|
||||
} 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…
|
||||
</Button>
|
||||
)}
|
||||
{templatePickerTransaction && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start text-muted-foreground"
|
||||
onClick={() => void handleIgnoreTransaction(templatePickerTransaction)}
|
||||
>
|
||||
Ignorera transaktionen…
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<TemplatePicker
|
||||
direction={templatePickerTransaction && templatePickerTransaction.amount < 0 ? 'expense' : 'income'}
|
||||
@@ -2073,7 +2156,7 @@ export default function TransactionsPage() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<DestructiveConfirmDialog {...deleteDialogProps} />
|
||||
<DestructiveConfirmDialog {...confirmDialogProps} />
|
||||
|
||||
<EditTransactionTitleDialog
|
||||
open={editTitleTarget !== null}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { getWorklistCounts } from '@/lib/worklist'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
/**
|
||||
* GET /api/worklist/counts — all pending-work counts for the active company.
|
||||
*
|
||||
* Powers the "Att göra" surfaces (home-page section, sidebar badges) and
|
||||
* client-side refetch after an inline action completes. Read-only; every
|
||||
* count is a cheap head-only query that soft-fails to 0 — see lib/worklist.
|
||||
*
|
||||
* Response: { data: { counts: Record<WorklistCategory, number>, total } }
|
||||
*/
|
||||
export const GET = withRouteContext('worklist.counts', async (_request, ctx) => {
|
||||
const { supabase, companyId } = ctx
|
||||
const data = await getWorklistCounts(supabase, companyId)
|
||||
return NextResponse.json({ data })
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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<WorklistCounts> {
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -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<number> {
|
||||
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<number> {
|
||||
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<number> {
|
||||
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<number> {
|
||||
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<number> {
|
||||
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<number> {
|
||||
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<number> {
|
||||
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<number> {
|
||||
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<SuggestedMatch[]> {
|
||||
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<string, CandidateRow>(
|
||||
((invoiceRes.data ?? []) as unknown as CandidateRow[]).map((r) => [r.id, r]),
|
||||
)
|
||||
const supplierById = new Map<string, CandidateRow>(
|
||||
((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
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './types'
|
||||
export * from './categories'
|
||||
export * from './aggregate'
|
||||
@@ -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<WorklistCategory, number>
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
@@ -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';
|
||||
Reference in New Issue
Block a user