feat(skattekonto): bulk Bokför + inline single-row booking (fewer clicks) (#1535)

* feat(skattekonto): bulk Bokför and inline single-row booking

Booking a year of skattekonto events took 6 clicks per row (list, Bokför,
review page, Bokför, confirm, navigate back), even for a +1 kr
intäktsränta row.

- GET /skattekonto/transaktioner now attaches a deterministic
  booking_suggestion per unbooked row (one hoisted skattekonto_rules +
  entity_type fetch via the new attachBookingSuggestions), shown as muted
  text on the inbox row ('Bokförs mot 8314 ...').
- New POST /skattekonto/transaktioner/bokfor-batch (Zod, max 200 ids):
  sequential draft+commit per row server-side (no orphan drafts), per-row
  results, never aborts on a row failure. Commit attribution: bulk_accept
  for real batches, user_accept for the one-row inline flow. A failed
  commit keeps the linked draft (degrades to the old review flow).
- Inbox: hover-reveal checkboxes on eligible SKV rows (suggestion
  present, no duplicate hint, unbooked, genomförd), separate skvSelectedIds
  set, bulkbar 'Bokför valda (N)' with ONE summary ConfirmationDialog
  grouped by suggestion with per-group sums, chunked batchProgress, ONE
  aggregate toast, local state patch with exit animation.
- Single-row: new SkattekontoBookDialog (dynamic import) replaces the
  draft-then-window.location detour on both /transactions and /skattekonto;
  'Öppna som utkast' keeps the old draft path via router.push.

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

* fix(skattekonto): batch booking guards for unsettled rows, double-post race and locked periods

- reject status != booked rows in the batch flow with NOT_SETTLED before
  any draft exists (server no longer trusts client eligibility); the
  single-row draft endpoint keeps its behaviour
- make the journal_entry_id backlink a conditional claim (update where
  journal_entry_id is null, select affected rows): zero affected rows maps
  to ALREADY_BOOKED and commitEntry only runs after a won claim, so two
  concurrent submissions can no longer double-post the same row
- detect the period-lock trigger signature in the batch catch and map it
  to PERIOD_LOCKED with Swedish text instead of UNKNOWN with raw DB output
- SkattekontoBookDialog: rows with no matched rule no longer get the
  guaranteed-422 draft CTA; they route to the existing match flow and to
  manual verifikat creation in /bookkeeping
- pass an explicit skv_book_dialog.commit_warning key for the
  direct-commit warning instead of ConfirmationDialog's hardcoded default

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-12 21:00:57 +02:00
committed by GitHub
co-authored by Claude Fable 5 Jakob Wennberg
parent 7cf0e34434
commit dea5e31756
13 changed files with 1884 additions and 156 deletions
+65 -40
View File
@@ -1,6 +1,7 @@
'use client'
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import dynamic from 'next/dynamic'
import Link from 'next/link'
import { useTranslations } from 'next-intl'
import { Button } from '@/components/ui/button'
@@ -27,6 +28,8 @@ import {
DialogTitle,
} from '@/components/ui/dialog'
import { useToast } from '@/components/ui/use-toast'
import { ToastAction } from '@/components/ui/toast'
import { DialogLoadingSkeleton } from '@/components/ui/dialog-loading-skeleton'
import { cn } from '@/lib/utils'
import {
formatCurrency,
@@ -50,8 +53,14 @@ import type {
SkattekontoTransactionWithSuggestion,
StoredSkattekontoTransaction,
} from '@/extensions/general/skatteverket/types'
import type { SkattekontoBatchRowResult } from '@/types/skatteverket'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
const SkattekontoBookDialog = dynamic(
() => import('@/components/skattekonto/SkattekontoBookDialog'),
{ loading: DialogLoadingSkeleton },
)
interface SaldoEnvelope {
data: SkatteverketSaldoResponse | null
fetchedAt: string | null
@@ -86,7 +95,10 @@ export default function SkattekontoPage() {
const [tx, setTx] = useState<TransaktionerEnvelope['data'] | null>(null)
const [loading, setLoading] = useState(true)
const [syncing, setSyncing] = useState(false)
const [bookingId, setBookingId] = useState<string | null>(null)
// Row whose inline booking dialog is open (null = closed).
const [bookTarget, setBookTarget] = useState<SkattekontoTransactionWithSuggestion | null>(
null,
)
const [notConnected, setNotConnected] = useState(false)
const [loadError, setLoadError] = useState(false)
// Set when a sync fails with an auth error while a connection exists
@@ -222,37 +234,41 @@ export default function SkattekontoPage() {
}
}
async function bokfor(id: string) {
setBookingId(id)
try {
const res = await fetch(
`/api/extensions/ext/skatteverket/skattekonto/transaktioner/${id}/bokfor`,
{ method: 'POST' },
)
const json = await res.json()
if (!res.ok) {
toast({
title: 'Kunde inte bokföra',
description: getUserErrorMessage(json, { statusCode: res.status }),
variant: 'destructive',
})
return
}
toast({
title: 'Utkast skapat',
description: 'Granska och bokför verifikatet i Bokföring.',
})
// Take the user to the draft so they can review.
window.location.href = `/bookkeeping/${json.data.entry.id}`
} catch (err) {
toast({
title: 'Kunde inte bokföra',
description: err instanceof Error ? getUserErrorMessage(err) : undefined,
variant: 'destructive',
})
} finally {
setBookingId(null)
}
function bokfor(id: string) {
// Open the inline booking dialog instead of the old draft-then-navigate
// detour. The row may live in any bucket: genomförda rows carry the
// booking suggestion, kommande/förfallna open in plain draft mode.
const row =
tx?.booked.find((r) => r.id === id) ??
tx?.overdue.find((r) => r.id === id) ??
tx?.upcoming.find((r) => r.id === id) ??
null
if (row) setBookTarget(row)
}
function handleBooked(_rowId: string, result: SkattekontoBatchRowResult) {
setBookTarget(null)
const voucherLabel =
result.voucher_series && result.voucher_number != null
? formatVoucher({
voucher_series: result.voucher_series,
voucher_number: result.voucher_number,
})
: null
toast({
title: t('booked_toast_title'),
description: voucherLabel
? t('booked_toast_description', { voucher: voucherLabel })
: undefined,
action: result.journal_entry_id ? (
<ToastAction altText={t('booked_toast_show')} asChild>
<Link href={`/bookkeeping/${result.journal_entry_id}`}>
{t('booked_toast_show')}
</Link>
</ToastAction>
) : undefined,
})
void reload()
}
async function openMatch(row: StoredSkattekontoTransaction) {
@@ -538,13 +554,28 @@ export default function SkattekontoPage() {
tx={tx}
onBokfor={bokfor}
onMatch={openMatch}
bookingId={bookingId}
/>
<p className="px-1 text-xs leading-5 text-muted-foreground">
{t('pgnote', { amount: formatCurrency(data?.saldoKronofogden ?? 0) })}
</p>
{bookTarget && (
<SkattekontoBookDialog
row={bookTarget}
open
onOpenChange={(o) => {
if (!o) setBookTarget(null)
}}
onBooked={handleBooked}
onMatch={() => {
const target = bookTarget
setBookTarget(null)
void openMatch(target)
}}
/>
)}
<MatchDialog
row={matchOpenFor}
candidates={matchCandidates}
@@ -629,12 +660,10 @@ function SkattekontoTable({
tx,
onBokfor,
onMatch,
bookingId,
}: {
tx: TransaktionerEnvelope['data'] | null
onBokfor: (id: string) => void
onMatch: (row: StoredSkattekontoTransaction) => void
bookingId: string | null
}) {
const t = useTranslations('skattekonto')
@@ -698,7 +727,6 @@ function SkattekontoTable({
section={section.key}
onBokfor={onBokfor}
onMatch={onMatch}
bookingId={bookingId}
showInterestDate={section.interestDateRowIds.has(row.id)}
/>
))}
@@ -715,14 +743,12 @@ function SkattekontoRow({
section,
onBokfor,
onMatch,
bookingId,
showInterestDate,
}: {
row: SkattekontoTransactionWithSuggestion
section: TableSection['key']
onBokfor: (id: string) => void
onMatch: (row: StoredSkattekontoTransaction) => void
bookingId: string | null
showInterestDate: boolean
}) {
const t = useTranslations('skattekonto')
@@ -802,10 +828,9 @@ function SkattekontoRow({
<button
type="button"
onClick={() => onBokfor(row.id)}
disabled={bookingId === row.id}
className={QUIET_LINK_CLASS}
>
{bookingId === row.id ? t('action_booking') : t('action_book')}
{t('action_book')}
</button>
</span>
)}
+368 -61
View File
@@ -11,6 +11,7 @@ import { Badge } from '@/components/ui/badge'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { useToast } from '@/components/ui/use-toast'
import { ToastAction } from '@/components/ui/toast'
import { ConfirmationDialog } from '@/components/ui/confirmation-dialog'
import { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui/destructive-confirm-dialog'
import { DataList, DataListEmpty } from '@/components/ui/data-list'
import { Input } from '@/components/ui/input'
@@ -41,9 +42,12 @@ import type {
CategorizeHandler,
} from '@/components/transactions/transaction-types'
import type {
SkattekontoBatchResult,
SkattekontoBatchRowResult,
SkattekontoTransactionWithSuggestion,
StoredSkattekontoTransaction,
} from '@/types/skatteverket'
import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver'
import { findBankSkvCounterparts } from '@/lib/skatteverket/bank-counterpart'
import {
MATCHABLE_INVOICE_STATUSES,
@@ -54,6 +58,7 @@ import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { useRealtimeSupabase } from '@/lib/hooks/use-realtime-supabase'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { cn, formatCurrency, formatDate } from '@/lib/utils'
import { roundOre } from '@/lib/money'
import type { TransactionCategory, CreateTransactionInput, Invoice, Customer, SupplierInvoice, Supplier, VatTreatment, EntityType, LinePatternEntry, BookingTemplateLibrary, CashAccount } from '@/types'
import type { SuggestedTemplate } from '@/lib/transactions/category-suggestions'
import { isImportedTransaction } from '@/lib/transactions/origin'
@@ -94,6 +99,10 @@ const SkattekontoMatchDialog = dynamic(
() => import('@/components/skattekonto/SkattekontoMatchDialog').then((module) => module.SkattekontoMatchDialog),
{ loading: DialogLoadingSkeleton },
)
const SkattekontoBookDialog = dynamic(
() => import('@/components/skattekonto/SkattekontoBookDialog'),
{ loading: DialogLoadingSkeleton },
)
const DuplicateBookingDialog = dynamic(
() => import('@/components/transactions/DuplicateBookingDialog'),
{ loading: DialogLoadingSkeleton },
@@ -123,6 +132,19 @@ function isSourceFilter(value: string | null): value is SourceFilter {
)
}
// A skattekonto row qualifies for bulk booking when the outcome is fully
// deterministic: a rule matched (booking_suggestion), it is not a likely
// duplicate (match_suggestion), it is unbooked, and it has actually happened
// (kommande rows have nothing to book yet).
function isSkvBulkEligible(row: SkattekontoTransactionWithSuggestion): boolean {
return (
row.booking_suggestion != null &&
row.match_suggestion == null &&
!row.journal_entry_id &&
row.status !== 'upcoming'
)
}
function buildInvoiceMap(rows: InvoiceWithCustomer[] | null): Record<string, InvoiceWithCustomer> {
if (!rows) return {}
return rows.reduce<Record<string, InvoiceWithCustomer>>((acc, inv) => {
@@ -365,10 +387,20 @@ export default function TransactionsPage() {
// Skatteverket extension is enabled and connected. 503/401 → silently
// hidden (extension disabled or user not connected).
const [skvRows, setSkvRows] = useState<SkattekontoTransactionWithSuggestion[]>([])
const [skvProcessingId, setSkvProcessingId] = useState<string | null>(null)
const [skvMatchTarget, setSkvMatchTarget] = useState<StoredSkattekontoTransaction | null>(
null,
)
// Inline single-row booking dialog (replaces the old draft-then-navigate
// flow that dumped the user in /bookkeeping and lost all list state).
const [skvBookTarget, setSkvBookTarget] = useState<SkattekontoTransactionWithSuggestion | null>(
null,
)
// SKV bulk selection: deliberately a SEPARATE set from the bank
// `selectedIds`: every bank batch handler POSTs /api/transactions/* and
// would 404 on skattekonto ids.
const [skvSelectedIds, setSkvSelectedIds] = useState<Set<string>>(new Set())
const [skvBulkConfirmOpen, setSkvBulkConfirmOpen] = useState(false)
const [skvBulkSubmitting, setSkvBulkSubmitting] = useState(false)
// True when an SKV connection exists but is dead (needs_reconsent, or
// expired with no refresh left). Drives the reconnect banner: without it
// a user whose token died sees an empty skattekonto and has no reason to
@@ -568,12 +600,60 @@ export default function TransactionsPage() {
}, [sourceFilter, sourceItems])
// Rows the bulkbar's "Markera alla" can select: the visible bank rows
// (skattekonto rows aren't batch-bookable).
// (they feed the /api/transactions/* batch handlers) ...
const selectableInboxIds = useMemo(
() => inboxItems.filter((item) => item.source === 'bank').map((item) => item.data.id),
[inboxItems],
)
// ... plus the visible skattekonto rows whose booking is deterministic
// (rule matched, no duplicate hint, unbooked, genomförd). These go through
// the skatteverket extension's bokfor-batch endpoint instead.
const selectableSkvIds = useMemo(
() =>
inboxItems
.filter((item) => item.source === 'skatteverket' && isSkvBulkEligible(item.data))
.map((item) => item.data.id),
[inboxItems],
)
const skvSelectedRows = useMemo(
() => skvRows.filter((r) => skvSelectedIds.has(r.id)),
[skvRows, skvSelectedIds],
)
// Bulk confirmation summary: selected rows grouped by their deterministic
// suggestion ("3 × Intäktsränta skattekonto → 8314") with per-group sums.
// Count-based on purpose: voucher numbers are assigned atomically at
// commit, so predicting them here would lie under concurrency.
const skvBulkGroups = useMemo(() => {
const groups = new Map<
string,
{ label: string; account: string; count: number; sum: number }
>()
for (const row of skvSelectedRows) {
const suggestion = row.booking_suggestion
if (!suggestion) continue
const label = suggestion.label ?? suggestion.account_name ?? row.transaktionstext
const key = `${suggestion.account}|${label}`
const group = groups.get(key) ?? {
label,
account: suggestion.account,
count: 0,
sum: 0,
}
group.count += 1
group.sum = roundOre(group.sum + Number(row.belopp_skatteverket))
groups.set(key, group)
}
return Array.from(groups.values())
}, [skvSelectedRows])
const skvBulkTotal = useMemo(
() => roundOre(skvBulkGroups.reduce((sum, g) => sum + g.sum, 0)),
[skvBulkGroups],
)
const PAGE_SIZE = 200
@@ -1971,38 +2051,183 @@ export default function TransactionsPage() {
}
}
async function handleSkvBokfor(row: StoredSkattekontoTransaction) {
setSkvProcessingId(row.id)
try {
const res = await fetch(
`/api/extensions/ext/skatteverket/skattekonto/transaktioner/${row.id}/bokfor`,
{ method: 'POST' },
function handleSkvBokfor(row: StoredSkattekontoTransaction) {
// Prefer the enriched row (booking_suggestion) when it's in state:
// history-view callers only hold the stored shape.
setSkvBookTarget(skvRows.find((r) => r.id === row.id) ?? row)
}
/**
* Single-row booking succeeded (inline dialog): exit animation, local
* journal_entry_id patch (no refetch), and one toast linking the verifikat.
*/
function handleSkvBooked(rowId: string, result: SkattekontoBatchRowResult) {
setSkvBookTarget(null)
setSkvSelectedIds((prev) => {
if (!prev.has(rowId)) return prev
const next = new Set(prev)
next.delete(rowId)
return next
})
setExitingIds((prev) => new Set(prev).add(rowId))
setTimeout(() => {
setSkvRows((prev) =>
prev.map((r) =>
r.id === rowId && result.journal_entry_id
? { ...r, journal_entry_id: result.journal_entry_id }
: r,
),
)
const json = await res.json()
if (!res.ok) {
// Map the parsed body plus the status, never `new Error(json.error)`:
// the Error constructor stringifies a non-string body field, and the
// mapper would discard the route's own Swedish reason.
toast({
title: 'Kunde inte bokföra',
description: getErrorMessage(json, { statusCode: res.status }),
variant: 'destructive',
})
return
}
toast({
title: 'Utkast skapat',
description: t('review_in_bookkeeping_description'),
setExitingIds((prev) => {
const next = new Set(prev)
next.delete(rowId)
return next
})
window.location.href = `/bookkeeping/${json.data.entry.id}`
} catch (err) {
}, 350)
const voucherLabel =
result.voucher_series && result.voucher_number != null
? formatVoucher({
voucher_series: result.voucher_series,
voucher_number: result.voucher_number,
})
: null
toast({
title: t('skv_booked_title'),
description: voucherLabel
? t('skv_booked_description', { voucher: voucherLabel })
: undefined,
action: result.journal_entry_id ? (
<ToastAction altText={t('skv_booked_show')} asChild>
<Link href={`/bookkeeping/${result.journal_entry_id}`}>
{t('skv_booked_show')}
</Link>
</ToastAction>
) : undefined,
})
}
function toggleSkvSelect(id: string) {
setSkvSelectedIds((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
/**
* Bulk "Bokför valda": one confirmed summary → server-side draft+commit
* per row via bokfor-batch (chunked so batchProgress moves), then ONE
* aggregate toast and a local state patch with the exit animation.
*/
async function handleSkvBulkConfirm() {
// Re-check eligibility at submit time: a refetch may have attached a
// duplicate hint or booked a row while the selection sat idle.
const ids = skvSelectedRows.filter(isSkvBulkEligible).map((r) => r.id)
if (ids.length === 0) {
setSkvBulkConfirmOpen(false)
setSkvSelectedIds(new Set())
return
}
setSkvBulkConfirmOpen(false)
setSkvBulkSubmitting(true)
setBatchProgress({ done: 0, total: ids.length })
const results: SkattekontoBatchRowResult[] = []
const CHUNK = 25
try {
for (let i = 0; i < ids.length; i += CHUNK) {
const chunk = ids.slice(i, i + CHUNK)
try {
const res = await fetch(
'/api/extensions/ext/skatteverket/skattekonto/transaktioner/bokfor-batch',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids: chunk }),
},
)
const json = await res.json()
if (res.ok) {
results.push(...(json.data as SkattekontoBatchResult).results)
} else {
const message = getErrorMessage(json, { statusCode: res.status })
for (const id of chunk) {
results.push({ id, ok: false, error_code: 'UNKNOWN', error_message: message })
}
}
} catch {
for (const id of chunk) {
results.push({ id, ok: false, error_code: 'UNKNOWN' })
}
}
setBatchProgress({ done: Math.min(i + CHUNK, ids.length), total: ids.length })
}
} finally {
setBatchProgress(null)
setSkvBulkSubmitting(false)
}
// Rows that got a journal entry (posted, or a kept draft on
// COMMIT_FAILED) leave the inbox: patch locally instead of refetching.
const patched = new Map<string, string>()
for (const r of results) {
if (r.journal_entry_id) patched.set(r.id, r.journal_entry_id)
}
if (patched.size > 0) {
setExitingIds((prev) => {
const next = new Set(prev)
for (const id of patched.keys()) next.add(id)
return next
})
setTimeout(() => {
setSkvRows((prev) =>
prev.map((r) =>
patched.has(r.id) ? { ...r, journal_entry_id: patched.get(r.id)! } : r,
),
)
setExitingIds((prev) => {
const next = new Set(prev)
for (const id of patched.keys()) next.delete(id)
return next
})
}, 350)
}
setSkvSelectedIds(new Set())
const succeeded = results.filter((r) => r.ok).length
const failures = results.filter((r) => !r.ok)
if (failures.length === 0) {
toast({
title: 'Kunde inte bokföra',
description: err instanceof Error ? getErrorMessage(err) : undefined,
title: t('skv_bulk_done_title'),
description: t('skv_bulk_done_description', { count: succeeded }),
})
} else {
const codeCounts = new Map<string, number>()
for (const f of failures) {
const code = f.error_code ?? 'UNKNOWN'
codeCounts.set(code, (codeCounts.get(code) ?? 0) + 1)
}
const codeLabel = (code: string) =>
code === 'PERIOD_LOCKED'
? t('skv_err_period_locked')
: code === 'NO_COUNTER_ACCOUNT'
? t('skv_err_no_counter_account')
: code === 'ALREADY_BOOKED'
? t('skv_err_already_booked')
: code === 'NOT_SETTLED'
? t('skv_err_not_settled')
: code === 'COMMIT_FAILED'
? t('skv_err_commit_failed')
: t('skv_err_other')
const parts = [t('skv_bulk_partial_ok', { count: succeeded })]
for (const [code, n] of codeCounts) parts.push(`${n} ${codeLabel(code)}`)
toast({
title: t('skv_bulk_partial_title'),
description: parts.join(', '),
variant: 'destructive',
})
} finally {
setSkvProcessingId(null)
}
}
@@ -2099,6 +2324,7 @@ export default function TransactionsPage() {
function exitBatchMode() {
setSelectedIds(new Set())
setSkvSelectedIds(new Set())
}
async function handleBatchDelete() {
@@ -2605,7 +2831,7 @@ export default function TransactionsPage() {
{/* Bulkbar (concept): hidden until at least one transaction is
selected via the hover checkboxes, then it pops in with the
count and the batch actions. */}
{selectedIds.size > 0 && (
{(selectedIds.size > 0 || skvSelectedIds.size > 0) && (
<div className="flex flex-wrap items-center gap-x-5 gap-y-2 border-b border-border px-1 py-2.5 text-[12.5px] animate-fade-in">
{batchProgress ? (
<span className="flex items-center gap-2 text-muted-foreground">
@@ -2615,41 +2841,60 @@ export default function TransactionsPage() {
) : (
<>
<span className="whitespace-nowrap">
<strong className="font-semibold tabular-nums">{selectedIds.size}</strong>{' '}
{t('bulkbar_selected', { count: selectedIds.size })}
<strong className="font-semibold tabular-nums">
{selectedIds.size + skvSelectedIds.size}
</strong>{' '}
{t('bulkbar_selected', { count: selectedIds.size + skvSelectedIds.size })}
</span>
<Button size="sm" onClick={() => setShowBatchSelector(true)}>
{t('batch_book')}
</Button>
{/* Bulk-book (samlingsverifikation): only when ≥2 selected
on the same date + same direction. Disabled state
explains why via title. */}
<Button
size="sm"
variant="outline"
onClick={() => setBulkBookOpen(true)}
disabled={!bulkBookEligible}
title={!bulkBookEligible ? t('batch_bulk_book_hint') : undefined}
>
{t('batch_bulk_book')}
</Button>
<button type="button" className={QUIET_LINK_CLASS} onClick={handleBatchIgnore}>
{t('batch_ignore')}
</button>
<button
type="button"
className={cn(QUIET_LINK_CLASS, 'hover:text-destructive')}
onClick={handleBatchDelete}
>
{t('batch_delete')}
</button>
{selectedIds.size < selectableInboxIds.length && (
{selectedIds.size > 0 && (
<>
<Button size="sm" onClick={() => setShowBatchSelector(true)}>
{t('batch_book')}
</Button>
{/* Bulk-book (samlingsverifikation): only when ≥2 selected
on the same date + same direction. Disabled state
explains why via title. */}
<Button
size="sm"
variant="outline"
onClick={() => setBulkBookOpen(true)}
disabled={!bulkBookEligible}
title={!bulkBookEligible ? t('batch_bulk_book_hint') : undefined}
>
{t('batch_bulk_book')}
</Button>
<button type="button" className={QUIET_LINK_CLASS} onClick={handleBatchIgnore}>
{t('batch_ignore')}
</button>
<button
type="button"
className={cn(QUIET_LINK_CLASS, 'hover:text-destructive')}
onClick={handleBatchDelete}
>
{t('batch_delete')}
</button>
</>
)}
{/* SKV rows book through the skatteverket extension: one
summary confirmation, then draft+commit per row. */}
{skvSelectedIds.size > 0 && (
<Button size="sm" onClick={() => setSkvBulkConfirmOpen(true)}>
{t('batch_skv_book', { count: skvSelectedIds.size })}
</Button>
)}
{(selectedIds.size < selectableInboxIds.length ||
skvSelectedIds.size < selectableSkvIds.length) && (
<button
type="button"
className={QUIET_LINK_CLASS}
onClick={() => setSelectedIds(new Set(selectableInboxIds))}
onClick={() => {
setSelectedIds(new Set(selectableInboxIds))
setSkvSelectedIds(new Set(selectableSkvIds))
}}
>
{t('batch_select_all', { count: selectableInboxIds.length })}
{t('batch_select_all', {
count: selectableInboxIds.length + selectableSkvIds.length,
})}
</button>
)}
<button type="button" className={QUIET_LINK_CLASS} onClick={exitBatchMode}>
@@ -2706,7 +2951,11 @@ export default function TransactionsPage() {
key={`skv-${item.data.id}`}
row={item.data}
matchSuggestion={item.data.match_suggestion}
processing={skvProcessingId === item.data.id}
bookingSuggestion={item.data.booking_suggestion}
processing={skvBulkSubmitting}
selectable={isSkvBulkEligible(item.data)}
isSelected={skvSelectedIds.has(item.data.id)}
onToggleSelect={toggleSkvSelect}
onBokfor={handleSkvBokfor}
onMatch={r => setSkvMatchTarget(r)}
/>
@@ -3001,6 +3250,64 @@ export default function TransactionsPage() {
/>
)}
{skvBookTarget && (
<SkattekontoBookDialog
row={skvBookTarget}
open
onOpenChange={(o) => {
if (!o) setSkvBookTarget(null)
}}
onBooked={handleSkvBooked}
onMatch={() => {
setSkvMatchTarget(skvBookTarget)
setSkvBookTarget(null)
}}
/>
)}
{/* SKV bulk booking: one summary confirmation for the whole selection,
grouped by deterministic suggestion. Count-based: voucher numbers
are assigned atomically at commit. */}
{skvBulkConfirmOpen && (
<ConfirmationDialog
open
onOpenChange={setSkvBulkConfirmOpen}
title={t('skv_bulk_title', { count: skvSelectedIds.size })}
isSubmitting={skvBulkSubmitting}
confirmLabel={t('skv_bulk_confirm')}
warningText={t('skv_bulk_warning', { count: skvSelectedIds.size })}
onConfirm={handleSkvBulkConfirm}
>
<div className="space-y-2 py-2 text-sm">
{skvBulkGroups.map((group) => (
<div
key={`${group.account}|${group.label}`}
className="flex items-baseline justify-between gap-4"
>
<span className="min-w-0 truncate">
{t('skv_bulk_group', {
count: group.count,
label: group.label,
account: group.account,
})}
</span>
<span className={cn('shrink-0 tabular-nums', group.sum > 0 && 'text-success')}>
{group.sum > 0 ? '+' : ''}
{formatCurrency(group.sum)}
</span>
</div>
))}
<div className="flex items-baseline justify-between gap-4 border-t border-border pt-2 font-medium">
<span>{t('skv_bulk_total', { count: skvSelectedIds.size })}</span>
<span className="tabular-nums">
{skvBulkTotal > 0 ? '+' : ''}
{formatCurrency(skvBulkTotal)}
</span>
</div>
</div>
</ConfirmationDialog>
)}
{/* Prong B: match-against-supplier-invoice suggestion */}
{siMatchSuggestion && <Dialog
open