diff --git a/app/(dashboard)/import/page.tsx b/app/(dashboard)/import/page.tsx index 0acf6734..74e74e15 100644 --- a/app/(dashboard)/import/page.tsx +++ b/app/(dashboard)/import/page.tsx @@ -4,7 +4,7 @@ import { useState, useCallback, useEffect } from 'react' import { useSearchParams, useRouter } from 'next/navigation' import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs' import { useTranslations } from 'next-intl' -import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card' +import { Card, CardContent } from '@/components/ui/card' import { Progress } from '@/components/ui/progress' import { Button } from '@/components/ui/button' import { useToast } from '@/components/ui/use-toast' @@ -14,11 +14,8 @@ import { cn, formatDate } from '@/lib/utils' import { createClient } from '@/lib/supabase/client' import { useCompany, useCapability } from '@/contexts/CompanyContext' import { CAPABILITY } from '@/lib/entitlements/keys' -import { UpgradeNote } from '@/components/billing/UpgradeNote' -import { BankSelector, type Bank } from '@/extensions/general/enable-banking/components/BankSelector' -import { BankConnectionStatus } from '@/extensions/general/enable-banking/components/BankConnectionStatus' import { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui/destructive-confirm-dialog' -import type { BankConnection } from '@/types' +import { getSettingsPanel } from '@/lib/extensions/settings-panel-registry' // Bank file import components import BankFileUploadStep from '@/components/import/BankFileUploadStep' @@ -1904,201 +1901,14 @@ function CSVDataImportWizard() { } // ============================================================ -// PSD2 Bank Connection (inline, from Enable Banking extension) +// Banking (PSD2) connect UI // ============================================================ - -function PSD2ConnectWizard() { - const { toast } = useToast() - const supabase = createClient() - const { dialogProps, confirm } = useDestructiveConfirm() - const { company } = useCompany() - const hasBankSync = useCapability(CAPABILITY.bank_sync) - - const [bankConnections, setBankConnections] = useState([]) - const [syncingConnectionId, setSyncingConnectionId] = useState(null) - const [isConnecting, setIsConnecting] = useState(false) - const [connectingBankName, setConnectingBankName] = useState(null) - const [isLoading, setIsLoading] = useState(true) - - useEffect(() => { - fetchConnections() - }, []) - - async function fetchConnections() { - setIsLoading(true) - const { data: { user } } = await supabase.auth.getUser() - if (!user) return - - if (!company) return - - const { data: connections } = await supabase - .from('bank_connections') - .select('*') - .eq('company_id', company.id) - .order('created_at', { ascending: false }) - - setBankConnections(connections || []) - setIsLoading(false) - } - - async function handleConnectBank(bank: Bank) { - setIsConnecting(true) - setConnectingBankName(bank.name) - - try { - const response = await fetch('/api/extensions/ext/enable-banking/connect', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ aspsp_name: bank.name, aspsp_country: bank.country }), - }) - - const data = await response.json() - - if (!response.ok) { - throw new Error(data.error) - } - - window.location.href = data.authorization_url - } catch (error) { - toast({ - title: 'Kunde inte ansluta bank', - description: error instanceof Error ? error.message : 'Försök igen.', - variant: 'destructive', - }) - setIsConnecting(false) - setConnectingBankName(null) - } - } - - async function handleSyncTransactions(connectionId: string) { - setSyncingConnectionId(connectionId) - - try { - const response = await fetch('/api/extensions/ext/enable-banking/sync', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ connection_id: connectionId }), - }) - - const data = await response.json() - - if (!response.ok) { - throw new Error(data.error) - } - - toast({ - title: 'Synkronisering klar', - description: `${data.imported} nya transaktioner importerade`, - }) - - fetchConnections() - } catch (error) { - toast({ - title: 'Synkronisering misslyckades', - description: error instanceof Error ? error.message : 'Försök igen.', - variant: 'destructive', - }) - } - - setSyncingConnectionId(null) - } - - async function handleDisconnectBank(connectionId: string) { - const ok = await confirm({ - title: 'Koppla bort bank?', - description: 'PSD2-samtycket kommer återkallas. Befintliga transaktioner påverkas inte.', - confirmLabel: 'Koppla bort', - variant: 'warning', - }) - if (!ok) return - - try { - const response = await fetch('/api/extensions/ext/enable-banking/disconnect', { - method: 'DELETE', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ connection_id: connectionId }), - }) - - if (!response.ok) { - const data = await response.json() - throw new Error(data.error || 'Disconnect failed') - } - - toast({ - title: 'Bank bortkopplad', - description: 'Bankanslutningen och PSD2-samtycket har återkallats', - }) - fetchConnections() - } catch (error) { - toast({ - title: 'Kunde inte koppla bort bank', - description: error instanceof Error ? error.message : 'Försök igen.', - variant: 'destructive', - }) - } - } - - if (isLoading) { - return ( -
- -
- ) - } - - const activeConnections = bankConnections.filter((c) => c.status === 'active') - - return ( -
- - - {/* Connected banks */} - {activeConnections.length > 0 && ( - - - Anslutna banker - - - {activeConnections.map((connection) => ( - - ))} - - - )} - - {/* Connect new bank. Non-payers see the card but the bank list is - replaced by an upgrade note: the server gate would 403 the connect. */} - - - Anslut din bank - - Välj din bank nedan för att koppla ditt konto via PSD2. Transaktioner synkas automatiskt varje dag. - - - - {!hasBankSync ? ( - - Automatisk banksynk kräver ett abonnemang. Du kan fortfarande importera - transaktioner manuellt via bankfiler nedan. - - ) : ( - - )} - - -
- ) -} +// Provided by the enable-banking extension and loaded through the settings +// panel registry (dynamic import), so this core page never imports from +// @/extensions directly. The shared panel renders every connection state +// (pending account selection, active, expiring, and expired/error with the +// reconnect entry point), which the old inline wizard here did not. +const BankingPanel = getSettingsPanel('enable-banking') // ============================================================ // Import Page with Selection Cards @@ -2475,7 +2285,25 @@ export default function ImportPage() { )} - {mode === 'psd2' && } + {mode === 'psd2' && ( + hasBankingExtension && BankingPanel ? ( + + ) : ( + + + +

Bankintegration (PSD2) är inte aktiverad

+

+ Aktivera tillägget Enable Banking för att koppla ditt bankkonto, eller importera + transaktioner manuellt via bankfil. +

+ +
+
+ ) + )} {mode === 'bank' && } {mode === 'sie' && } {mode === 'csv_data' && } diff --git a/components/settings/sections/BankingSettingsContent.tsx b/components/settings/sections/BankingSettingsContent.tsx index 46447cb1..2a277b5b 100644 --- a/components/settings/sections/BankingSettingsContent.tsx +++ b/components/settings/sections/BankingSettingsContent.tsx @@ -1,6 +1,6 @@ 'use client' -import { useState, useEffect, useRef } from 'react' +import { useState, useEffect } from 'react' import { useTranslations } from 'next-intl' import { useSearchParams, useRouter } from 'next/navigation' import Link from 'next/link' @@ -23,92 +23,36 @@ export function BankingSettingsContent() { const [failedBankName, setFailedBankName] = useState(null) const [isAccessDenied, setIsAccessDenied] = useState(false) const [showHbPoaHint, setShowHbPoaHint] = useState(false) - const syncInitiatedRef = useRef(false) - const abortControllerRef = useRef(null) - const unmountedRef = useRef(false) const hasBankingExtension = ENABLED_EXTENSION_IDS.has('enable-banking') + // Surface a bank connection/authorization failure that the OAuth callback + // bounced back as `?bank_error=...`. The success path is handled by the + // callback redirecting to `?select_accounts=`, which the banking panel + // picks up to open account selection: there is no `bank_connected` param. useEffect(() => { - return () => { - unmountedRef.current = true - if (abortControllerRef.current) abortControllerRef.current.abort() - } - }, []) - - useEffect(() => { - const bankConnected = searchParams.get('bank_connected') const bankError = searchParams.get('bank_error') + if (!bankError) return - if (bankConnected === 'true' && !syncInitiatedRef.current) { - syncInitiatedRef.current = true - const connectionId = searchParams.get('connection_id') - router.replace('/settings/banking') - - if (connectionId) { - toast({ - title: t('sync_start_title'), - description: t('sync_start_description'), - }) - const controller = new AbortController() - abortControllerRef.current = controller - const syncTimeout = setTimeout(() => controller.abort(), 120_000) - - ;(async () => { - try { - const res = await fetch('/api/extensions/ext/enable-banking/sync', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ connection_id: connectionId, days_back: 120 }), - signal: controller.signal, - }) - clearTimeout(syncTimeout) - const data = await res.json() - if (res.ok) { - if (!unmountedRef.current) { - toast({ - title: t('sync_success_title'), - description: t('sync_success_description', { count: data.imported ?? 0 }), - }) - } - } else { - throw new Error(data.error || 'Sync failed') - } - } catch (err) { - clearTimeout(syncTimeout) - if (unmountedRef.current) return - if (controller.signal.aborted) { - toast({ - title: t('sync_timeout_title'), - description: t('sync_timeout_description'), - }) - } else { - toast({ - title: t('sync_failed_title'), - description: err instanceof Error ? err.message : t('sync_failed_default'), - variant: 'destructive', - }) - } - } - })() - } else { - toast({ - title: t('sync_success_title'), - description: t('sync_success_no_id_description'), - }) - } + let errorMsg: string + try { errorMsg = decodeURIComponent(bankError) } catch { errorMsg = bankError } + const bankName = searchParams.get('bank_name') + const errorCode = searchParams.get('bank_error_code') + const psuType = searchParams.get('psu_type') + // The bank often returns a bare "server_error" with no description: show a + // human message instead of the raw OAuth error code. + if (errorCode === 'server_error' && errorMsg === 'server_error') { + errorMsg = t('bank_server_error') } - if (bankError) { - let errorMsg: string - try { errorMsg = decodeURIComponent(bankError) } catch { errorMsg = bankError } - const bankName = searchParams.get('bank_name') - const errorCode = searchParams.get('bank_error_code') - const psuType = searchParams.get('psu_type') - // The bank often returns a bare "server_error" with no description — - // show a human message instead of the raw OAuth error code. - if (errorCode === 'server_error' && errorMsg === 'server_error') { - errorMsg = t('bank_server_error') - } + // Consume the one-shot ?bank_error= param off the render path: a microtask + // defers these updates out of the effect body (react-hooks/set-state-in- + // effect) without a user-visible delay, since the param appears at most + // once per OAuth bounce-back. The cancellation flag drops the deferred work + // if the effect re-runs or the component unmounts before it flushes (also + // suppresses a duplicate toast under StrictMode's dev double-invoke). + let cancelled = false + queueMicrotask(() => { + if (cancelled) return toast({ title: t('connect_failed_title'), description: errorMsg, @@ -119,12 +63,13 @@ export function BankingSettingsContent() { if (errorCode === 'access_denied') setIsAccessDenied(true) // Handelsbanken rejects business connects with server_error when the // company hasn't registered the open banking fullmakt ("Internet - // Företag – tilläggstjänst API Företag") — surface the fix steps. + // Företag – tilläggstjänst API Företag"): surface the fix steps. if (bankName === 'Handelsbanken' && psuType === 'business' && errorCode === 'server_error') { setShowHbPoaHint(true) } router.replace('/settings/banking') - } + }) + return () => { cancelled = true } }, [searchParams, router, toast, t]) return ( diff --git a/components/transactions/BankSyncNowButton.tsx b/components/transactions/BankSyncNowButton.tsx index f9eb9210..68a897b2 100644 --- a/components/transactions/BankSyncNowButton.tsx +++ b/components/transactions/BankSyncNowButton.tsx @@ -14,6 +14,7 @@ import { DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' import { createClient } from '@/lib/supabase/client' +import { notifyBankSyncUpdated } from '@/lib/transactions/bank-sync-signal' import { useCompany, useCapability } from '@/contexts/CompanyContext' import { CAPABILITY } from '@/lib/entitlements/keys' @@ -129,6 +130,9 @@ export default function BankSyncNowButton() { ? t('bank_sync_new_since_last_visit_one') : t('bank_sync_new_since_last_visit_many', { count: data.imported ?? 0 }), }) + // Tell the neighbouring status chip to refetch so it doesn't keep showing + // the pre-sync "synced Nd ago" until a hard reload. + notifyBankSyncUpdated() router.refresh() } catch (error) { toast({ diff --git a/components/transactions/BankSyncStatusChip.tsx b/components/transactions/BankSyncStatusChip.tsx index 695d0bc8..e610c65f 100644 --- a/components/transactions/BankSyncStatusChip.tsx +++ b/components/transactions/BankSyncStatusChip.tsx @@ -5,6 +5,7 @@ import Link from 'next/link' import { useTranslations } from 'next-intl' import { AlertTriangle, RefreshCw } from 'lucide-react' import { createClient } from '@/lib/supabase/client' +import { onBankSyncUpdated } from '@/lib/transactions/bank-sync-signal' import { useCompany } from '@/contexts/CompanyContext' import { Tooltip, @@ -72,17 +73,31 @@ export default function BankSyncStatusChip() { useEffect(() => { if (!company?.id) return + const companyId = company.id let cancelled = false const supabase = createClient() - supabase - .from('bank_connections') - .select('id, status, last_synced_at') - .eq('company_id', company.id) - .then(({ data }) => { - if (!cancelled) setRows(data ?? []) - }) + + const load = () => { + supabase + .from('bank_connections') + .select('id, status, last_synced_at') + .eq('company_id', companyId) + .then(({ data, error }) => { + if (cancelled) return + // On error keep the chip hidden (empty rows) rather than rendering a + // false "healthy" state from stale data. + setRows(error ? [] : (data ?? [])) + }) + } + + load() + // Refetch when a manual "Sync now" / reconnect elsewhere on the page + // changes the connections, so the chip doesn't keep showing "synced 2d ago". + const unsubscribe = onBankSyncUpdated(load) + return () => { cancelled = true + unsubscribe() } }, [company?.id]) diff --git a/extensions/general/enable-banking/components/AccountPickerDialog.tsx b/extensions/general/enable-banking/components/AccountPickerDialog.tsx index 7723175e..d168b88a 100644 --- a/extensions/general/enable-banking/components/AccountPickerDialog.tsx +++ b/extensions/general/enable-banking/components/AccountPickerDialog.tsx @@ -88,8 +88,10 @@ export function AccountPickerDialog({ const [isSaving, setIsSaving] = useState(false) const [sieLastDate, setSieLastDate] = useState(null) const [chartAccounts, setChartAccounts] = useState([]) + const [chartError, setChartError] = useState(false) const [ledgerByUid, setLedgerByUid] = useState>({}) const [companySettings, setCompanySettings] = useState | null>(null) + const [settingsLoaded, setSettingsLoaded] = useState(false) const [lookbackMode, setLookbackMode] = useState('fiscal-year') const [customSubMode, setCustomSubMode] = useState('date') @@ -97,9 +99,17 @@ export function AccountPickerDialog({ const [progressOpen, setProgressOpen] = useState(false) const [progressState, setProgressState] = useState({ kind: 'syncing' }) + // Bumped on each new backfill so the progress dialog is keyed per attempt and + // remounts fresh: the dialog stays mounted across attempts, so without this a + // second sync would inherit the previous run's elapsed timer for a frame and + // briefly compute overGrace/blockClose from stale state. + const [syncAttempt, setSyncAttempt] = useState(0) useEffect(() => { if (open) { + // Re-arm the "settings loaded" gate each open so the fiscal-year label + // doesn't flash last-open's resolved date before this open's fetch lands. + setSettingsLoaded(false) const initial = new Set( accounts.filter(a => a.enabled !== false).map(a => a.uid) ) @@ -140,6 +150,7 @@ export function AccountPickerDialog({ .maybeSingle() if (cancelled) return setCompanySettings((data as { fiscal_year_start_month?: number; entity_type?: CompanySettings['entity_type'] } | null) as Pick | null) + setSettingsLoaded(true) })() return () => { cancelled = true } }, [open, company?.id, supabase]) @@ -174,13 +185,20 @@ export function AccountPickerDialog({ if (!open || !company?.id) return let cancelled = false ;(async () => { - const { data } = await supabase + const { data, error } = await supabase .from('chart_of_accounts') .select('account_number, account_name') .eq('company_id', company.id) .like('account_number', '19%') .order('account_number', { ascending: true }) if (cancelled) return + if (error) { + // Surface the failure: without the 19xx chart the ledger picker is + // silently empty, which reads as "no bank accounts exist". + setChartError(true) + return + } + setChartError(false) setChartAccounts((data as ChartAccount[] | null) || []) })() return () => { cancelled = true } @@ -268,10 +286,18 @@ export function AccountPickerDialog({ setIsSaving(true) + // Cap the client wait at the route's 300s budget so a hung backfill can't + // leave the progress modal in 'syncing' forever. The save+backfill is one + // request; on abort we don't know if it finished, so the message stays + // neutral and the parent refetch reflects the true state. + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), 300_000) + // For the initial-selection path, open the progress modal up-front so the // user has visible feedback during the 30-60s backfill. Selection edits // (no backfill) keep the existing toast-only feedback. if (isInitialSelection) { + setSyncAttempt((n) => n + 1) setProgressState({ kind: 'syncing' }) setProgressOpen(true) onOpenChange(false) @@ -294,6 +320,7 @@ export function AccountPickerDialog({ account_mappings, ...(isInitialSelection && lookback.body ? lookback.body : {}), }), + signal: controller.signal, }) const data = await response.json() @@ -319,17 +346,21 @@ export function AccountPickerDialog({ onSaved() } catch (error) { - const message = error instanceof Error ? error.message : 'Kunde inte spara kontoval' + const aborted = controller.signal.aborted + const message = aborted + ? 'Det tar längre tid än vanligt. Vi slutför i bakgrunden: uppdatera sidan om en stund.' + : (error instanceof Error ? error.message : 'Kunde inte spara kontoval') if (isInitialSelection) { setProgressState({ kind: 'failed', error: { message } }) } else { toast({ - title: 'Fel', + title: aborted ? 'Tar längre tid än vanligt' : 'Fel', description: message, - variant: 'destructive', + variant: aborted ? undefined : 'destructive', }) } } finally { + clearTimeout(timeout) setIsSaving(false) } } @@ -372,6 +403,7 @@ export function AccountPickerDialog({ return ( <> { setProgressOpen(next) @@ -459,7 +491,7 @@ export function AccountPickerDialog({ Sedan räkenskapsårets början - från {fiscalYearStart} + från {settingsLoaded ? fiscalYearStart : '…'} @@ -489,7 +521,7 @@ export function AccountPickerDialog({ Specifikt datum - Föregående räkenskapsårets start ({previousFiscalYearStart}) + Föregående räkenskapsårets start ({settingsLoaded ? previousFiscalYearStart : '…'}) @@ -558,6 +590,12 @@ export function AccountPickerDialog({ )} + {chartError && ( +
+ Kunde inte ladda bokföringskonton (19xx). Ladda om sidan och försök igen innan du sparar kontoval. +
+ )} +
{sortedAccounts.map(account => { const isChecked = selected.has(account.uid) diff --git a/extensions/general/enable-banking/components/BankSelector.tsx b/extensions/general/enable-banking/components/BankSelector.tsx index fe1293c3..d1782204 100644 --- a/extensions/general/enable-banking/components/BankSelector.tsx +++ b/extensions/general/enable-banking/components/BankSelector.tsx @@ -1,6 +1,7 @@ 'use client' import { useEffect, useRef, useState } from 'react' +import { Loader2 } from 'lucide-react' import { cn } from '@/lib/utils' export interface Bank { @@ -49,7 +50,7 @@ function BankCard({ bank, isConnecting, connectingBankName, onConnect }: { >
{connecting ? ( -
+ ) : bank.logo ? ( -
+
)} @@ -221,7 +230,7 @@ export function BankSelector({ {/* Connecting overlay */} {isConnecting && connectingBankName && (
-
+ Ansluter till {connectingBankName}...
)} diff --git a/extensions/general/enable-banking/components/BankSyncProgressDialog.tsx b/extensions/general/enable-banking/components/BankSyncProgressDialog.tsx index 1b3ced7a..8c7bded6 100644 --- a/extensions/general/enable-banking/components/BankSyncProgressDialog.tsx +++ b/extensions/general/enable-banking/components/BankSyncProgressDialog.tsx @@ -1,5 +1,6 @@ 'use client' +import { useEffect, useState } from 'react' import Link from 'next/link' import { Dialog, @@ -39,6 +40,16 @@ interface BankSyncProgressDialogProps { state: SyncProgressState } +// Past this point the sync has run longer than the promised "up to a minute". +// We stop hard-locking the modal so the user isn't trapped: the request keeps +// running server-side (idempotent) and completion still resolves the state. +const GRACE_SEC = 75 + +function formatElapsed(sec: number): string { + if (sec < 60) return `${sec}s` + return `${Math.floor(sec / 60)}m ${String(sec % 60).padStart(2, '0')}s` +} + export function BankSyncProgressDialog({ open, onOpenChange, @@ -46,27 +57,47 @@ export function BankSyncProgressDialog({ accounts, state, }: BankSyncProgressDialogProps) { - // Close-prevention while sync is in flight is handled inline below via the - // onOpenChange guard + onPointerDownOutside + onEscapeKeyDown handlers. - const enabledAccounts = accounts.filter((a) => a.enabled !== false) + // Tick a visible elapsed counter while syncing so a slow bank doesn't look + // frozen, and so we know when to release the close-lock (GRACE_SEC). State is + // only ever set from the timer callbacks (never synchronously in the effect + // body), and elapsed is never computed from Date.now() during render, so this + // stays clear of the react-hooks purity rules. + const [elapsedSec, setElapsedSec] = useState(0) + useEffect(() => { + if (!open || state.kind !== 'syncing') return + const started = Date.now() + const tick = () => setElapsedSec(Math.max(0, Math.floor((Date.now() - started) / 1000))) + // Reset to ~0 on the next tick (async, so not a synchronous effect setState). + const reset = setTimeout(tick, 0) + const id = setInterval(tick, 1000) + return () => { + clearTimeout(reset) + clearInterval(id) + } + }, [open, state.kind]) + + const overGrace = state.kind === 'syncing' && elapsedSec >= GRACE_SEC + // Only hard-block the close affordances during the expected window; after the + // grace period the user may background the (still-running) sync. + const blockClose = state.kind === 'syncing' && !overGrace + return ( { - // Block manual close mid-sync - if (!next && state.kind === 'syncing') return + if (!next && blockClose) return onOpenChange(next) }} > { - if (state.kind === 'syncing') e.preventDefault() + if (blockClose) e.preventDefault() }} onEscapeKeyDown={(e) => { - if (state.kind === 'syncing') e.preventDefault() + if (blockClose) e.preventDefault() }} > @@ -77,10 +108,17 @@ export function BankSyncProgressDialog({ {state.kind === 'syncing' && ( - <> - Vi hämtar transaktioner från {enabledAccounts.length}{' '} - {enabledAccounts.length === 1 ? 'konto' : 'konton'}. Detta kan ta upp till en minut. Stäng inte fönstret. - + overGrace ? ( + <> + Det tar längre tid än vanligt. Vi fortsätter i bakgrunden: du kan + stänga rutan och komma tillbaka senare. + + ) : ( + <> + Vi hämtar transaktioner från {enabledAccounts.length}{' '} + {enabledAccounts.length === 1 ? 'konto' : 'konton'}. Detta kan ta upp till en minut. Stäng inte fönstret. + + ) )} {state.kind === 'done' && ( <> @@ -96,8 +134,11 @@ export function BankSyncProgressDialog({ {state.kind === 'syncing' && (
-
+
+ + {formatElapsed(elapsedSec)} +
    {enabledAccounts.map((a) => ( @@ -124,9 +165,11 @@ export function BankSyncProgressDialog({ diff --git a/extensions/general/enable-banking/components/BankingSettingsPanel.tsx b/extensions/general/enable-banking/components/BankingSettingsPanel.tsx index b3e28c22..09a286c9 100644 --- a/extensions/general/enable-banking/components/BankingSettingsPanel.tsx +++ b/extensions/general/enable-banking/components/BankingSettingsPanel.tsx @@ -9,6 +9,7 @@ import { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui import { AlertTriangle, Loader2, Upload } from 'lucide-react' import { cn } from '@/lib/utils' import { createClient } from '@/lib/supabase/client' +import { notifyBankSyncUpdated } from '@/lib/transactions/bank-sync-signal' import { useCompany, useCapability } from '@/contexts/CompanyContext' import { CAPABILITY } from '@/lib/entitlements/keys' import { UpgradeNote } from '@/components/billing/UpgradeNote' @@ -37,6 +38,8 @@ export default function BankingSettingsPanel() { const connectingRef = useRef(false) const releaseTimerRef = useRef | null>(null) const [isLoading, setIsLoading] = useState(true) + const [loadError, setLoadError] = useState(false) + const hasLoadedRef = useRef(false) const [showCsvFallback, setShowCsvFallback] = useState(false) const [psuType, setPsuType] = useState<'personal' | 'business'>('business') const [pickerConnectionId, setPickerConnectionId] = useState(null) @@ -80,35 +83,55 @@ export default function BankingSettingsPanel() { } async function fetchConnections() { - setIsLoading(true) - const { data: { user } } = await supabase.auth.getUser() - if (!user) return - if (!company) return - - const { data: connections } = await supabase - .from('bank_connections') - .select('*') - .eq('company_id', company.id) - .order('created_at', { ascending: false }) - - setBankConnections(connections || []) - - // If a pending connection exists from a recent attempt (e.g. user bounced back from - // the bank's auth page), keep the connect button disabled until the server-side lock expires. - const freshPending = (connections || []).find((c) => c.status === 'pending') - if (freshPending) { - const age = Date.now() - new Date(freshPending.created_at).getTime() - const remaining = PENDING_LOCK_MS - age - if (remaining > 0) { - connectingRef.current = true - setIsConnecting(true) - setConnectingBankName(freshPending.bank_name) - if (releaseTimerRef.current) clearTimeout(releaseTimerRef.current) - releaseTimerRef.current = setTimeout(releaseConnectingLock, remaining) + // Only the first load blanks the panel to a spinner. Later refetches (after + // a sync, disconnect, or account save) refresh in the background so the + // panel doesn't flash back to a full-height spinner and lose scroll + // position on every action. + if (!hasLoadedRef.current) setIsLoading(true) + setLoadError(false) + try { + const { data: { user } } = await supabase.auth.getUser() + if (!user || !company) { + setBankConnections([]) + return } - } - setIsLoading(false) + const { data: connections, error } = await supabase + .from('bank_connections') + .select('*') + .eq('company_id', company.id) + .order('created_at', { ascending: false }) + + if (error) { + // Surface the failure instead of rendering an empty panel: an empty + // panel reads as "your bank got disconnected" when it's really a + // transient fetch/RLS error. + setLoadError(true) + return + } + + setBankConnections(connections || []) + + // If a pending connection exists from a recent attempt (e.g. user bounced back from + // the bank's auth page), keep the connect button disabled until the server-side lock expires. + const freshPending = (connections || []).find((c) => c.status === 'pending') + if (freshPending) { + const age = Date.now() - new Date(freshPending.created_at).getTime() + const remaining = PENDING_LOCK_MS - age + if (remaining > 0) { + connectingRef.current = true + setIsConnecting(true) + setConnectingBankName(freshPending.bank_name) + if (releaseTimerRef.current) clearTimeout(releaseTimerRef.current) + releaseTimerRef.current = setTimeout(releaseConnectingLock, remaining) + } + } + } finally { + // Always clear the spinner, even on the early `!user || !company` return, + // so an expired session can't leave the panel spinning forever. + hasLoadedRef.current = true + setIsLoading(false) + } } async function handleConnectBank(bank: Bank, psuTypeOverride?: 'personal' | 'business') { @@ -220,6 +243,13 @@ export default function BankingSettingsPanel() { async function handleSyncTransactions(connectionId: string) { setSyncingConnectionId(connectionId) + // A slow bank can hold the request open up to the route's 300s budget. + // Cap the client wait so the spinner can't hang indefinitely; the sync is + // idempotent (imports dedup), so a background completion or manual retry is + // safe. + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), 180_000) + try { console.log('[enable-banking] Starting sync', { connectionId }) @@ -227,6 +257,7 @@ export default function BankingSettingsPanel() { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ connection_id: connectionId }), + signal: controller.signal, }) const data = await response.json() @@ -253,25 +284,35 @@ export default function BankingSettingsPanel() { }) setShowCsvFallback(false) + notifyBankSyncUpdated() fetchConnections() } catch (error) { - console.error('[enable-banking] Sync flow failed', { - message: error instanceof Error ? error.message : String(error), - stack: error instanceof Error ? error.stack : undefined, - connectionId, - }) - toast({ - title: 'Fel', - description: error instanceof Error ? error.message : 'Synkronisering misslyckades', - variant: 'destructive', - }) - setShowCsvFallback(true) - // Refresh so a now-expired connection (e.g. closed PSD2 session) moves - // into "Åtgärd krävs" and surfaces the "Förnya anslutning" button. - fetchConnections() + if (controller.signal.aborted) { + toast({ + title: 'Synkronisering tar längre tid än vanligt', + description: 'Transaktionerna hämtas i bakgrunden. Uppdatera sidan om en stund.', + }) + fetchConnections() + } else { + console.error('[enable-banking] Sync flow failed', { + message: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + connectionId, + }) + toast({ + title: 'Fel', + description: error instanceof Error ? error.message : 'Synkronisering misslyckades', + variant: 'destructive', + }) + setShowCsvFallback(true) + // Refresh so a now-expired connection (e.g. closed PSD2 session) moves + // into "Åtgärd krävs" and surfaces the "Förnya anslutning" button. + fetchConnections() + } + } finally { + clearTimeout(timeout) + setSyncingConnectionId(null) } - - setSyncingConnectionId(null) } async function handleDisconnectBank(connectionId: string) { @@ -331,6 +372,31 @@ export default function BankingSettingsPanel() { ) } + // First-load failure: show a recoverable error instead of an empty panel (a + // blank panel misreads as "no banks connected"). A background-refetch failure + // keeps the already-loaded connections visible instead of wiping them. + if (loadError && bankConnections.length === 0) { + return ( + + + Kunde inte ladda bankanslutningar + + Något gick fel när dina bankanslutningar skulle hämtas. Dina anslutningar + och transaktioner är oförändrade. + + + + + + + + ) + } + const activeConnections = bankConnections.filter((c) => c.status === 'active') const pendingSelectionConnections = bankConnections.filter((c) => c.status === 'pending_selection') const actionRequiredConnections = bankConnections.filter((c) => ['expired', 'error'].includes(c.status)) diff --git a/lib/transactions/bank-sync-signal.ts b/lib/transactions/bank-sync-signal.ts new file mode 100644 index 00000000..384606c2 --- /dev/null +++ b/lib/transactions/bank-sync-signal.ts @@ -0,0 +1,26 @@ +/** + * Cross-component signal that a bank sync (or reconnect) just changed + * `bank_connections` rows or imported new transactions. + * + * Several surfaces render bank-sync status from a client-side fetch on mount + * (the transactions-page status chip, the "since last visit" pill, the banking + * settings panel). Without a shared signal they show stale data until a hard + * reload: e.g. a manual "Sync now" pulls fresh rows but the neighbouring chip + * keeps showing the old "synced 2d ago" until the page is reloaded. + * + * Sync entry points call `notifyBankSyncUpdated()` on success; status surfaces + * subscribe with `onBankSyncUpdated()` and refetch. This is a browser-only + * CustomEvent, so it is a no-op during SSR. + */ +export const BANK_SYNC_UPDATED_EVENT = 'gnubok:bank-sync-updated' + +export function notifyBankSyncUpdated(): void { + if (typeof window === 'undefined') return + window.dispatchEvent(new Event(BANK_SYNC_UPDATED_EVENT)) +} + +export function onBankSyncUpdated(handler: () => void): () => void { + if (typeof window === 'undefined') return () => {} + window.addEventListener(BANK_SYNC_UPDATED_EVENT, handler) + return () => window.removeEventListener(BANK_SYNC_UPDATED_EVENT, handler) +} diff --git a/messages/en.json b/messages/en.json index 1c6ca6a7..a606e3ec 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1280,15 +1280,6 @@ }, "settings_api": {}, "settings_banking": { - "sync_start_title": "Syncing transactions...", - "sync_start_description": "Fetching transactions from your bank in the background.", - "sync_success_title": "Bank connected!", - "sync_success_description": "{count} transactions imported", - "sync_success_no_id_description": "Your bank is now linked.", - "sync_timeout_title": "Sync took too long", - "sync_timeout_description": "Transactions are being fetched in the background. Reload the page in a moment.", - "sync_failed_title": "Sync failed", - "sync_failed_default": "Could not fetch transactions", "connect_failed_title": "Connection failed", "bank_server_error": "The bank rejected the connection.", "hb_business_poa_hint": "Handelsbanken requires a power of attorney before business accounts can be connected: log in to the corporate online bank, go to Powers of attorney → Self-service services and enable \"Internet Företag – tilläggstjänst API Företag\". Then link the power of attorney to the person who will approve the connection and try again.", diff --git a/messages/sv.json b/messages/sv.json index aa76c9c6..bddd724a 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -1280,15 +1280,6 @@ }, "settings_api": {}, "settings_banking": { - "sync_start_title": "Synkroniserar transaktioner...", - "sync_start_description": "Hämtar transaktioner från din bank i bakgrunden.", - "sync_success_title": "Bank ansluten!", - "sync_success_description": "{count} transaktioner importerade", - "sync_success_no_id_description": "Din bank är nu kopplad.", - "sync_timeout_title": "Synkronisering tog för lång tid", - "sync_timeout_description": "Transaktionerna hämtas i bakgrunden. Ladda om sidan om en stund.", - "sync_failed_title": "Synkronisering misslyckades", - "sync_failed_default": "Kunde inte hämta transaktioner", "connect_failed_title": "Anslutning misslyckades", "bank_server_error": "Banken avvisade anslutningen.", "hb_business_poa_hint": "Handelsbanken kräver en fullmakt innan företagskonton kan kopplas: logga in i internetbanken för företag, gå till Fullmakter → Självbetjäningstjänster och aktivera \"Internet Företag – tilläggstjänst API Företag\". Koppla sedan fullmakten till den person som ska godkänna anslutningen och försök igen.",