diff --git a/components/dashboard/AttGoraSection.tsx b/components/dashboard/AttGoraSection.tsx index f43cf70b..0b4cc376 100644 --- a/components/dashboard/AttGoraSection.tsx +++ b/components/dashboard/AttGoraSection.tsx @@ -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 && ( + + )} {matches.length > 0 && (

diff --git a/components/dashboard/DashboardNav.tsx b/components/dashboard/DashboardNav.tsx index b9ccbbc9..c7bc9541 100644 --- a/components/dashboard/DashboardNav.tsx +++ b/components/dashboard/DashboardNav.tsx @@ -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 | 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', { diff --git a/lib/hooks/use-worklist-badges.ts b/lib/hooks/use-worklist-badges.ts index 92de811f..2f1cb6a4 100644 --- a/lib/hooks/use-worklist-badges.ts +++ b/lib/hooks/use-worklist-badges.ts @@ -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), } }, diff --git a/lib/worklist/__tests__/aggregate.test.ts b/lib/worklist/__tests__/aggregate.test.ts index ca3ce88f..ce4d5c12 100644 --- a/lib/worklist/__tests__/aggregate.test.ts +++ b/lib/worklist/__tests__/aggregate.test.ts @@ -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) }) }) diff --git a/lib/worklist/__tests__/categories.test.ts b/lib/worklist/__tests__/categories.test.ts index 850b078f..89363cfa 100644 --- a/lib/worklist/__tests__/categories.test.ts +++ b/lib/worklist/__tests__/categories.test.ts @@ -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({ diff --git a/lib/worklist/aggregate.ts b/lib/worklist/aggregate.ts index daddaa5d..31b1133c 100644 --- a/lib/worklist/aggregate.ts +++ b/lib/worklist/aggregate.ts @@ -9,6 +9,7 @@ import { countReconciliationDue, countSuggestedMatches, countSupplierInvoicesAwaitingApproval, + countUnbookedSkattekontoRows, countUnbookedTransactions, countVerifikatMissingDocument, } from './categories' @@ -40,6 +41,7 @@ export async function getWorklistCounts( ): Promise { 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 + diff --git a/lib/worklist/categories.ts b/lib/worklist/categories.ts index 984cf678..a1284af7 100644 --- a/lib/worklist/categories.ts +++ b/lib/worklist/categories.ts @@ -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 { + 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 diff --git a/lib/worklist/types.ts b/lib/worklist/types.ts index eb2c707f..44cf80e2 100644 --- a/lib/worklist/types.ts +++ b/lib/worklist/types.ts @@ -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 diff --git a/messages/en.json b/messages/en.json index 48eda726..984db25c 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/sv.json b/messages/sv.json index ff980177..08e80003 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -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", diff --git a/supabase/migrations/20260903120000_skattekonto_transactions_realtime_publication.sql b/supabase/migrations/20260903120000_skattekonto_transactions_realtime_publication.sql new file mode 100644 index 00000000..063555e6 --- /dev/null +++ b/supabase/migrations/20260903120000_skattekonto_transactions_realtime_publication.sql @@ -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';