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:
Jakob Wennberg
2026-06-05 10:11:37 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent cac692e293
commit f59da07fc0
10 changed files with 987 additions and 20 deletions
+10 -14
View File
@@ -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}
+89 -6
View File
@@ -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}
+21
View File
@@ -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 })
})