Files
accounted/components/bookkeeping/StrikeLinesDialog.tsx
T
Jakob Wennberg 3ee3565d6d perf(reference-data): sweep the remaining raw reads onto the session cache, ratchet to 0 (#1941)
Final consumer migration of the responsiveness plan: the 35 files still
fetching fiscal periods, settings, accounts, cash accounts, dimensions or
templates on their own now read lib/reference-data, and every client
write site invalidates the shared cache instead of refetching locally.

Settings and registries: FiscalYearsManager, FiscalPeriodEditor (period
snapshotted once per company so a revalidation cannot reset dates being
edited), BookingTemplatesPanel, ChartOfAccounts, ChartOfAccountsManager,
EditAccountDialog, CorrectionEntryDialog, StrikeLinesDialog,
InvoicePaymentAccountsSettings; the dimensions registry (DimensionsManager,
DimensionCombobox, LineDimensionFields, DimensionFilter, bookkeeping/[id])
reads useDimensions and the ad-hoc fetchDimensions/fetchDimensionsCached
helpers are deleted.

Pages and pickers: CashAccountSelector (FyPicker-shaped restore, once per
company load), use-account-names, FiscalYearGapNotice,
OpeningBalancePeriodStep, BankFileConfirmStep, ImportReviewStep, the import
page (invalidates accounts + periods after a SIE execute), customers list,
invoices list + detail, pending, salary employee, asset dispose, year-end
and periodisering pages (invalidate periods after closing), reports
DimensionPnlView (its pivot picker read the wrong payload key and was
always empty; it now populates), SkatteverketPanel, TemplatePicker,
ArticleForm (vat_registered).

Invoice dialogs and extensions: SendInvoiceDialog, PaymentBookingDialog
(init reduced to the credit-note lookup + catalogue, proposal and voucher
preview fire on open when cached; a local getSession replaces the network
getUser for the fallback CC), InvoiceInboxWorkspace, TicWorkspace,
ArcimMigrationWorkspace (invalidates after each SIE import step),
enable-banking AccountPickerDialog.

raw-reference-fetch ratchet: 35 -> 0 files.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 14:56:37 +02:00

430 lines
18 KiB
TypeScript

'use client'
import { useState, useEffect, useMemo } from 'react'
import { useTranslations } from 'next-intl'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Checkbox } from '@/components/ui/checkbox'
import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
import RattelseExplainer from '@/components/bookkeeping/RattelseExplainer'
import { AddAccountDialog } from '@/components/bookkeeping/AddAccountDialog'
import { AccountNumber } from '@/components/ui/account-number'
import { useToast } from '@/components/ui/use-toast'
import { useAccounts } from '@/lib/reference-data/hooks'
import { invalidateReferenceData } from '@/lib/reference-data/invalidate'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { changeCorrectionLineAccount, getSelectableCorrectionCatalog } from '@/lib/bookkeeping/correction-line-account'
import { splitCreateAccountPrefill } from '@/lib/bookkeeping/create-account-prefill'
import { loadBasCatalog, type CatalogAccount } from '@/lib/bookkeeping/bas-catalog-client'
import { Loader2, Plus, Trash2 } from 'lucide-react'
import type { JournalEntry, JournalEntryLine } from '@/types'
interface NewLine {
account_number: string
debit_amount: string
credit_amount: string
line_description: string
}
interface Props {
entry: JournalEntry
open: boolean
onOpenChange: (open: boolean) => void
onCorrected: () => void
}
/**
* Inline line rättelse (BFL 5 kap 5 §): strike lines in a posted verifikat
* and add replacement lines in the SAME verifikat, without an
* ändringsverifikation. The struck originals stay visible (strikethrough)
* in the verifikat via the immutable rättelse log. Stays Swedish
* (verifikat surface, .claude/rules/i18n.md).
*/
export default function StrikeLinesDialog({ entry, open, onOpenChange, onCorrected }: Props) {
const { toast } = useToast()
const t = useTranslations('journal_detail')
// The full chart (deactivated rows included) comes from the session cache
// (lib/reference-data); only the static BAS catalogue is loaded per open,
// and it is module-cached after the first time.
const { accounts, isLoading: accountsLoading, error: accountsError, refresh: refreshAccounts } = useAccounts(false)
const [catalog, setCatalog] = useState<CatalogAccount[]>([])
const [catalogStatus, setCatalogStatus] = useState<'loading' | 'ready' | 'error'>('loading')
const accountsStatus: 'loading' | 'ready' | 'error' =
accountsLoading || catalogStatus === 'loading'
? 'loading'
: accountsError || catalogStatus === 'error'
? 'error'
: 'ready'
const [strikeIds, setStrikeIds] = useState<Set<string>>(new Set())
const [newLines, setNewLines] = useState<NewLine[]>([])
const [isSubmitting, setIsSubmitting] = useState(false)
// Index of the replacement line whose combobox opened the create dialog, and
// the search string it was showing. Null index = the dialog is closed.
const [creatingAccountForLine, setCreatingAccountForLine] = useState<number | null>(null)
const [createAccountPrefill, setCreateAccountPrefill] = useState('')
const activeAccounts = useMemo(
() => accounts.filter((account) => account.is_active),
[accounts],
)
const selectableCatalog = useMemo(
() => getSelectableCorrectionCatalog(accounts, catalog),
[accounts, catalog],
)
const originalLines = ((entry.lines || []) as JournalEntryLine[])
.slice()
.sort((a, b) => a.sort_order - b.sort_order)
useEffect(() => {
if (open) {
setStrikeIds(new Set())
setNewLines([])
void loadCatalog()
}
}, [open, entry.id])
async function loadCatalog() {
setCatalogStatus('loading')
try {
setCatalog(await loadBasCatalog())
setCatalogStatus('ready')
} catch {
setCatalog([])
setCatalogStatus('error')
}
}
const toggleStrike = (lineId: string) => {
setStrikeIds((prev) => {
const next = new Set(prev)
if (next.has(lineId)) next.delete(lineId)
else next.add(lineId)
return next
})
}
const updateNewLine = (index: number, field: keyof NewLine, value: string) => {
setNewLines((prev) => prev.map((l, i) => (i === index ? { ...l, [field]: value } : l)))
}
const updateNewLineAccount = (index: number, accountNumber: string) => {
setNewLines((prev) => prev.map((line, lineIndex) => (
lineIndex === index
? changeCorrectionLineAccount(line, accountNumber, [...accounts, ...catalog])
: line
)))
}
const addNewLine = () => {
setNewLines((prev) => [...prev, { account_number: '', debit_amount: '', credit_amount: '', line_description: '' }])
}
const removeNewLine = (index: number) => {
setNewLines((prev) => prev.filter((_, i) => i !== index))
}
const closeCreateAccount = () => {
setCreatingAccountForLine(null)
setCreateAccountPrefill('')
}
// A number that is neither in the company chart nor in BAS 2026 (a retired
// account such as 8022, or a company-specific underkonto) would otherwise be
// a dead end here: the rättelse can only post to accounts that exist in the
// chart. Creating it inline keeps the half-finished rättelse intact.
const handleAccountCreated = async (account: { account_number: string; account_name?: string }) => {
await invalidateReferenceData('ref:accounts')
if (creatingAccountForLine != null) {
// The refreshed cache is not visible in this closure, so the
// fresh account's own name is passed alongside the stale sources. The
// reactivate path reports no name, but that account is already in
// `accounts` (the fetch includes deactivated rows).
const created = account.account_name
? [{ account_number: account.account_number, account_name: account.account_name }]
: []
setNewLines((prev) => prev.map((line, index) => (
index === creatingAccountForLine
? changeCorrectionLineAccount(line, account.account_number, [...accounts, ...catalog, ...created])
: line
)))
}
closeCreateAccount()
}
// Effective verifikat after the rättelse: remaining original lines + new lines.
const remaining = originalLines.filter((l) => !strikeIds.has(l.id))
const remainingDebit = remaining.reduce((sum, l) => sum + (Number(l.debit_amount) || 0), 0)
const remainingCredit = remaining.reduce((sum, l) => sum + (Number(l.credit_amount) || 0), 0)
const newDebit = newLines.reduce((sum, l) => sum + (parseFloat(l.debit_amount) || 0), 0)
const newCredit = newLines.reduce((sum, l) => sum + (parseFloat(l.credit_amount) || 0), 0)
const totalDebit = Math.round((remainingDebit + newDebit) * 100) / 100
const totalCredit = Math.round((remainingCredit + newCredit) * 100) / 100
const isBalanced = totalDebit === totalCredit && totalDebit > 0
const newLinesValid = newLines.every((l) => {
const debit = parseFloat(l.debit_amount) || 0
const credit = parseFloat(l.credit_amount) || 0
return l.account_number.length === 4 && debit >= 0 && credit >= 0 && (debit > 0) !== (credit > 0)
})
const hasChange = strikeIds.size > 0 || newLines.length > 0
const effectiveCount = remaining.length + newLines.length
const canSubmit = hasChange && newLinesValid && isBalanced && effectiveCount >= 2
async function handleSubmit() {
if (!canSubmit) return
setIsSubmitting(true)
try {
const res = await fetch(`/api/bookkeeping/journal-entries/${entry.id}/strike-lines`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
strike_line_ids: [...strikeIds],
lines: newLines.map((l) => ({
account_number: l.account_number,
debit_amount: parseFloat(l.debit_amount) || 0,
credit_amount: parseFloat(l.credit_amount) || 0,
line_description: l.line_description || undefined,
})),
}),
})
const result = await res.json()
if (!res.ok) {
const error = new Error('Failed to strike lines') as Error & { body?: unknown; status?: number }
error.body = result
error.status = res.status
throw error
}
toast({
title: 'Verifikationen rättad',
description: 'De strukna raderna visas överstrukna i verifikatet.',
})
onOpenChange(false)
onCorrected()
} catch (err) {
const anyErr = err as { body?: unknown; status?: number }
toast({
title: 'Kunde inte rätta verifikationen',
description: getErrorMessage(anyErr.body ?? err, { context: 'journal_entry', statusCode: anyErr.status }),
variant: 'destructive',
})
} finally {
setIsSubmitting(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-3xl max-h-[95dvh] sm:max-h-[90vh] overflow-y-auto">
<DialogHeader>
{/* Convention 7: the how-it-works copy lives behind the "?", not in
the dialog flow. */}
<div className="flex items-center gap-2">
<DialogTitle>Stryk rader i verifikatet</DialogTitle>
<RattelseExplainer>
<p>
Här stryks felaktiga rader och ersätts direkt i samma verifikat,
utan ändringsverifikation. Det fungerar bara i öppna, olåsta
perioder.
</p>
<p>
Varje rättelse loggas med vem och när, och de ursprungliga
raderna förblir synliga i verifikatets rättelsehistorik.
</p>
<p>
Om månaden redan är momsdeklarerad kan en ändring av momskonton
påverka den inlämnade deklarationen.
</p>
</RattelseExplainer>
</div>
<DialogDescription>
De strukna raderna förblir synliga (överstrukna) i verifikatet.
</DialogDescription>
</DialogHeader>
{/* Original lines with strike checkboxes */}
<div className="space-y-1">
<p className="text-sm font-medium">Markera rader som ska strykas</p>
<div className="rounded-lg border divide-y">
{originalLines.map((line) => {
const struck = strikeIds.has(line.id)
// FX lines carry conversion data replacements cannot reproduce;
// the RPC rejects striking them, so the checkbox is disabled.
const isForeign = !!line.currency && line.currency !== 'SEK'
return (
<label
key={line.id}
className={`flex items-center gap-3 px-3 py-2 text-sm transition-colors ${isForeign ? 'opacity-60' : 'cursor-pointer hover:bg-secondary/60'}`}
title={isForeign ? 'Rader i utländsk valuta rättas med ändringsverifikat' : undefined}
>
<Checkbox
checked={struck}
disabled={isForeign}
onCheckedChange={() => toggleStrike(line.id)}
/>
<span className={`flex-1 min-w-0 ${struck ? 'line-through text-muted-foreground' : ''}`}>
<AccountNumber number={line.account_number} showName />
{line.line_description && (
<span className="text-muted-foreground ml-2">{line.line_description}</span>
)}
</span>
<span className={`tabular-nums shrink-0 ${struck ? 'line-through text-muted-foreground' : ''}`}>
{Number(line.debit_amount) > 0
? `${Number(line.debit_amount).toLocaleString('sv-SE', { minimumFractionDigits: 2 })} D`
: `${Number(line.credit_amount).toLocaleString('sv-SE', { minimumFractionDigits: 2 })} K`}
</span>
</label>
)
})}
</div>
</div>
{/* Replacement lines */}
<div className="space-y-2">
<div className="space-y-1">
<p className="text-sm font-medium">Ersättningsrader</p>
<p className="text-xs text-muted-foreground">
Lägg till de rader som ska gälla i stället. Verifikationen måste balansera efter rättelsen.
</p>
</div>
{accountsStatus !== 'ready' && (
<div className="flex items-center justify-between gap-3 rounded-lg border bg-muted/50 p-3 text-sm text-muted-foreground">
<span className="flex items-center gap-2">
{accountsStatus === 'loading' && <Loader2 className="h-4 w-4 animate-spin" />}
{accountsStatus === 'loading' ? t('accounts_loading') : t('accounts_load_failed')}
</span>
{accountsStatus === 'error' && (
<Button variant="outline" size="sm" onClick={() => void refreshAccounts()}>
{t('accounts_retry')}
</Button>
)}
</div>
)}
<div className="space-y-2">
{newLines.map((line, index) => (
<div key={index} className="space-y-2 sm:space-y-0 sm:grid sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_120px_120px_auto] sm:gap-2 sm:items-start border-b sm:border-0 pb-3 sm:pb-0 last:border-0">
<div className="grid grid-cols-[minmax(0,1fr)_auto] sm:contents gap-2">
{/* min-w-0: at sm: the sm:contents wrapper promotes this cell
to a direct grid item; without it the combobox refuses to
shrink below its content and overflows the dialog (same
pattern as SendInvoiceDialog's desktop rows). */}
<div className="min-w-0">
<AccountCombobox
value={line.account_number}
accounts={activeAccounts}
catalog={selectableCatalog}
onChange={(v) => updateNewLineAccount(index, v)}
onCreateAccount={(prefill) => {
setCreatingAccountForLine(index)
setCreateAccountPrefill(prefill)
}}
disabled={accountsStatus !== 'ready'}
/>
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 min-h-[44px] min-w-[44px] sm:order-last"
onClick={() => removeNewLine(index)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
<Input
value={line.line_description}
onChange={(e) => updateNewLine(index, 'line_description', e.target.value)}
placeholder="Beskrivning"
className="h-8"
/>
<div className="grid grid-cols-2 gap-2 sm:contents">
<Input
type="number"
value={line.debit_amount}
onChange={(e) => updateNewLine(index, 'debit_amount', e.target.value)}
placeholder="Debet"
className="h-8 text-right"
min={0}
step="0.01"
/>
<Input
type="number"
value={line.credit_amount}
onChange={(e) => updateNewLine(index, 'credit_amount', e.target.value)}
placeholder="Kredit"
className="h-8 text-right"
min={0}
step="0.01"
/>
</div>
</div>
))}
</div>
<Button variant="outline" size="sm" onClick={addNewLine}>
<Plus className="h-4 w-4 mr-1" />
Lägg till rad
</Button>
</div>
{/* Effective balance after the rättelse */}
<div className="flex justify-end gap-6 text-sm pt-2 border-t">
<div>
<span className="text-muted-foreground mr-2">Debet efter rättelse:</span>
<span className={!isBalanced ? 'text-destructive font-medium' : 'font-medium'}>
{totalDebit.toLocaleString('sv-SE', { minimumFractionDigits: 2 })}
</span>
</div>
<div>
<span className="text-muted-foreground mr-2">Kredit efter rättelse:</span>
<span className={!isBalanced ? 'text-destructive font-medium' : 'font-medium'}>
{totalCredit.toLocaleString('sv-SE', { minimumFractionDigits: 2 })}
</span>
</div>
</div>
{hasChange && !isBalanced && (
<p className="text-sm text-destructive">
Debet och kredit måste vara lika och större än 0 efter rättelsen.
</p>
)}
{hasChange && isBalanced && effectiveCount < 2 && (
<p className="text-sm text-destructive">
Verifikationen måste ha minst två rader efter rättelsen. Använd Återför (storno) för att
makulera hela verifikatet.
</p>
)}
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSubmitting}>
Avbryt
</Button>
<Button onClick={handleSubmit} disabled={!canSubmit || isSubmitting}>
{isSubmitting ? 'Rättar...' : 'Rätta verifikatet'}
</Button>
</DialogFooter>
</DialogContent>
{/* Nested on purpose: closing this one (Esc, click-outside, Avbryt) must
leave the rättelse behind it untouched. */}
<AddAccountDialog
open={creatingAccountForLine != null}
onOpenChange={(next) => {
if (!next) closeCreateAccount()
}}
onCreated={handleAccountCreated}
{...splitCreateAccountPrefill(createAccountPrefill)}
/>
</Dialog>
)
}