feat(banking): the bank-connect payoff (#1477)

* feat(banking): the bank-connect payoff: point the first sync onward

Fifth activation slice. Every successful connect passes through the
sync-progress dialog's done state, which used to end on a bare Klar
that stranded the user on the settings panel. Now it is the payoff:
the imported count stays, the work now waiting gets named (N att
bokföra · M matchar fakturor, from the same worklist counts endpoint
the dashboard pane refetches), and the primary action becomes
Visa N att bokföra -> /transactions, where realtime rows, the match
pills and the Att bokföra badge already deliver the rest. Zero-import
syncs keep the plain Klar.

Also mounts the fully-built-but-orphaned BankSyncSinceLastVisit pill
on the transactions footer, so returning users get the same payoff
line for the nightly cron (N nya transaktioner sedan sist).

Strings stay hardcoded Swedish inside the extension, matching every
neighboring string in the dialog.

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

* fix(banking): review triage: no stale counts, no marker advance on error

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-08-09 20:52:25 +02:00
committed by GitHub
co-authored by Claude Fable 5 Jakob Wennberg
parent 170b2722d6
commit ce1e0b7642
3 changed files with 76 additions and 12 deletions
+2
View File
@@ -22,6 +22,7 @@ import BankSyncStatusChip from '@/components/transactions/BankSyncStatusChip'
import { ContextPicker, type ContextPickerItem } from '@/components/common/ContextPicker'
import { AttnLine } from '@/components/ui/attn-line'
import BankSyncNowButton from '@/components/transactions/BankSyncNowButton'
import BankSyncSinceLastVisit from '@/components/transactions/BankSyncSinceLastVisit'
import TransactionInboxCard from '@/components/transactions/TransactionInboxCard'
import TransactionHistoryList from '@/components/transactions/TransactionHistoryList'
import InboxZeroState from '@/components/transactions/InboxZeroState'
@@ -2663,6 +2664,7 @@ export default function TransactionsPage() {
)}
<BankSyncStatusChip />
<BankSyncNowButton />
<BankSyncSinceLastVisit />
<Link
href="/reports/bank-reconciliation"
className="ml-auto transition-colors duration-150 hover:text-foreground"
@@ -43,8 +43,11 @@ export default function BankSyncSinceLastVisit() {
.eq('company_id', company.id)
.eq('import_source', 'enable_banking')
.gt('created_at', lastVisitRaw)
.then(({ count: rowCount }) => {
.then(({ count: rowCount, error }) => {
if (cancelled) return
// A failed query must not advance the marker: that would silently
// swallow the notification for rows that DID arrive in the window.
if (error) return
if (rowCount && rowCount > 0) {
setCount(rowCount)
} else {
@@ -83,6 +83,34 @@ export function BankSyncProgressDialog({
// grace period the user may background the (still-running) sync.
const blockClose = state.kind === 'syncing' && !overGrace
// The payoff: once the first sync lands, name the work now waiting so the
// moment points onward instead of stranding the user on the settings panel.
// Same authenticated endpoint the dashboard pane refetches; best-effort.
const [workCounts, setWorkCounts] = useState<{ book: number; matches: number } | null>(null)
useEffect(() => {
if (!open || state.kind !== 'done' || state.summary.imported === 0) {
// A later sync must not inherit the previous run's numbers.
setWorkCounts(null)
return
}
let cancelled = false
fetch('/api/worklist/counts')
.then((res) => (res.ok ? res.json() : null))
.then((json: { data?: { counts?: Record<string, number> } } | null) => {
if (cancelled || !json?.data?.counts) return
setWorkCounts({
book: json.data.counts.book_transaction ?? 0,
matches: json.data.counts.suggested_match ?? 0,
})
})
.catch(() => {
// The CTA degrades to a plain link; the numbers are a nicety.
})
return () => {
cancelled = true
}
}, [open, state])
return (
<Dialog
open={open}
@@ -152,7 +180,7 @@ export function BankSyncProgressDialog({
)}
{state.kind === 'done' && (
<DoneBody summary={state.summary} />
<DoneBody summary={state.summary} workCounts={workCounts} />
)}
{state.kind === 'failed' && (
@@ -162,22 +190,47 @@ export function BankSyncProgressDialog({
)}
<DialogFooter>
<Button
type="button"
onClick={() => onOpenChange(false)}
disabled={blockClose}
>
{state.kind === 'syncing'
? (overGrace ? 'Fortsätt i bakgrunden' : 'Hämtar…')
: 'Klar'}
</Button>
{state.kind === 'done' && state.summary.imported > 0 ? (
<>
<Button
type="button"
variant="ghost"
onClick={() => onOpenChange(false)}
>
Stäng
</Button>
<Button asChild>
<Link href="/transactions">
{workCounts && workCounts.book > 0
? `Visa ${workCounts.book} att bokföra`
: 'Öppna transaktionerna'}
</Link>
</Button>
</>
) : (
<Button
type="button"
onClick={() => onOpenChange(false)}
disabled={blockClose}
>
{state.kind === 'syncing'
? (overGrace ? 'Fortsätt i bakgrunden' : 'Hämtar…')
: 'Klar'}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
)
}
function DoneBody({ summary }: { summary: SyncProgressSummary }) {
function DoneBody({
summary,
workCounts,
}: {
summary: SyncProgressSummary
workCounts: { book: number; matches: number } | null
}) {
const requestedDays = daysBetween(summary.requested_from)
const returnedDays =
summary.returned_min_date && summary.returned_max_date
@@ -199,6 +252,12 @@ function DoneBody({ summary }: { summary: SyncProgressSummary }) {
Datum: {summary.returned_min_date} → {summary.returned_max_date}
</p>
)}
{workCounts && workCounts.book > 0 && (
<p className="mt-1 text-xs text-muted-foreground tabular-nums">
{workCounts.book} att bokföra
{workCounts.matches > 0 && <> · {workCounts.matches} matchar fakturor</>}
</p>
)}
</div>
</div>