'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 (
{connection.bank_name} Väntar på banken…
) } // 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 ? ( ) : null case 'error': return ( ) 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 ? ( ) : null case 'stale': case 'never_synced': return ( ) default: // Healthy active row: no primary needed; sync stays reachable as a // quiet ghost button. return ( ) } } const primaryIsSync = uiState === 'stale' || uiState === 'never_synced' || uiState === 'active' || uiState === 'error' const primaryIsReconnect = uiState === 'expired' || uiState === 'expiring' return (
{/* Main line: identity + state left, one primary action + menu right */}
{connection.bank_name} {statusDisplay.kind === 'badge' ? ( {statusDisplay.label} ) : ( {statusDisplay.label} )} {uiState === 'pending_selection' ? ( {accounts.length} konton tillgängliga: inga transaktioner synkas ännu ) : ( <> {connection.last_synced_at && ( Synkad {formatDate(connection.last_synced_at)} )} {connection.consent_expires && !isExpired && ( Samtycke till {formatDate(connection.consent_expires)} )} )} {renderPrimaryAction()} {uiState === 'pending_selection' ? ( onDisconnect(connection.id)}> Avbryt ) : ( <> {onManageAccounts && ( onManageAccounts(connection.id)}> Välj konton )} {canSync && !primaryIsSync && ( onSync(connection.id)}> Synka )} {canReconnect && !primaryIsReconnect && ( onReconnect!(connection)}> Förnya samtycke )} {canReconnect && ( <> {/* Some banks (notably Handelsbanken) only sign with one account type: keep the explicit choice reachable even though the primary renew reuses the stored type. */} Förnya och logga in som onReconnect!(connection, 'business')}> Företagskonto onReconnect!(connection, 'personal')}> Privatkonto )} Importera bankfil onDisconnect(connection.id)} > Koppla från )}
{/* Error detail: the page-level .attn owns the ochre sentence; the row's own message stays quiet. */} {uiState === 'error' && connection.error_message && (

{connection.error_message}

)} {/* 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' && (
{detailsOpen && (
{/* 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 (
Initial historik:{' '} {min ? formatDate(min) : '-'} → {max ? formatDate(max) : '-'} {' '} (begärde {formatDate(requested)}) {truncated && ( Bankens API returnerade kortare period än begärt: använd SIE-import för äldre data )}
) })()} {accounts.map((account) => { const isDisabled = account.enabled === false return (
{account.name || account.iban || 'Okänt konto'} {isDisabled && ( Synkas ej )} {account.iban && ( {account.iban.replace(/(.{4})/g, '$1 ').trim()} )} {!isExpired && account.balance !== undefined && ( {account.balance_updated_at && ( {formatBalanceAge(account.balance_updated_at)} )} {new Intl.NumberFormat('sv-SE', { style: 'currency', currency: account.currency, }).format(account.balance)} )}
) })}
)}
)}
) }