feat(dashboard): unified "Att göra" worklist section on Hem (#674)

* feat(dashboard): unified "Att göra" worklist section on Hem

The pilot's core complaint: pending work was scattered across
Transaktioner, Underlag and Ny verifikation with no single starting
point. Hem now carries one flat Att göra ledger — three bands by
session intent (Bokför / Granska & komplettera / Bevaka), every count
read from lib/worklist (the same source as the sidebar badges, so the
numbers can never disagree), and an "Allt klart!" empty state.

Suggested transaction↔invoice matches render inline with one-click
Bekräfta posting to the existing match endpoints; rows fade out
optimistically and counts re-sync from /api/worklist/counts.

Replaces the "Att hantera" alert-card grid — whose warning/destructive
chrome borders violated the design system — with neutral hairline rows;
urgency is now carried by Badge variants only. The "Att göra" KPI tile
switches to the worklist total, and the home page drops eight inline
pending-work queries (incl. the legacy receipts queue, superseded by
the inbox category) in favour of getWorklistCounts().

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(dashboard): address PR #674 review — count/visibility consistency

greptile found two real contradictions in the Att göra section:
- Expiring bank connections rendered a Bevaka row without counting
  toward the header total — a user with only an expiring consent read
  "0 kvar" next to a visible action row. The section header and the
  KPI tile now both show worklist.total + expiring connections.
- deadline_action counted toward the total but had no row, so
  deadline-only users saw "Allt klart!" under a non-zero tile. Bevaka
  gains a "Moms- och skattedeadlines" row linking to /deadlines.

Invariant after this commit: every count that feeds a displayed total
has a visible row, and the tile and section header always agree.

Also per compliance review: a failed counts refetch after a confirmed
match now logs via console.error (Sentry-observable) instead of being
silently swallowed.

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:44:16 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent f59da07fc0
commit 076bb169f8
5 changed files with 470 additions and 256 deletions
+13 -83
View File
@@ -6,7 +6,8 @@ import WelcomeGate from '@/components/onboarding/WelcomeGate'
import { getActiveCompanyId } from '@/lib/company/context'
import { getDisplayTotal } from '@/lib/invoices/rounding'
import { ensureSandboxAgentProfile } from '@/lib/sandbox/ensure-agent'
import type { Deadline, ReceiptQueueSummary, OnboardingProgress } from '@/types'
import { getWorklistCounts, listSuggestedMatches } from '@/lib/worklist'
import type { Deadline, OnboardingProgress } from '@/types'
export const dynamic = 'force-dynamic'
@@ -51,16 +52,6 @@ export default async function DashboardPage() {
const today = now.toISOString().split('T')[0]
const nextWeek = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]
// Source types that require supporting documents
const needsDocSourceTypes = [
'manual',
'bank_transaction',
'supplier_invoice_registered',
'supplier_invoice_paid',
'supplier_invoice_cash_payment',
'import',
]
// Fetch all data in parallel
const [
{ data: settings },
@@ -69,22 +60,16 @@ export default async function DashboardPage() {
{ count: receiptCount },
{ count: transactionCount },
{ data: journalLines },
{ data: transactions },
{ data: unpaidInvoices },
{ data: bankConnections },
{ data: deadlines },
{ count: pendingReviewCount },
{ count: unmatchedReceiptsCount },
{ count: unmatchedTransactionsCount },
{ count: postedEntriesCount },
{ data: entriesWithDocs },
{ data: recentReceiptActivity },
{ count: sieImportCount },
{ count: staleUncategorizedCount },
{ count: uncategorizedCount },
{ count: skatteverketTokenCount },
{ data: agentProfile },
{ data: noDocRequiredEntries },
{ count: postedEntriesCount },
worklist,
suggestedMatches,
] = await Promise.all([
supabase.from('company_settings').select('*').eq('company_id', companyId).single(),
supabase.from('customers').select('*', { count: 'exact', head: true }).eq('company_id', companyId),
@@ -96,26 +81,23 @@ export default async function DashboardPage() {
.eq('journal_entry.status', 'posted')
.eq('journal_entry.company_id', companyId)
.gte('journal_entry.entry_date', startOfYearStr),
supabase.from('transactions').select('amount, amount_sek, is_business').eq('company_id', companyId).gte('date', startOfYearStr),
supabase.from('invoices').select('total, total_sek, vat_amount, vat_amount_sek, status').eq('company_id', companyId).in('status', ['sent', 'overdue']).is('credited_invoice_id', null),
supabase.from('bank_connections').select('id, accounts_data, status, consent_expires, bank_name').eq('company_id', companyId).eq('status', 'active'),
supabase.from('deadlines').select('*, customer:customers(id, name)').eq('company_id', companyId).eq('is_completed', false)
.or(`due_date.lt.${today},due_date.lte.${nextWeek}`).order('due_date', { ascending: true }),
supabase.from('receipts').select('*', { count: 'exact', head: true }).eq('company_id', companyId).eq('status', 'extracted'),
supabase.from('receipts').select('*', { count: 'exact', head: true }).eq('company_id', companyId).eq('status', 'confirmed').is('matched_transaction_id', null),
supabase.from('transactions').select('*', { count: 'exact', head: true }).eq('company_id', companyId).lt('amount', 0).is('receipt_id', null),
supabase.from('journal_entries').select('*', { count: 'exact', head: true }).eq('company_id', companyId).eq('status', 'posted').in('source_type', needsDocSourceTypes),
supabase.from('document_attachments').select('journal_entry_id').eq('company_id', companyId).eq('is_current_version', true).not('journal_entry_id', 'is', null),
supabase.from('receipts').select('created_at').eq('company_id', companyId).eq('status', 'confirmed').order('created_at', { ascending: false }).limit(30),
supabase.from('sie_imports').select('*', { count: 'exact', head: true }).eq('company_id', companyId).eq('status', 'completed'),
supabase.from('transactions').select('*', { count: 'exact', head: true }).eq('company_id', companyId).is('journal_entry_id', null).eq('is_ignored', false).is('is_business', null).lt('date', new Date(now.getTime() - 14 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]),
supabase.from('transactions').select('*', { count: 'exact', head: true }).eq('company_id', companyId).eq('is_ignored', false).is('is_business', null),
// Skatteverket tokens are user-scoped (one BankID identity per user) but
// carry the active company_id; either filter would work — we use user_id
// because that's what the token-store reads/writes against.
supabase.from('skatteverket_tokens').select('*', { count: 'exact', head: true }).eq('user_id', user.id),
supabase.from('agent_profiles').select('verified_at').eq('company_id', companyId).maybeSingle(),
supabase.from('journal_entry_no_doc_required').select('journal_entry_id').eq('company_id', companyId),
// Any posted entry counts as "company has been used" for the hasData gate.
supabase.from('journal_entries').select('*', { count: 'exact', head: true }).eq('company_id', companyId).eq('status', 'posted'),
// Pending-work counts + suggested matches come from lib/worklist — the
// same source as the sidebar badges, so the numbers can never diverge.
getWorklistCounts(supabase, companyId),
listSuggestedMatches(supabase, companyId, 5),
])
// If onboarding is not complete, redirect to onboarding
@@ -204,16 +186,6 @@ export default async function DashboardPage() {
const ytdTotals = calculateTotals(journalLines, startOfYearStr)
const mtdTotals = calculateTotals(journalLines, startOfMonthStr)
const uncategorizedTxns = (transactions || []).filter(
(t) => t.is_business === null
)
const uncategorizedIncome = uncategorizedTxns
.filter((t) => t.amount > 0)
.reduce((sum, t) => sum + Number(t.amount_sek || t.amount), 0)
const uncategorizedExpenses = uncategorizedTxns
.filter((t) => t.amount < 0)
.reduce((sum, t) => sum + Math.abs(Number(t.amount_sek || t.amount)), 0)
// Mirror the per-invoice öresavrundning rule used on the invoice list/detail
// pages: sum the displayed (rounded) SEK amount per invoice so the dashboard
// total matches what the user sees on the invoice list when the setting is on.
@@ -262,45 +234,6 @@ export default async function DashboardPage() {
),
}))
const entriesWithDocsSet = new Set(
(entriesWithDocs || []).map((d) => d.journal_entry_id)
)
// Exempted entries that *also* have a doc are already excluded by entriesWithDocsSet,
// so subtracting only the exempt-without-doc set avoids double-counting.
let exemptedWithoutDoc = 0
for (const row of (noDocRequiredEntries || []) as { journal_entry_id: string }[]) {
if (!entriesWithDocsSet.has(row.journal_entry_id)) exemptedWithoutDoc++
}
const missingUnderlagCount = Math.max(
0,
(postedEntriesCount || 0) - entriesWithDocsSet.size - exemptedWithoutDoc
)
let streakCount = 0
if (recentReceiptActivity && recentReceiptActivity.length > 0) {
const todayDate = new Date()
todayDate.setHours(0, 0, 0, 0)
const activityDates = new Set(
recentReceiptActivity.map((r) => new Date(r.created_at).toISOString().split('T')[0])
)
const checkDate = new Date(todayDate)
while (activityDates.has(checkDate.toISOString().split('T')[0])) {
streakCount++
checkDate.setDate(checkDate.getDate() - 1)
}
}
const receiptQueue: ReceiptQueueSummary = {
unmatched_receipts_count: unmatchedReceiptsCount || 0,
unmatched_transactions_count: unmatchedTransactionsCount || 0,
pending_review_count: pendingReviewCount || 0,
streak_count: streakCount,
}
return (
<DashboardContent
companyId={companyId}
@@ -308,9 +241,6 @@ export default async function DashboardPage() {
summary={{
ytd: ytdTotals,
mtd: mtdTotals,
uncategorizedCount: uncategorizedCount || 0,
uncategorizedIncome,
uncategorizedExpenses,
unpaidInvoicesCount: (unpaidInvoices || []).length,
unpaidInvoicesTotal: unpaidTotal,
unpaidVatTotal,
@@ -318,10 +248,10 @@ export default async function DashboardPage() {
bankBalance,
expiringBankConnections,
deadlines: (deadlines || []) as Deadline[],
receiptQueue,
missingUnderlagCount,
staleUncategorizedCount: staleUncategorizedCount || 0,
}}
worklist={worklist}
suggestedMatches={suggestedMatches}
onboardingProgress={onboardingProgress}
/>
)