From a8072f64233b5060cfe35943e218f333dd3eb687 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg <149234542+jakobwennberg@users.noreply.github.com> Date: Tue, 30 Jun 2026 08:58:01 +0200 Subject: [PATCH] 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 * 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) * style(dashboard): fix indentation in collapsed-nav badge block --------- Signed-off-by: Esaias Westberg Co-authored-by: Esaias Westberg Co-authored-by: Claude Opus 4.8 (1M context) --- app/(dashboard)/transactions/page.tsx | 221 +++++++++++------- components/dashboard/DashboardNav.tsx | 87 ++++++- lib/hooks/use-realtime-supabase.ts | 17 ++ ...0000_transactions_realtime_publication.sql | 26 +++ 4 files changed, 258 insertions(+), 93 deletions(-) create mode 100644 lib/hooks/use-realtime-supabase.ts create mode 100644 supabase/migrations/20260629180000_transactions_realtime_publication.sql diff --git a/app/(dashboard)/transactions/page.tsx b/app/(dashboard)/transactions/page.tsx index 43e83908..ff6c46d7 100644 --- a/app/(dashboard)/transactions/page.tsx +++ b/app/(dashboard)/transactions/page.tsx @@ -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([]) 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(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(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 = {} @@ -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=. 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) diff --git a/components/dashboard/DashboardNav.tsx b/components/dashboard/DashboardNav.tsx index d4ef0019..1fb00f12 100644 --- a/components/dashboard/DashboardNav.tsx +++ b/components/dashboard/DashboardNav.tsx @@ -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 | 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 diff --git a/lib/hooks/use-realtime-supabase.ts b/lib/hooks/use-realtime-supabase.ts new file mode 100644 index 00000000..d664d4e8 --- /dev/null +++ b/lib/hooks/use-realtime-supabase.ts @@ -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 +} diff --git a/supabase/migrations/20260629180000_transactions_realtime_publication.sql b/supabase/migrations/20260629180000_transactions_realtime_publication.sql new file mode 100644 index 00000000..d935acaa --- /dev/null +++ b/supabase/migrations/20260629180000_transactions_realtime_publication.sql @@ -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';