feat: realtime updates for dashboard transactions (rebased reimplementation of #757) (#833)

* Add realtime transaction subscriptions to dashboard and transactions page

Introduce a shared browser Supabase hook so client components can create a stable realtime-capable client once and reuse it across multiple dashboard surfaces. Dashboard navigation now keeps the uncategorized transactions badge in sync through a company-scoped postgres_changes subscription on public.transactions, and the transactions page now subscribes to the same table so it can refresh its list and uncategorized count live without a full page reload.

The transactions page keeps the initial server-driven load path intact, but once mounted it listens for inserts, updates, and deletes on the active company's transactions. When a change arrives it refetches the list and total uncategorized count using the same RLS-scoped filters that already power the page, then hydrates the visible rows in the same way as the initial load. The refresh path is intentionally coalesced so bursts of realtime events do not trigger overlapping database reads.

The dashboard nav and the transactions page both use the new shared hook instead of creating browser clients ad hoc. This keeps the realtime client setup consistent, avoids duplicate client construction logic, and makes it straightforward to add more realtime dashboard consumers later without re-implementing the same Supabase plumbing.

A Supabase migration is included to add public.transactions to the supabase_realtime publication. Without that publication entry the browser subscription would be correct but silent, so the migration is required for hosted environments as well as local resets.

Also included in this commit is the current package-lock.json drift present in the staged set.

Signed-off-by: Esaias Westberg <esaias@westbergs.se>

* fix(migration): retimestamp transactions realtime publication to clear collision

20260628120000 collided with 20260628120000_ef_no_owner_employee.sql on main.
Renamed to a unique timestamp after main's latest. SQL unchanged.

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

* style(dashboard): fix indentation in collapsed-nav badge block

---------

Signed-off-by: Esaias Westberg <esaias@westbergs.se>
Co-authored-by: Esaias Westberg <esaias@westbergs.se>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-06-30 08:58:01 +02:00
committed by GitHub
co-authored by Claude Opus 4.8 Esaias Westberg
parent f8aef335c9
commit a8072f6423
4 changed files with 258 additions and 93 deletions
+139 -82
View File
@@ -1,10 +1,9 @@
'use client'
import { useState, useEffect, useMemo, useRef } from 'react'
import { useState, useEffect, useMemo, useRef, useCallback } from 'react'
import { AnimatePresence } from 'framer-motion'
import { useSearchParams } from 'next/navigation'
import { useTranslations } from 'next-intl'
import { createClient } from '@/lib/supabase/client'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
@@ -57,6 +56,7 @@ import type {
} from '@/types/skatteverket'
import { findBankSkvCounterparts } from '@/lib/skatteverket/bank-counterpart'
import { useCompany } from '@/contexts/CompanyContext'
import { useRealtimeSupabase } from '@/lib/hooks/use-realtime-supabase'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { formatCurrency, formatDate } from '@/lib/utils'
import type { TransactionCategory, CreateTransactionInput, Invoice, Customer, SupplierInvoice, Supplier, VatTreatment, EntityType, LinePatternEntry, BookingTemplateLibrary } from '@/types'
@@ -96,6 +96,7 @@ interface QuickReviewState {
export default function TransactionsPage() {
const { company } = useCompany()
const companyId = company?.id ?? null
const t = useTranslations('transactions')
const [transactions, setTransactions] = useState<TransactionWithInvoice[]>([])
const [isLoading, setIsLoading] = useState(true)
@@ -243,12 +244,14 @@ export default function TransactionsPage() {
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()
const supabase = useRealtimeSupabase()
const searchParams = useSearchParams()
const highlightId = searchParams.get('highlight')
// Tracks the last highlight target we acted on so re-renders don't re-trigger
// the auto-open every time the user closes the categorize panel.
const handledHighlightRef = useRef<string | null>(null)
const refreshTransactionsInFlightRef = useRef(false)
const refreshTransactionsQueuedRef = useRef(false)
// Computed lists
const uncategorizedTransactions = transactions
@@ -322,73 +325,7 @@ export default function TransactionsPage() {
const PAGE_SIZE = 200
async function fetchTransactions() {
if (!company) return
setIsLoading(true)
const [{ data: txData, error: txError }, { count: uncatCount }] = await Promise.all([
supabase
.from('transactions')
.select('*')
.eq('company_id', company.id)
.order('date', { ascending: false })
.limit(PAGE_SIZE),
supabase
.from('transactions')
.select('*', { count: 'exact', head: true })
.eq('company_id', company.id)
.is('is_business', null)
// Same predicate as lib/worklist countUnbookedTransactions — ignored
// rows are handled, not pending.
.eq('is_ignored', false),
])
if (txError) {
toast({ title: t('load_failed_title'), description: t('load_failed_description'), variant: 'destructive' })
setIsLoading(false)
return
}
const rows = txData || []
const potentialInvoiceIds = rows
.filter((t) => t.potential_invoice_id)
.map((t) => t.potential_invoice_id)
const potentialSupplierInvoiceIds = rows
.filter((t) => t.potential_supplier_invoice_id)
.map((t) => t.potential_supplier_invoice_id)
const [invoiceResult, supplierInvoiceResult] = await Promise.all([
potentialInvoiceIds.length > 0
? supabase.from('invoices').select('*, customer:customers(*)').in('id', potentialInvoiceIds)
: Promise.resolve({ data: null }),
potentialSupplierInvoiceIds.length > 0
? supabase.from('supplier_invoices').select('*, supplier:suppliers(*)').in('id', potentialSupplierInvoiceIds)
: Promise.resolve({ data: null }),
])
const invoiceMap = buildInvoiceMap(invoiceResult.data)
const supplierInvoiceMap = buildSupplierInvoiceMap(supplierInvoiceResult.data)
const transactionsWithInvoices: TransactionWithInvoice[] = rows.map((t) => ({
...t,
potential_invoice: t.potential_invoice_id ? invoiceMap[t.potential_invoice_id] : undefined,
potential_supplier_invoice: t.potential_supplier_invoice_id
? supplierInvoiceMap[t.potential_supplier_invoice_id]
: undefined,
}))
setTransactions(transactionsWithInvoices)
setTotalUncategorizedCount(uncatCount ?? 0)
setHasMore(rows.length >= PAGE_SIZE)
setIsLoading(false)
// Fire-and-forget: load SKV rows in parallel with the rest of the
// page. We don't block on this — if the extension is disabled or the
// user isn't connected the response is 503/401 and we just leave the
// SKV section empty.
void loadSkvRows()
}
async function loadSkvRows() {
const loadSkvRows = useCallback(async () => {
try {
const res = await fetch('/api/extensions/ext/skatteverket/skattekonto/transaktioner')
if (!res.ok) {
@@ -404,16 +341,105 @@ export default function TransactionsPage() {
} catch {
setSkvRows([])
}
}
}, [])
const fetchTransactions = useCallback(async (showLoading = false, includeSkvRows = false) => {
if (!companyId) return
if (showLoading) setIsLoading(true)
try {
const [{ data: txData, error: txError }, { count: uncatCount }] = await Promise.all([
supabase
.from('transactions')
.select('*')
.eq('company_id', companyId)
.order('date', { ascending: false })
.limit(PAGE_SIZE),
supabase
.from('transactions')
.select('*', { count: 'exact', head: true })
.eq('company_id', companyId)
.is('is_business', null)
// Same predicate as lib/worklist countUnbookedTransactions — ignored
// rows are handled, not pending.
.eq('is_ignored', false),
])
if (txError) {
toast({ title: t('load_failed_title'), description: t('load_failed_description'), variant: 'destructive' })
return
}
const rows = txData || []
const potentialInvoiceIds = rows
.filter((t) => t.potential_invoice_id)
.map((t) => t.potential_invoice_id)
const potentialSupplierInvoiceIds = rows
.filter((t) => t.potential_supplier_invoice_id)
.map((t) => t.potential_supplier_invoice_id)
const [invoiceResult, supplierInvoiceResult] = await Promise.all([
potentialInvoiceIds.length > 0
? supabase.from('invoices').select('*, customer:customers(*)').in('id', potentialInvoiceIds)
: Promise.resolve({ data: null }),
potentialSupplierInvoiceIds.length > 0
? supabase.from('supplier_invoices').select('*, supplier:suppliers(*)').in('id', potentialSupplierInvoiceIds)
: Promise.resolve({ data: null }),
])
const invoiceMap = buildInvoiceMap(invoiceResult.data)
const supplierInvoiceMap = buildSupplierInvoiceMap(supplierInvoiceResult.data)
const transactionsWithInvoices: TransactionWithInvoice[] = rows.map((t) => ({
...t,
potential_invoice: t.potential_invoice_id ? invoiceMap[t.potential_invoice_id] : undefined,
potential_supplier_invoice: t.potential_supplier_invoice_id
? supplierInvoiceMap[t.potential_supplier_invoice_id]
: undefined,
}))
setTransactions(transactionsWithInvoices)
setTotalUncategorizedCount(uncatCount ?? 0)
setHasMore(rows.length >= PAGE_SIZE)
// Fire-and-forget: load SKV rows in parallel with the rest of the
// page. We don't block on this — if the extension is disabled or the
// user isn't connected the response is 503/401 and we just leave the
// SKV section empty.
if (includeSkvRows) {
void loadSkvRows()
}
} finally {
if (showLoading) setIsLoading(false)
}
}, [companyId, loadSkvRows, supabase, t, toast])
const refreshTransactions = useCallback(async () => {
if (!companyId) return
if (refreshTransactionsInFlightRef.current) {
refreshTransactionsQueuedRef.current = true
return
}
refreshTransactionsInFlightRef.current = true
try {
do {
refreshTransactionsQueuedRef.current = false
await fetchTransactions(false, false)
} while (refreshTransactionsQueuedRef.current)
} finally {
refreshTransactionsInFlightRef.current = false
refreshTransactionsQueuedRef.current = false
}
}, [companyId, fetchTransactions])
async function loadMoreTransactions() {
if (!company) return
if (!companyId) return
setIsLoadingMore(true)
const offset = transactions.length
const { data: txData, error: txError } = await supabase
.from('transactions')
.select('*')
.eq('company_id', company.id)
.eq('company_id', companyId)
.order('date', { ascending: false })
.range(offset, offset + PAGE_SIZE - 1)
@@ -463,9 +489,9 @@ export default function TransactionsPage() {
// requested, so loadMoreTransactions pages are covered without refetching.
// Soft-fails to "no badges" on error.
useEffect(() => {
if (!company) return
if (requestedJeIdsRef.current.companyId !== company.id) {
requestedJeIdsRef.current = { companyId: company.id, ids: new Set() }
if (!companyId) return
if (requestedJeIdsRef.current.companyId !== companyId) {
requestedJeIdsRef.current = { companyId, ids: new Set() }
setJeUnderlagStatus({})
}
const requested = requestedJeIdsRef.current.ids
@@ -479,7 +505,6 @@ export default function TransactionsPage() {
if (newIds.length === 0) return
newIds.forEach((id) => requested.add(id))
const companyId = company.id
;(async () => {
const IN_CLAUSE_CHUNK = 150
const merged: Record<string, JeUnderlagStatus> = {}
@@ -530,7 +555,7 @@ export default function TransactionsPage() {
}
})()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [transactions, company])
}, [transactions, companyId])
async function fetchCategorySuggestions(txIds: string[]) {
if (txIds.length === 0) return
@@ -557,7 +582,7 @@ export default function TransactionsPage() {
async function loadAll() {
// Fetch transactions and entity type in parallel
const [, entityRes] = await Promise.all([
fetchTransactions(),
fetchTransactions(true, true),
fetch('/api/settings').then(r => r.json()).catch(() => null),
])
@@ -571,7 +596,39 @@ export default function TransactionsPage() {
loadAll()
return () => { cancelled = true }
}, [])
}, [fetchTransactions])
useEffect(() => {
if (!companyId) return
let cancelled = false
const refreshFromRealtime = async () => {
if (cancelled) return
await refreshTransactions()
}
const channel = supabase
.channel(`transactions:list:${companyId}`)
.on(
'postgres_changes',
{
event: '*',
schema: 'public',
table: 'transactions',
filter: `company_id=eq.${companyId}`,
},
() => {
void refreshFromRealtime()
},
)
.subscribe()
return () => {
cancelled = true
void supabase.removeChannel(channel)
}
}, [companyId, refreshTransactions, supabase])
// Scroll the targeted row into view when arriving via
// /transactions?highlight=<id>. Callers are inbox "Öppna transaktionen",
@@ -1404,7 +1461,7 @@ export default function TransactionsPage() {
for (const id of ids) next.add(id)
return next
})
await fetchTransactions()
await refreshTransactions()
setSelectedIds(new Set())
setIsBatchMode(false)
setTimeout(() => {
@@ -1424,7 +1481,7 @@ export default function TransactionsPage() {
// confirms it's booked. Mirrors the pattern at the supplier-invoice
// match success path below.
setExitingIds((prev) => new Set(prev).add(txId))
await fetchTransactions()
await refreshTransactions()
setTimeout(() => {
setExitingIds((prev) => {
const next = new Set(prev)
+76 -11
View File
@@ -1,10 +1,9 @@
'use client'
import { useState, useRef } from 'react'
import { useEffect, useState, useRef } from 'react'
import Link from 'next/link'
import { usePathname, useRouter } from 'next/navigation'
import { useTranslations } from 'next-intl'
import { createClient } from '@/lib/supabase/client'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import {
@@ -50,6 +49,7 @@ import CompanySwitcher from '@/components/dashboard/CompanySwitcher'
import AgentAvatar from '@/components/agent/AgentAvatar'
import { useAgentSheet } from '@/components/agent/AgentSheetProvider'
import { useCompany } from '@/contexts/CompanyContext'
import { useRealtimeSupabase } from '@/lib/hooks/use-realtime-supabase'
import type { EntityType } from '@/types'
void _ENABLED_EXTENSION_IDS
@@ -185,7 +185,7 @@ function accountInitial(name: string | null, email: string | null): string {
export default function DashboardNav({ companyName: _companyName, entityType, paysSalaries = false, uncategorizedTransactionCount = 0, pendingOperationsCount = 0, isSandbox = false, extensionNavItems = [], userName = null, userEmail = null }: DashboardNavProps) {
const pathname = usePathname()
const router = useRouter()
const supabase = createClient()
const supabase = useRealtimeSupabase()
const { company } = useCompany()
// Agent identity drives the "Assistent" nav icon — when the user has
// built their assistant we show its chosen avatar instead of the
@@ -196,6 +196,11 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false)
const [isClosing, setIsClosing] = useState(false)
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const [liveUncategorizedTransactionCount, setLiveUncategorizedTransactionCount] = useState(
uncategorizedTransactionCount,
)
const refreshInFlightRef = useRef(false)
const refreshQueuedRef = useRef(false)
const hasCompany = !!company
const ALWAYS_ENABLED = new Set(['/settings'])
@@ -250,6 +255,66 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
}, 200)
}
useEffect(() => {
if (!company?.id) return
let cancelled = false
const refreshUncategorizedCount = async () => {
if (!company?.id || cancelled) return
if (refreshInFlightRef.current) {
refreshQueuedRef.current = true
return
}
refreshInFlightRef.current = true
try {
do {
refreshQueuedRef.current = false
const { count, error } = await supabase
.from('transactions')
.select('id', { count: 'exact', head: true })
.eq('company_id', company.id)
.is('is_business', null)
.eq('is_ignored', false)
if (error) {
console.error('Failed to refresh uncategorized transaction count:', error)
break
}
setLiveUncategorizedTransactionCount(count ?? 0)
} while (refreshQueuedRef.current && !cancelled)
} finally {
refreshInFlightRef.current = false
refreshQueuedRef.current = false
}
}
void refreshUncategorizedCount()
const channel = supabase
.channel(`dashboard-nav:transactions:${company.id}`)
.on(
'postgres_changes',
{
event: '*',
schema: 'public',
table: 'transactions',
filter: `company_id=eq.${company.id}`,
},
() => {
void refreshUncategorizedCount()
},
)
.subscribe()
return () => {
cancelled = true
void supabase.removeChannel(channel)
}
}, [company?.id, supabase])
const hiddenNavHrefs = new Set(getBranding().hiddenNavHrefs)
// Render a nav item's leading glyph. The "Assistent" entry (/chat) shows
@@ -354,8 +419,8 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
const active = isActive(item.href)
const enabled = isItemEnabled(item.href)
const badge =
item.href === '/transactions' && uncategorizedTransactionCount > 0
? uncategorizedTransactionCount
item.href === '/transactions' && liveUncategorizedTransactionCount > 0
? liveUncategorizedTransactionCount
: item.href === '/pending' && pendingOperationsCount > 0
? pendingOperationsCount
: null
@@ -614,8 +679,8 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
{mobileNavItems.map((item) => {
const active = isActive(item.href)
const enabled = isItemEnabled(item.href)
const badge = item.href === '/transactions' && uncategorizedTransactionCount > 0
? uncategorizedTransactionCount
const badge = item.href === '/transactions' && liveUncategorizedTransactionCount > 0
? liveUncategorizedTransactionCount
: null
const content = (
@@ -719,8 +784,8 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
const active = isActive(item.href)
const enabled = isItemEnabled(item.href)
const badge =
item.href === '/transactions' && uncategorizedTransactionCount > 0
? uncategorizedTransactionCount
item.href === '/transactions' && liveUncategorizedTransactionCount > 0
? liveUncategorizedTransactionCount
: item.href === '/pending' && pendingOperationsCount > 0
? pendingOperationsCount
: null
@@ -776,8 +841,8 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
const Icon = item.icon
const active = isActive(item.href)
const enabled = isItemEnabled(item.href) && !item.comingSoon
const badge = item.href === '/transactions' && uncategorizedTransactionCount > 0
? uncategorizedTransactionCount
const badge = item.href === '/transactions' && liveUncategorizedTransactionCount > 0
? liveUncategorizedTransactionCount
: item.href === '/pending' && pendingOperationsCount > 0
? pendingOperationsCount
: null
+17
View File
@@ -0,0 +1,17 @@
'use client'
import { useState } from 'react'
import type { SupabaseClient } from '@supabase/supabase-js'
import { createClient } from '@/lib/supabase/client'
/**
* Stable browser Supabase client for realtime-enabled client components.
*
* The browser client itself is shared with normal data fetching; this hook
* lazily creates the instance once so components can wire subscriptions
* without recreating the client on every render.
*/
export function useRealtimeSupabase(): SupabaseClient {
const [supabase] = useState(() => createClient())
return supabase
}
@@ -0,0 +1,26 @@
-- Migration: stream transactions changes via Supabase realtime
--
-- The dashboard sidebar transaction badge now listens to postgres_changes
-- on public.transactions. Adding the table to supabase_realtime lets the
-- browser receive INSERT/UPDATE/DELETE events and keep the uncategorized
-- count in sync without a manual refresh.
--
-- RLS already scopes transactions to the active company, 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 = 'transactions'
) THEN
ALTER PUBLICATION supabase_realtime ADD TABLE public.transactions;
END IF;
END $$;
NOTIFY pgrst, 'reload schema';