'use client' import { useState } from 'react' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' import { cn, formatDate } from '@/lib/utils' import { getDaysUntilExpiry, isConsentExpiringSoon } from '../lib/api-client' import Link from 'next/link' import { ChevronDown, Loader2 } from 'lucide-react' 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): * bank name + state on one line with quiet actions on the right, live * warnings as compact warning-tone lines underneath, and the accounts as an * indented sub-list. Normal state (Aktiv) is muted text; a Badge appears * only when the row deviates (expired/error/pending). */ export function BankConnectionStatus({ connection, onSync, onDisconnect, onReconnect, onManageAccounts, isSyncing = false, }: BankConnectionStatusProps) { const daysUntilExpiry = getDaysUntilExpiry(connection.consent_expires) const isExpiring = isConsentExpiringSoon(connection.consent_expires) type StatusEntry = | { kind: 'text'; label: string } | { kind: 'badge'; label: string; variant: 'warning' | 'destructive' | 'secondary' } const statusConfig: Record = { active: { kind: 'text', label: 'Aktiv' }, pending: { kind: 'badge', label: 'Väntar', variant: 'warning' }, expired: { kind: 'badge', label: 'Utgånget samtycke', variant: 'warning' }, error: { kind: 'badge', label: 'Fel', variant: 'destructive' }, revoked: { kind: 'badge', label: 'Bortkopplad', variant: 'secondary' }, } const status = statusConfig[connection.status] || statusConfig.error // 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 const [now] = useState(() => Date.now()) 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` } const isConnectionExpired = connection.status === 'expired' const isConnectionError = connection.status === 'error' const errorMessage = connection.error_message ?? '' // "Aktiv" is a stored status, not a live fact: a session killed bank-side // keeps the row at 'active' until something tries to use it. The nightly // health probe catches most of those, but a connection that has gone quiet // for days is worth saying out loud rather than presenting old balances as // current. The cron runs daily, so 3 days is several missed runs. const STALE_SYNC_DAYS = 3 const daysSinceSync = connection.last_synced_at ? Math.floor((now - new Date(connection.last_synced_at).getTime()) / (1000 * 60 * 60 * 24)) : null const isStale = connection.status === 'active' && daysSinceSync !== null && daysSinceSync >= STALE_SYNC_DAYS const neverSynced = connection.status === 'active' && !connection.last_synced_at return (
{/* Main line: identity + state left, quiet actions right */}
{connection.bank_name} {status.kind === 'badge' ? ( {status.label} ) : ( {status.label} )} {connection.last_synced_at && ( Synkad {formatDate(connection.last_synced_at)} )} {/* Consent renewal date as quiet metadata; the expired state already carries its own warning line below. */} {connection.consent_expires && !isConnectionExpired && ( Samtycke till {formatDate(connection.consent_expires)} )} {(isConnectionExpired || isConnectionError) && onReconnect && ( {/* Let the user pick the account type for the bank login. The server reuses the last-used type by default, but some banks (notably Handelsbanken) only sign with one of them: e.g. an AB owner who signs with a personal Mobile BankID needs "Privatkonto", not the company default "Företagskonto". */} Logga in på banken som onReconnect(connection, 'business')}> Företagskonto onReconnect(connection, 'personal')}> Privatkonto )} {isConnectionError && ( )} {connection.status === 'active' && ( )} {onManageAccounts && ( )}
{/* Error message: live warning, compact warning-tone lines */} {isConnectionError && errorMessage && ( <>

{errorMessage}

Du kan också{' '} importera transaktioner via bankfil

)} {/* Expired consent notice */} {isConnectionExpired && ( <>

PSD2-samtycket har löpt ut. Förnya anslutningen för att återuppta synkroniseringen.

Medan du väntar kan du{' '} importera transaktioner via bankfil

)} {/* Gone quiet: the row still says Aktiv, but nothing has confirmed the session is alive for days. */} {isStale && (

Ingen synkning på {daysSinceSync} dagar. Saldon och transaktioner kan vara inaktuella: kör Synka för att kontrollera att anslutningen fortfarande fungerar.

)} {neverSynced && (

Anslutningen har aldrig synkat. Kör Synka för att hämta transaktioner.

)} {/* Consent expiry warning (for active connections) */} {!isConnectionExpired && isExpiring && daysUntilExpiry !== null && (

Samtycket går ut om {daysUntilExpiry} {daysUntilExpiry === 1 ? 'dag' : 'dagar'}. Förnya genom att ansluta igen.

)} {/* Initial backfill summary: shows what the bank actually returned vs what we asked for. */} {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: indented flat sub-list instead of boxed rows */} {accounts.length > 0 && (

Konton

{enabledCount} av {accounts.length} synkas

{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()} )} {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)} )}
) })}
)}
) }