Files
accounted/extensions/general/enable-banking/components/BankConnectionStatus.tsx
T
Jakob Wennberg 1ded1af8fe fix(enable-banking): one primary action per connection state on /settings/banking (#1727)
Restructure the banking settings page so every connection state has a
clear hierarchy:

- One "Dina bankkopplingar" group sorted by state precedence
  (pending_selection, pending, error, expired, expiring soon, active),
  replacing the three-way group split. State derivation, sorting and
  worst-state selection live in a pure, unit-tested helper
  (lib/connection-state.ts).
- Exactly one page-level .attn sentence for the worst state, or none;
  the BankSyncStatusChip is removed from this page (it linked to
  itself; it stays on /transactions and /import).
- Each row shows one primary action per state (Valj konton, Forsok
  igen, Fornya samtycke, Synka nu); everything else moves into a "..."
  menu, and details (accounts, IBAN, balances, initial historik) sit
  behind a collapsed disclosure. Expired rows never show balances.
- Expiring-soon active rows get a "Fornya samtycke" primary that
  reconnects without a psu-type override (the server reuses the stored
  psu_type); the explicit account-type choice stays in the menu.
- "Anslut ny bank" collapses behind one outline "Anslut en bank till"
  button whenever a non-revoked connection exists; the reuse-session
  group only shows while the connect-new surface is visible.
- Fresh connects to an already-connected bank are intercepted with a
  renew-instead dialog; "Anslut som ny" proceeds with force_new: true
  for the upcoming server-side 409 guard.
- In-flight 'pending' rows render as a spinner row ("Vantar pa banken")
  instead of being invisible.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 10:07:04 +02:00

382 lines
15 KiB
TypeScript

'use client'
import { useState } from 'react'
import Link from 'next/link'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { cn, formatDate } from '@/lib/utils'
import { ChevronRight, Loader2, MoreHorizontal } from 'lucide-react'
import { getConnectionUiState } from '../lib/connection-state'
import type { BankConnection } from '@/types'
interface BankConnectionStatusProps {
connection: BankConnection
onSync: (connectionId: string) => void
onDisconnect: (connectionId: string) => void
onReconnect?: (connection: BankConnection, psuType?: 'personal' | 'business') => void
onManageAccounts?: (connectionId: string) => void
isSyncing?: boolean
}
/**
* One bank connection as a flat hairline row (Fönster settings language):
* identity + state on one line, exactly ONE primary action decided by the
* derived UI state, every secondary action behind a "..." menu, and the
* details (accounts, IBAN, balances, initial backfill) behind a collapsed
* disclosure. The page-level attention sentence lives in the panel, not
* here (design convention 6: one .attn per page).
*/
export function BankConnectionStatus({
connection,
onSync,
onDisconnect,
onReconnect,
onManageAccounts,
isSyncing = false,
}: BankConnectionStatusProps) {
const [now] = useState(() => Date.now())
const [detailsOpen, setDetailsOpen] = useState(false)
const uiState = getConnectionUiState(connection, now)
// Parse accounts from connection
const accounts = (connection.accounts_data as Array<{
uid: string
iban?: string
name?: string
currency: string
balance?: number
balance_updated_at?: string
enabled?: boolean
}>) || []
const enabledCount = accounts.filter((a) => a.enabled !== false).length
function formatBalanceAge(updatedAt: string): string {
const hoursAgo = Math.floor((now - new Date(updatedAt).getTime()) / (1000 * 60 * 60))
if (hoursAgo < 1) return 'Nyss uppdaterat'
if (hoursAgo < 24) return `${hoursAgo}h sedan`
const daysAgo = Math.floor(hoursAgo / 24)
return `${daysAgo}d sedan`
}
// In-flight authorization: the row exists but the user is still at the
// bank. Render it as a quiet spinner row instead of hiding it (the connect
// button lock alone made this state invisible).
if (uiState === 'pending') {
return (
<div className="flex min-h-10 flex-wrap items-center gap-x-3 gap-y-1 border-b border-border px-1 py-3">
<span className="text-sm font-medium">{connection.bank_name}</span>
<span className="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Väntar banken
</span>
<span className="ml-auto shrink-0">
<Button
variant="ghost"
size="sm"
className="text-muted-foreground hover:text-foreground"
onClick={() => onDisconnect(connection.id)}
>
Avbryt
</Button>
</span>
</div>
)
}
// Status display: muted text for the normal state, Badge only when the row
// deviates (design convention 5).
type StatusEntry =
| { kind: 'text'; label: string }
| { kind: 'badge'; label: string; variant: 'warning' | 'destructive' | 'secondary' }
const statusDisplay: StatusEntry = (() => {
switch (uiState) {
case 'pending_selection':
return { kind: 'badge', label: 'Välj konton', variant: 'warning' }
case 'error':
return { kind: 'badge', label: 'Fel', variant: 'destructive' }
case 'expired':
return { kind: 'badge', label: 'Utgånget samtycke', variant: 'warning' }
case 'expiring':
return { kind: 'badge', label: 'Går ut snart', variant: 'warning' }
default:
return { kind: 'text', label: 'Aktiv' }
}
})()
const isExpired = uiState === 'expired'
const canReconnect = !!onReconnect
const canSync = connection.status === 'active' || connection.status === 'error'
// Exactly ONE primary action per state; everything else goes in the menu.
function renderPrimaryAction() {
switch (uiState) {
case 'pending_selection':
return onManageAccounts ? (
<Button size="sm" onClick={() => onManageAccounts(connection.id)}>
Välj konton
</Button>
) : null
case 'error':
return (
<Button size="sm" onClick={() => onSync(connection.id)} disabled={isSyncing}>
{isSyncing ? <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" /> : null}
Försök igen
</Button>
)
case 'expired':
case 'expiring':
// No psu override: the server reuses the stored psu_type, so renewal
// is one click. Switching account type lives in the menu.
return canReconnect ? (
<Button size="sm" onClick={() => onReconnect!(connection)}>
Förnya samtycke
</Button>
) : null
case 'stale':
case 'never_synced':
return (
<Button size="sm" onClick={() => onSync(connection.id)} disabled={isSyncing}>
{isSyncing ? <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" /> : null}
Synka nu
</Button>
)
default:
// Healthy active row: no primary needed; sync stays reachable as a
// quiet ghost button.
return (
<Button
variant="ghost"
size="sm"
className="text-muted-foreground hover:text-foreground"
onClick={() => onSync(connection.id)}
disabled={isSyncing}
>
{isSyncing ? <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" /> : null}
Synka
</Button>
)
}
}
const primaryIsSync = uiState === 'stale' || uiState === 'never_synced' || uiState === 'active' || uiState === 'error'
const primaryIsReconnect = uiState === 'expired' || uiState === 'expiring'
return (
<div className="border-b border-border px-1 py-3">
{/* Main line: identity + state left, one primary action + menu right */}
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
<span className="text-sm font-medium">{connection.bank_name}</span>
{statusDisplay.kind === 'badge' ? (
<Badge variant={statusDisplay.variant}>{statusDisplay.label}</Badge>
) : (
<span className="text-xs text-muted-foreground">{statusDisplay.label}</span>
)}
{uiState === 'pending_selection' ? (
<span className="text-xs text-muted-foreground">
{accounts.length} konton tillgängliga: inga transaktioner synkas ännu
</span>
) : (
<>
{connection.last_synced_at && (
<span className="text-xs text-muted-foreground tabular-nums">
Synkad {formatDate(connection.last_synced_at)}
</span>
)}
{connection.consent_expires && !isExpired && (
<span className="text-xs text-muted-foreground tabular-nums">
Samtycke till {formatDate(connection.consent_expires)}
</span>
)}
</>
)}
<span className="ml-auto flex shrink-0 items-center gap-1">
{renderPrimaryAction()}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-foreground"
aria-label={`Fler åtgärder för ${connection.bank_name}`}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{uiState === 'pending_selection' ? (
<DropdownMenuItem onSelect={() => onDisconnect(connection.id)}>
Avbryt
</DropdownMenuItem>
) : (
<>
{onManageAccounts && (
<DropdownMenuItem onSelect={() => onManageAccounts(connection.id)}>
Välj konton
</DropdownMenuItem>
)}
{canSync && !primaryIsSync && (
<DropdownMenuItem onSelect={() => onSync(connection.id)}>
Synka
</DropdownMenuItem>
)}
{canReconnect && !primaryIsReconnect && (
<DropdownMenuItem onSelect={() => onReconnect!(connection)}>
Förnya samtycke
</DropdownMenuItem>
)}
{canReconnect && (
<>
<DropdownMenuSeparator />
{/* Some banks (notably Handelsbanken) only sign with one
account type: keep the explicit choice reachable even
though the primary renew reuses the stored type. */}
<DropdownMenuLabel className="text-xs font-normal normal-case tracking-normal">
Förnya och logga in som
</DropdownMenuLabel>
<DropdownMenuItem onSelect={() => onReconnect!(connection, 'business')}>
Företagskonto
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => onReconnect!(connection, 'personal')}>
Privatkonto
</DropdownMenuItem>
</>
)}
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link href="/import?mode=bank">Importera bankfil</Link>
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onSelect={() => onDisconnect(connection.id)}
>
Koppla från
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</span>
</div>
{/* Error detail: the page-level .attn owns the ochre sentence; the
row's own message stays quiet. */}
{uiState === 'error' && connection.error_message && (
<p className="mt-1 text-[12.5px] leading-relaxed text-muted-foreground">
{connection.error_message}
</p>
)}
{/* Details behind a collapsed disclosure: accounts, IBAN, balances,
initial backfill. Expired rows never show balances (stale numbers
would read as current). */}
{accounts.length > 0 && uiState !== 'pending_selection' && (
<div className="mt-1">
<button
type="button"
onClick={() => setDetailsOpen((v) => !v)}
aria-expanded={detailsOpen}
className="flex min-h-9 items-center gap-1 text-xs text-muted-foreground transition-colors duration-150 hover:text-foreground"
>
<ChevronRight
className={cn('h-3.5 w-3.5 transition-transform duration-150', detailsOpen && 'rotate-90')}
/>
<span className="tabular-nums">
{enabledCount} av {accounts.length} konton synkas
</span>
</button>
{detailsOpen && (
<div className="ml-3 border-l border-border pl-4">
{/* Initial backfill summary: what the bank actually returned vs
what we asked for. Diagnostics, so it lives in the details. */}
{!isExpired &&
connection.initial_sync_completed_at &&
connection.initial_sync_requested_from &&
(() => {
const requested = connection.initial_sync_requested_from
const min = connection.initial_sync_returned_min_date
const max = connection.initial_sync_returned_max_date
// Truncation = bank returned less history than requested.
// 7-day grace for off-by-one + weekend posting differences.
let truncated = false
if (min && requested) {
const requestedTime = new Date(requested).getTime()
const minTime = new Date(min).getTime()
truncated = minTime - requestedTime > 7 * 24 * 60 * 60 * 1000
}
return (
<div className="flex flex-wrap items-center gap-2 py-2 text-xs text-muted-foreground">
<span>
Initial historik:{' '}
<span className="tabular-nums">
{min ? formatDate(min) : '-'} {max ? formatDate(max) : '-'}
</span>{' '}
(begärde <span className="tabular-nums">{formatDate(requested)}</span>)
</span>
{truncated && (
<Badge variant="outline">
Bankens API returnerade kortare period än begärt: använd SIE-import för äldre data
</Badge>
)}
</div>
)
})()}
{accounts.map((account) => {
const isDisabled = account.enabled === false
return (
<div
key={account.uid}
className={cn(
'flex flex-wrap items-center gap-x-3 gap-y-1 py-2',
isDisabled && 'opacity-60',
)}
>
<span className="text-sm">
{account.name || account.iban || 'Okänt konto'}
</span>
{isDisabled && (
<Badge variant="outline" className="text-[10px] uppercase tracking-wide text-muted-foreground">
Synkas ej
</Badge>
)}
{account.iban && (
<span className="text-xs text-muted-foreground">
{account.iban.replace(/(.{4})/g, '$1 ').trim()}
</span>
)}
{!isExpired && account.balance !== undefined && (
<span className="ml-auto inline-flex shrink-0 items-baseline gap-2">
{account.balance_updated_at && (
<span className="text-[10px] text-muted-foreground">
{formatBalanceAge(account.balance_updated_at)}
</span>
)}
<span className="text-sm tabular-nums">
{new Intl.NumberFormat('sv-SE', {
style: 'currency',
currency: account.currency,
}).format(account.balance)}
</span>
</span>
)}
</div>
)
})}
</div>
)}
</div>
)}
</div>
)
}