The Transaktioner inbox lists unbooked skattekonto rows next to unbooked bank rows, but the sidebar badge and the Hem "Att göra" list counted bank rows only, so a migrated tax account waited in the inbox unseen. - lib/worklist: new category book_skattekonto with the inbox's own predicate (status = 'booked', no verifikat, not ignored); aggregate includes it in counts and total. - Hem: a "Bokföra skattekontohändelser" row under Bokför, deep-linking to /transactions?source=skatteverket. - Sidebar badge hook: the same third head-count query, summed into the /transactions badge. - DashboardNav subscribes to skattekonto_transactions realtime changes; migration adds the table to the supabase_realtime publication so a booked or ignored row drops the badge without a manual refresh. Closes #2180 Claude-Session: https://claude.ai/code/session_01QPQLwHNEiQfiCNLSMzXMiQ Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Jakob Wennberg
Claude Fable 5.1
parent
aeff998b90
commit
1e91c126ff
@@ -200,7 +200,11 @@ export default function AttGoraSection({
|
||||
}
|
||||
|
||||
const showInboxDocuments = hasAi && counts.inbox_document > 0
|
||||
const bokforRows = counts.book_transaction > 0 || showInboxDocuments || matches.length > 0
|
||||
const bokforRows =
|
||||
counts.book_transaction > 0 ||
|
||||
counts.book_skattekonto > 0 ||
|
||||
showInboxDocuments ||
|
||||
matches.length > 0
|
||||
const granskaRows =
|
||||
counts.supplier_invoice_approval > 0 ||
|
||||
counts.verifikat_missing_document > 0 ||
|
||||
@@ -277,6 +281,14 @@ export default function AttGoraSection({
|
||||
count={counts.book_transaction}
|
||||
/>
|
||||
)}
|
||||
{counts.book_skattekonto > 0 && (
|
||||
<WorklistRow
|
||||
href="/transactions?source=skatteverket"
|
||||
icon={Landmark}
|
||||
label={t('row_book_skattekonto')}
|
||||
count={counts.book_skattekonto}
|
||||
/>
|
||||
)}
|
||||
{matches.length > 0 && (
|
||||
<div className="px-4 py-3">
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
|
||||
@@ -509,9 +509,10 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
|
||||
useEffect(() => {
|
||||
if (!company?.id) return
|
||||
|
||||
// Realtime keeps the badges live; a trailing debounce collapses event
|
||||
// bursts (bulk booking / bulk approvals emit one event per row) into a
|
||||
// single SWR revalidation instead of a request stampede.
|
||||
// Realtime keeps the badges live (bank rows, skattekonto rows, staged
|
||||
// operations); a trailing debounce collapses event bursts (bulk booking
|
||||
// / bulk approvals emit one event per row) into a single SWR
|
||||
// revalidation instead of a request stampede.
|
||||
let debounce: ReturnType<typeof setTimeout> | null = null
|
||||
const queueRefresh = () => {
|
||||
if (debounce) clearTimeout(debounce)
|
||||
@@ -530,6 +531,16 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
|
||||
},
|
||||
queueRefresh,
|
||||
)
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{
|
||||
event: '*',
|
||||
schema: 'public',
|
||||
table: 'skattekonto_transactions',
|
||||
filter: `company_id=eq.${company.id}`,
|
||||
},
|
||||
queueRefresh,
|
||||
)
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{
|
||||
|
||||
@@ -4,7 +4,12 @@ import useSWR from 'swr'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
|
||||
export interface WorklistBadges {
|
||||
/** Unbooked bank transactions: same predicate as lib/worklist countUnbookedTransactions. */
|
||||
/**
|
||||
* Rows waiting in the Transaktioner inbox: unbooked bank transactions
|
||||
* (lib/worklist countUnbookedTransactions) plus unbooked skattekonto rows
|
||||
* (countUnbookedSkattekontoRows). The badge sits on /transactions, which
|
||||
* lists both, so the number must cover both (#2180).
|
||||
*/
|
||||
uncategorized: number
|
||||
/** Agent-staged operations awaiting review: same predicate as countPendingOperations. */
|
||||
pendingOperations: number
|
||||
@@ -26,13 +31,20 @@ export function useWorklistBadges(companyId: string | null | undefined) {
|
||||
companyId ? ['worklist-badges', companyId] : null,
|
||||
async ([, id]: [string, string]) => {
|
||||
const supabase = createClient()
|
||||
const [tx, ops] = await Promise.all([
|
||||
const [tx, skv, ops] = await Promise.all([
|
||||
supabase
|
||||
.from('transactions')
|
||||
.select('id', { count: 'exact', head: true })
|
||||
.eq('company_id', id)
|
||||
.is('is_business', null)
|
||||
.eq('is_ignored', false),
|
||||
supabase
|
||||
.from('skattekonto_transactions')
|
||||
.select('id', { count: 'exact', head: true })
|
||||
.eq('company_id', id)
|
||||
.eq('status', 'booked')
|
||||
.is('journal_entry_id', null)
|
||||
.eq('is_ignored', false),
|
||||
supabase
|
||||
.from('pending_operations')
|
||||
.select('id', { count: 'exact', head: true })
|
||||
@@ -40,7 +52,7 @@ export function useWorklistBadges(companyId: string | null | undefined) {
|
||||
.eq('status', 'pending'),
|
||||
])
|
||||
return {
|
||||
uncategorized: tx.error ? 0 : (tx.count ?? 0),
|
||||
uncategorized: (tx.error ? 0 : (tx.count ?? 0)) + (skv.error ? 0 : (skv.count ?? 0)),
|
||||
pendingOperations: ops.error ? 0 : (ops.count ?? 0),
|
||||
}
|
||||
},
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
|
||||
vi.mock('../categories', () => ({
|
||||
countUnbookedTransactions: vi.fn().mockResolvedValue(4),
|
||||
countUnbookedSkattekontoRows: vi.fn().mockResolvedValue(7),
|
||||
countInboxDocuments: vi.fn().mockResolvedValue(6),
|
||||
countSuggestedMatches: vi.fn().mockResolvedValue(2),
|
||||
countSupplierInvoicesAwaitingApproval: vi.fn().mockResolvedValue(1),
|
||||
@@ -26,6 +27,7 @@ describe('getWorklistCounts', () => {
|
||||
const { counts } = await getWorklistCounts(supabase, 'company-1')
|
||||
expect(counts).toEqual({
|
||||
book_transaction: 4,
|
||||
book_skattekonto: 7,
|
||||
inbox_document: 6,
|
||||
suggested_match: 2,
|
||||
supplier_invoice_approval: 1,
|
||||
@@ -49,7 +51,7 @@ describe('getWorklistCounts', () => {
|
||||
|
||||
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 + 1, without the 2 suggested matches.
|
||||
expect(total).toBe(23)
|
||||
// 4 + 7 + 6 + 1 + 3 + 5 + 1 + 2 + 1, without the 2 suggested matches.
|
||||
expect(total).toBe(30)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
countReconciliationDue,
|
||||
countSuggestedMatches,
|
||||
countSupplierInvoicesAwaitingApproval,
|
||||
countUnbookedSkattekontoRows,
|
||||
countUnbookedTransactions,
|
||||
countVerifikatMissingDocument,
|
||||
listSuggestedMatches,
|
||||
@@ -40,6 +41,25 @@ describe('countUnbookedTransactions', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('countUnbookedSkattekontoRows', () => {
|
||||
it('counts only settled, unbooked, non-ignored skattekonto rows', async () => {
|
||||
enqueue({ count: 3 })
|
||||
await expect(countUnbookedSkattekontoRows(supabase, COMPANY)).resolves.toBe(3)
|
||||
expect(mockSupabase.from).toHaveBeenCalledWith('skattekonto_transactions')
|
||||
// Same predicate as the Transaktioner inbox: Skatteverket status 'booked'
|
||||
// (upcoming charges have nothing to book), no verifikat, not ignored.
|
||||
const eqCalls = findCalls('skattekonto_transactions', 'eq')
|
||||
expect(eqCalls).toContainEqual(['status', 'booked'])
|
||||
expect(eqCalls).toContainEqual(['is_ignored', false])
|
||||
expect(findCall('skattekonto_transactions', 'is')).toEqual(['journal_entry_id', null])
|
||||
})
|
||||
|
||||
it('soft-fails to 0 on query error', async () => {
|
||||
enqueue({ error: { message: 'boom' } })
|
||||
await expect(countUnbookedSkattekontoRows(supabase, COMPANY)).resolves.toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('countInboxDocuments', () => {
|
||||
it('counts only items whose document is still unlinked', async () => {
|
||||
enqueue({
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
countReconciliationDue,
|
||||
countSuggestedMatches,
|
||||
countSupplierInvoicesAwaitingApproval,
|
||||
countUnbookedSkattekontoRows,
|
||||
countUnbookedTransactions,
|
||||
countVerifikatMissingDocument,
|
||||
} from './categories'
|
||||
@@ -40,6 +41,7 @@ export async function getWorklistCounts(
|
||||
): Promise<WorklistCounts> {
|
||||
const [
|
||||
bookTransaction,
|
||||
bookSkattekonto,
|
||||
inboxDocument,
|
||||
suggestedMatch,
|
||||
supplierInvoiceApproval,
|
||||
@@ -50,6 +52,7 @@ export async function getWorklistCounts(
|
||||
reconciliationDue,
|
||||
] = await Promise.all([
|
||||
countUnbookedTransactions(supabase, companyId),
|
||||
countUnbookedSkattekontoRows(supabase, companyId),
|
||||
countInboxDocuments(supabase, companyId),
|
||||
options.suggestedMatches
|
||||
? Promise.resolve(options.suggestedMatches).then((m) => m.length)
|
||||
@@ -65,6 +68,7 @@ export async function getWorklistCounts(
|
||||
return {
|
||||
counts: {
|
||||
book_transaction: bookTransaction,
|
||||
book_skattekonto: bookSkattekonto,
|
||||
inbox_document: inboxDocument,
|
||||
suggested_match: suggestedMatch,
|
||||
supplier_invoice_approval: supplierInvoiceApproval,
|
||||
@@ -76,6 +80,7 @@ export async function getWorklistCounts(
|
||||
},
|
||||
total:
|
||||
bookTransaction +
|
||||
bookSkattekonto +
|
||||
inboxDocument +
|
||||
supplierInvoiceApproval +
|
||||
verifikatMissingDocument +
|
||||
|
||||
@@ -67,6 +67,28 @@ export async function countUnbookedTransactions(
|
||||
return count ?? 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Unbooked skattekonto rows: every Skatteverket-side event the Transaktioner
|
||||
* inbox lists (status = 'booked', i.e. "tidigare") that has no verifikat and
|
||||
* was not ignored. `status` is Skatteverket's status, not booking status:
|
||||
* 'upcoming' rows are future charges with nothing to book. journal_entry_id
|
||||
* is the booked marker here (unlike bank transactions, which use is_business).
|
||||
*/
|
||||
export async function countUnbookedSkattekontoRows(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
): Promise<number> {
|
||||
const { count, error } = await supabase
|
||||
.from('skattekonto_transactions')
|
||||
.select('id', { count: 'exact', head: true })
|
||||
.eq('company_id', companyId)
|
||||
.eq('status', 'booked')
|
||||
.is('journal_entry_id', null)
|
||||
.eq('is_ignored', false)
|
||||
if (error) return logAndZero('book_skattekonto', 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
|
||||
|
||||
@@ -24,6 +24,20 @@ export const WORKLIST_CATEGORIES = [
|
||||
* keep journal_entry_id NULL (see lib/transactions/is-booked.ts).
|
||||
*/
|
||||
'book_transaction',
|
||||
/**
|
||||
* Unbooked skattekonto rows ("N st skattekontohändelser att bokföra").
|
||||
* Pending: skattekonto_transactions with status = 'booked' (Skatteverket's
|
||||
* "tidigare": the event has happened on the tax account),
|
||||
* journal_entry_id IS NULL and is_ignored = false. Rows with
|
||||
* status = 'upcoming' are future charges with nothing to book
|
||||
* yet and never reach the Transaktioner inbox, so they are not
|
||||
* pending work either.
|
||||
* Done: the skattekonto booking flows set journal_entry_id (here it IS
|
||||
* the booked marker, unlike bank transactions), or the user
|
||||
* ignores the row (is_ignored = true). Same predicate as the
|
||||
* Transaktioner inbox's Skatteverket rows.
|
||||
*/
|
||||
'book_skattekonto',
|
||||
/**
|
||||
* Unconsumed documents in the inbox ("N st underlag att hantera").
|
||||
* Pending: invoice_inbox_items with a document and no
|
||||
|
||||
@@ -6534,6 +6534,7 @@
|
||||
"band_bevaka": "Monitor",
|
||||
"row_book_transactions": "Transactions to record",
|
||||
"row_book_transactions_stale": "{count} older than 14 days",
|
||||
"row_book_skattekonto": "Tax account events to record",
|
||||
"row_inbox_documents": "Documents to handle",
|
||||
"row_inbox_documents_detail": "Match to a transaction or record directly",
|
||||
"row_supplier_approval": "Supplier invoices to approve",
|
||||
|
||||
@@ -6534,6 +6534,7 @@
|
||||
"band_bevaka": "Bevaka",
|
||||
"row_book_transactions": "Bokföra transaktioner",
|
||||
"row_book_transactions_stale": "{count} äldre än 14 dagar",
|
||||
"row_book_skattekonto": "Bokföra skattekontohändelser",
|
||||
"row_inbox_documents": "Underlag att hantera",
|
||||
"row_inbox_documents_detail": "Matcha mot transaktion eller bokför direkt",
|
||||
"row_supplier_approval": "Leverantörsfakturor att attestera",
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
-- Migration: stream skattekonto_transactions changes via Supabase realtime
|
||||
--
|
||||
-- The sidebar "Att göra" badge now counts unbooked skattekonto rows next to
|
||||
-- unbooked bank transactions (#2180), and DashboardNav listens to
|
||||
-- postgres_changes on public.skattekonto_transactions so a booked or
|
||||
-- ignored tax-account row drops the badge without a manual refresh, the
|
||||
-- same way public.transactions already does (20260629180000).
|
||||
--
|
||||
-- RLS already scopes skattekonto_transactions to the user's companies, so
|
||||
-- realtime only delivers rows the current user is allowed to read.
|
||||
--
|
||||
-- Idempotent so preview branches or partial re-applies do not fail if the
|
||||
-- publication already includes the table.
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_publication_tables
|
||||
WHERE pubname = 'supabase_realtime'
|
||||
AND schemaname = 'public'
|
||||
AND tablename = 'skattekonto_transactions'
|
||||
) THEN
|
||||
ALTER PUBLICATION supabase_realtime ADD TABLE public.skattekonto_transactions;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
Reference in New Issue
Block a user