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:
co-authored by
Claude Opus 4.8
parent
cac692e293
commit
f59da07fc0
+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 })
|
||||
})
|
||||
Reference in New Issue
Block a user