Files
accounted/components/transactions/BankSyncNowButton.tsx
T
Jakob Wennberg 1ccf7ab81f feat(transactions): concept shell and row language (UI migration PR 5) (#1124)
* feat(transactions): concept shell for scene 10 (header, toolbar, footer)

- Header: title + Importera SplitButton (import guide / manual entry,
  last-used mode persisted via ui_state.create_mode.transactions),
  replacing the three-button row
- Toolbar in concept order: [Att bokföra/Alla seg with count chip]
  [search] [Välj flera as quiet toggle] ... [source ContextPicker far
  right merging the old in-list source dropdown (bank/Skatteverket)]
- Bank sync status + sync-now + Bankavstämning move to the concept's
  footer status line together with the honest visible-row counter
  ("n att hantera")
- skv-reconnect banner and all dialogs/actions untouched

Row cards -> concept dry-table conversion follows separately.

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

* feat(transactions): concept row language for the inbox cards

Rows read like the scene 10 table: date as the first tabular column,
description on one line, amount right (+income in sage), auto-detected
invoice matches as beige suggestion chips instead of primary pills,
tighter hairline rows at 13px. Every action unchanged: Bokför pill,
match/assistant/overflow menu, batch checkboxes, exit animations,
extraction status.

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

* refactor(transactions): de-bloat rows and chrome to match the concept

- Rows carry only the essentials: date, description, amount, Bokför and
  one overflow menu. The inline match and ask-assistant icon buttons
  fold into the overflow menu (all actions still one click away)
- The "n nya banktransaktioner sen ditt senaste besök" banner is removed;
  the footer sync line covers it
- The Skatteverket reconnect banner becomes the one-ochre-sentence
  AttnLine with an inline action (convention 6)

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

* refactor(ui): extract dry-table primitives into components/ui/dry-table

The concept table styles (TH/TD/VTH/VTD classes, quiet links, RowFoldout)
move out of JournalEntryList so the transaction inbox can render the exact
same table language.

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

* feat(transactions): Synka bank nu in the Importera menu

Extracts the per-connection sync/reconnect logic into a shared useBankSync()
hook (BankSyncNowButton keeps using it) and adds the concept's first menu row
to the Importera split button: sync all active connections, with the
last-synced age as the row description. Hidden without a connection or the
bank_sync capability; the footer button stays the gated conversion surface.

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

* feat(transactions): per-account source picker with balances

The context chip becomes the concept's account chooser: always visible in
inbox mode, one row per enabled cash account (PSD2 balance annotation),
an Övriga bucket for bank rows without a registered account, and a
Skattekonto row annotated with the cached saldo. The trigger reads
'Alla källor · <sum>' where the sum covers SEK ledgers + skattekonto.

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

* feat(transactions): concept dry-table for the inbox

The inbox list becomes the exact Bokforing table: borderless dry-table with
hover-revealed checkboxes and chevrons, one-line rows (date, description,
amount, quiet primary pill + overflow + foldout), and a RowFoldout expansion
carrying the row detail and the full action set as quiet links. Skattekonto
rows render as chip-marked table rows with inline actions. The 'Valj flera'
toggle and the floating batch bar are replaced by the concept bulkbar that
pops in above the table once a row is selected.

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

* fix(i18n): singular form for bulkbar selection count

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

* feat(transactions): suggestion-first Bokfor flow (concept scene 10)

Bokfor on a row with a suggestion now goes straight to the compact
kontering confirm (QuickReview) showing the proposed verifikat; the full
template picker becomes the fallback and the 'Byt mall' path. Rows carry
the concept's 'Forslag: <mall>' chip (with D/K in the tooltip and the
foldout meta). The picker dialog is de-bloated: alternate paths (manuellt,
matcha, ignorera) collapse into quiet links. Modal scroll containers
(DialogContent, SlideOverBody) keep a visible scrollbar thumb: with the
app-wide auto-hide, a long dialog read as cut off at the fold.

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

* refactor(transactions): drop suggestion UI and foldout action links

Founder direction: no booking suggestions in the list for now (logic comes
later), and the foldout duplicated the overflow menu: the three dots are
the clearer surface. The foldout keeps row detail only (FX conversion,
original bank name, 1930/1630 hint, extraction status) and rows without
any detail no longer expand. The Skattekonto picker row drops its saldo
annotation; the Alla kallor sum covers bank ledgers only.

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

* feat(transactions): dry-table for the Alla view + pill chips app-wide

The history list joins the inbox's table language: Datum, Beskrivning,
Belopp, Status columns with Bokford as muted text + Visa verifikat quiet
link (normal state) and Ej bokford as the beige exception chip with an
inline Bokfor pill; the business/private tabs become the house seg and the
KALLA header row becomes the standard chip-picker. Skattekonto rows get
the same treatment. SEK conversion moves to a tooltip (one-line rows).

Badge itself becomes the concept chip: pill radius (99px), 11.5px, quiet
padding, replacing the boxy rounded-md look the founder flagged on the
Underlag saknas badge.

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

* fix(ui): audit round: viewer-disabled split options, touch fallback

Regression-audit fixes: SplitButton options support disabled+disabledTitle
so viewer-gated create paths render inert with the viewer tooltip instead
of silently no-opping (wired on Ny transaktion); the history list gets its
Kopplad till faktura indicator back; dead transactionsWithMatches memo
removed; and coarse-pointer devices now always show hover-revealed
controls (checkboxes, chevrons, quiet row actions) since touch has no
hover.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 21:41:45 +02:00

277 lines
9.1 KiB
TypeScript

'use client'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { useTranslations } from 'next-intl'
import { Loader2, RefreshCw } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { useToast } from '@/components/ui/use-toast'
import { ToastAction } from '@/components/ui/toast'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { createClient } from '@/lib/supabase/client'
import { notifyBankSyncUpdated } from '@/lib/transactions/bank-sync-signal'
import { useCompany, useCapability } from '@/contexts/CompanyContext'
import { CAPABILITY } from '@/lib/entitlements/keys'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
export interface BankConn {
id: string
bank_name: string
status: string
provider: string
last_synced_at: string | null
}
/**
* Shared on-demand bank sync state + actions. Powers the footer "Synka nu"
* button below and the "Synka bank nu" row in the Importera split-button menu
* (TransactionStatusBar). Reuses the per-connection sync endpoint that
* BankingSettingsPanel already calls.
*
* Also handles dead PSD2 sessions: a connection whose consent has closed or
* expired re-authorizes in place via `reconnect` (no disconnect needed), and a
* sync that fails with a session expiry surfaces the same reconnect action
* right in the error toast.
*/
export function useBankSync() {
const t = useTranslations('transactions')
const { toast } = useToast()
const router = useRouter()
const { company } = useCompany()
const hasBankSync = useCapability(CAPABILITY.bank_sync)
const [connections, setConnections] = useState<BankConn[] | null>(null)
const [busyId, setBusyId] = useState<string | null>(null)
useEffect(() => {
if (!company?.id) return
let cancelled = false
const supabase = createClient()
supabase
.from('bank_connections')
.select('id, bank_name, status, provider, last_synced_at')
// Include expired/error so the reconnect entry point survives a reload:
// not just active connections that can sync.
.in('status', ['active', 'expired', 'error'])
.eq('company_id', company.id)
.then(({ data }) => {
if (!cancelled) setConnections((data as BankConn[]) ?? [])
})
return () => {
cancelled = true
}
}, [company?.id])
// Re-authorize an existing connection in place: posts the connection_id so
// the server reuses the same row, then hands off to the bank's consent screen.
async function reconnect(conn: BankConn) {
setBusyId(conn.id)
try {
const country = conn.provider?.split('-').pop()?.toUpperCase() || 'SE'
const res = await fetch('/api/extensions/ext/enable-banking/connect', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
connection_id: conn.id,
aspsp_name: conn.bank_name,
aspsp_country: country,
}),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error || 'Reconnect failed')
window.location.href = data.authorization_url
} catch (error) {
toast({
title: t('bank_reconnect'),
description: error instanceof Error ? getUserErrorMessage(error) : 'Reconnect failed',
variant: 'destructive',
})
setBusyId(null)
}
}
async function syncConnection(conn: BankConn) {
setBusyId(conn.id)
try {
const res = await fetch('/api/extensions/ext/enable-banking/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ connection_id: conn.id }),
})
const data = await res.json()
if (!res.ok) {
// A dead PSD2 session can't be fixed by retrying: surface a one-click
// reconnect in the toast instead of a dead-end error.
if (data?.reauth_required) {
toast({
title: t('bank_sync_session_expired'),
description: t('bank_sync_session_expired_desc'),
variant: 'destructive',
action: (
<ToastAction altText={t('bank_reconnect')} onClick={() => reconnect(conn)}>
{t('bank_reconnect')}
</ToastAction>
),
})
// Reflect the now-expired status so the button flips to reconnect.
setConnections((prev) =>
(prev ?? []).map((c) => (c.id === conn.id ? { ...c, status: 'expired' } : c))
)
return
}
throw new Error(data.error || 'Sync failed')
}
toast({
title: t('bank_sync_button_now'),
description: data.imported === 1
? t('bank_sync_new_since_last_visit_one')
: t('bank_sync_new_since_last_visit_many', { count: data.imported ?? 0 }),
})
// Tell the neighbouring status chip to refetch so it doesn't keep showing
// the pre-sync "synced Nd ago" until a hard reload.
notifyBankSyncUpdated()
router.refresh()
} catch (error) {
toast({
title: t('bank_sync_button_now'),
description: error instanceof Error ? getUserErrorMessage(error) : 'Sync failed',
variant: 'destructive',
})
} finally {
setBusyId((prev) => (prev === conn.id ? null : prev))
}
}
// Active connections sync; expired/error connections reconnect.
function runFor(conn: BankConn) {
if (conn.status === 'active') return syncConnection(conn)
return reconnect(conn)
}
// One-shot "sync everything" for the split-button menu row: syncs every
// active connection in turn; with only dead connections it jumps straight
// to re-authorizing the first one (a retry can't revive a closed session).
async function syncAll() {
const conns = connections ?? []
const active = conns.filter((c) => c.status === 'active')
if (active.length === 0) {
if (conns[0]) await reconnect(conns[0])
return
}
for (const conn of active) {
await syncConnection(conn)
}
}
const lastSyncedAt =
(connections ?? [])
.map((c) => c.last_synced_at)
.filter((s): s is string => Boolean(s))
.sort()
.pop() ?? null
return {
connections,
busyId,
isBusy: busyId !== null,
hasBankSync,
reconnect,
syncConnection,
runFor,
syncAll,
lastSyncedAt,
}
}
/**
* On-demand "Sync now" button beside BankSyncStatusChip. If the user has
* multiple connections, a dropdown lets them pick which one to sync/reconnect.
*/
export default function BankSyncNowButton() {
const t = useTranslations('transactions')
const { connections, isBusy, hasBankSync, runFor } = useBankSync()
if (!connections || connections.length === 0) return null
const syncLabel = isBusy ? t('bank_sync_button_syncing') : t('bank_sync_button_now')
// Bank sync (and reconnect) is a paid external PSD2 call. Without the
// capability we keep the button VISIBLE as the conversion surface but inert,
// and surface an Uppgradera link. CSV/SIE import stays free (separate UI).
const gateTitle = !hasBankSync ? 'Bankkoppling kräver ett abonnemang' : undefined
const upsellNote = !hasBankSync ? (
<span className="text-xs text-muted-foreground">
Kräver abonnemang.{' '}
<a href="/settings/billing" className="underline underline-offset-2">
Uppgradera
</a>
</span>
) : null
if (connections.length === 1) {
const conn = connections[0]
const needsReconnect = conn.status !== 'active'
return (
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
className="h-7 gap-1.5 px-2.5 text-xs"
disabled={isBusy || !hasBankSync}
title={gateTitle}
onClick={() => runFor(conn)}
>
{isBusy ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<RefreshCw className="h-3.5 w-3.5" />
)}
<span>{needsReconnect ? t('bank_reconnect') : syncLabel}</span>
</Button>
{upsellNote}
</div>
)
}
return (
<div className="flex items-center gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="h-7 gap-1.5 px-2.5 text-xs"
disabled={isBusy || !hasBankSync}
title={gateTitle}
>
{isBusy ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<RefreshCw className="h-3.5 w-3.5" />
)}
<span>{syncLabel}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
{connections.map((conn) => (
<DropdownMenuItem
key={conn.id}
disabled={isBusy}
onSelect={() => runFor(conn)}
>
{conn.status === 'active'
? conn.bank_name
: `${conn.bank_name} · ${t('bank_reconnect')}`}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{upsellNote}
</div>
)
}