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:
co-authored by
Claude Fable 5
Jakob Wennberg
parent
7cf0e34434
commit
dea5e31756
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user