Files
accounted/extensions/general/enable-banking/components/BankConnectionStatus.tsx
T
Jakob Wennberg 6d9846b1e7 feat(settings): Fönster redesign - flat rows, ? help, dirty save bar (#1193)
* feat(settings): Fönster redesign - flat rows, help behind ?, dirty save bar

Founder-approved concept (2026-07-25) applied to the whole settings
surface, modal and full-page variants alike:

- New primitives in components/settings/SettingsRows.tsx: section header
  (serif title + one-line intro), eyebrow groups, hairline label/control
  rows, flat inputs/selects/textareas, segmented control, animated
  reveal for gated settings, danger zone.
- Every static explanation paragraph moved behind a "?" popover
  (HelpPopover) at row or group level; dynamic status stays visible.
- Modal chrome: company kicker over serif title, fixed 920x680 window.
- SettingsFormWrapper: save is a sticky bar that appears only when the
  form is dirty; collapses to zero height when clean.
- All 11 sections converted (Konto, Abonnemang, Företag, Bokföring,
  Skatt, Löner, Fakturering, Mallar, Bank incl. Enable Banking-panel,
  Assistenten, API) with handlers, validation, role/entitlement/sandbox
  gates and i18n keys preserved; checkboxes became switches, cards
  dissolved into groups.
- Fix: Escape with an open help popover closed the whole settings
  modal; it now closes the popover first.
- New i18n keys: settings_intro.*, group labels, wrapper_unsaved
  (sv+en).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(settings): founder feedback round 1 on the Fönster redesign

- Abonnemang paying state: status and manage split into two rows so the
  row no longer wraps awkwardly; the included-features list now shows
  for paying companies too.
- Logos where the counterpart has one: BankID mark on the security row
  and on the Koppla BankID button, Skatteverket mark on the connection
  rows.
- Buttons are unmistakably buttons: 27 text-labeled row actions went
  from ghost to outline pills; icon-only actions stay quiet.
- The agent-knowledge view (Regler & profil: Dina regler, Momsprofil,
  Konventioner) converted to the flat row language; it was the last
  old-style surface inside settings. Descriptions moved behind "?",
  rules render as hairline rows, the per-row "Regel" chip demoted to
  muted text.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(settings): address review-bot findings on the Fönster redesign

- SettingsFormWrapper marks the form dirty on switch clicks too: Radix
  Switch is a button and fires no input event, so switch-only changes
  (f-skatt, KU, ROT/RUT, OSS...) never revealed the save bar.
- i18n: the migrated hardcoded strings got keys in both locales
  (fiscal-period start date/range/months, security set-password trio);
  dates in ApiKeysPanel/OAuthClientsPanel/CalendarFeedSettings now pass
  the active locale to formatDateLong.
- A11y: member remove/revoke buttons and the invite role select got
  correct accessible names; BankNameCombobox accepts aria-label wired
  from its row; the pinned-fact icon exposes role img.
- BankIdSettings: explicit Avbryt under the QR block so a cancelled
  BankID flow cannot strand isLinking.
- VoucherSeriesManager: clear the skeleton when no company is resolved.

Verified end to end in sandbox: switch-only dirty bar, PUT /api/settings
200 for text and switch saves, persistence across hard reload.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 23:55:08 +02:00

305 lines
12 KiB
TypeScript

'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<string, StatusEntry> = {
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 ?? ''
return (
<div className="border-b border-border px-1 py-3">
{/* Main line: identity + state left, quiet actions right */}
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
<span className="text-sm font-medium">{connection.bank_name}</span>
{status.kind === 'badge' ? (
<Badge variant={status.variant}>{status.label}</Badge>
) : (
<span className="text-xs text-muted-foreground">{status.label}</span>
)}
{connection.last_synced_at && (
<span className="text-xs text-muted-foreground tabular-nums">
Synkad {formatDate(connection.last_synced_at)}
</span>
)}
{/* Consent renewal date as quiet metadata; the expired state already
carries its own warning line below. */}
{connection.consent_expires && !isConnectionExpired && (
<span className="text-xs text-muted-foreground tabular-nums">
Samtycke till {formatDate(connection.consent_expires)}
</span>
)}
<span className="ml-auto flex shrink-0 flex-wrap items-center gap-1">
{(isConnectionExpired || isConnectionError) && onReconnect && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="gap-1 text-muted-foreground hover:text-foreground">
Förnya anslutning
<ChevronDown className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{/* 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". */}
<DropdownMenuLabel className="text-xs font-normal text-muted-foreground">
Logga in banken som
</DropdownMenuLabel>
<DropdownMenuItem onSelect={() => onReconnect(connection, 'business')}>
Företagskonto
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => onReconnect(connection, 'personal')}>
Privatkonto
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
{isConnectionError && (
<Button
variant="outline"
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}
Försök igen
</Button>
)}
{connection.status === 'active' && (
<Button
variant="outline"
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>
)}
{onManageAccounts && (
<Button
variant="outline"
size="sm"
className="text-muted-foreground hover:text-foreground"
onClick={() => onManageAccounts(connection.id)}
>
Välj konton
</Button>
)}
<Button
variant="outline"
size="sm"
className="text-muted-foreground hover:text-destructive"
onClick={() => onDisconnect(connection.id)}
>
Koppla från
</Button>
</span>
</div>
{/* Error message: live warning, compact warning-tone lines */}
{isConnectionError && errorMessage && (
<>
<p className="mt-1 text-[12.5px] leading-relaxed text-attn">{errorMessage}</p>
<p className="mt-1 text-xs text-muted-foreground">
Du kan också{' '}
<Link href="/import?mode=bank" className="underline underline-offset-2 hover:text-foreground">
importera transaktioner via bankfil
</Link>
</p>
</>
)}
{/* Expired consent notice */}
{isConnectionExpired && (
<>
<p className="mt-1 text-[12.5px] leading-relaxed text-attn">
PSD2-samtycket har löpt ut. Förnya anslutningen för att återuppta synkroniseringen.
</p>
<p className="mt-1 text-xs text-muted-foreground">
Medan du väntar kan du{' '}
<Link href="/import?mode=bank" className="underline underline-offset-2 hover:text-foreground">
importera transaktioner via bankfil
</Link>
</p>
</>
)}
{/* Consent expiry warning (for active connections) */}
{!isConnectionExpired && isExpiring && daysUntilExpiry !== null && (
<p className="mt-1 text-[12.5px] leading-relaxed text-attn">
Samtycket går ut om {daysUntilExpiry} {daysUntilExpiry === 1 ? 'dag' : 'dagar'}.
Förnya genom att ansluta igen.
</p>
)}
{/* 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 (
<div className="mt-1 flex flex-wrap items-center gap-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: indented flat sub-list instead of boxed rows */}
{accounts.length > 0 && (
<div className="ml-3 mt-3 border-l border-border pl-4">
<div className="flex items-center justify-between">
<p className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
Konton
</p>
<p className="text-xs text-muted-foreground tabular-nums">
{enabledCount} av {accounts.length} synkas
</p>
</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>
)}
{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>
)
}