'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
)}