Files
accounted/components/bookkeeping/CorrectionEntryDialog.tsx
T
Emil 263c229903 feat: add journal entry correction flow with storno rollback
Add rollback logic to storno-service correctEntry to restore ledger
consistency if step 2 fails after reversal. Add correction API route at
/api/bookkeeping/journal-entries/[id]/correct. Add CorrectionEntryDialog
component and correction button to JournalEntryList.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 13:00:03 +01:00

267 lines
9.5 KiB
TypeScript

'use client'
import { useState, useEffect } from 'react'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import { AccountNumber } from '@/components/ui/account-number'
import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
import { useToast } from '@/components/ui/use-toast'
import { Plus, Trash2 } from 'lucide-react'
import type { JournalEntry, JournalEntryLine, BASAccount } from '@/types'
interface CorrectionLine {
account_number: string
debit_amount: string
credit_amount: string
line_description: string
}
interface Props {
entry: JournalEntry
open: boolean
onOpenChange: (open: boolean) => void
onCorrected: () => void
}
export default function CorrectionEntryDialog({ entry, open, onOpenChange, onCorrected }: Props) {
const { toast } = useToast()
const [accounts, setAccounts] = useState<BASAccount[]>([])
const [lines, setLines] = useState<CorrectionLine[]>([])
const [isSubmitting, setIsSubmitting] = useState(false)
const originalLines = ((entry.lines || []) as JournalEntryLine[])
.slice()
.sort((a, b) => a.sort_order - b.sort_order)
useEffect(() => {
if (open) {
// Pre-fill with original entry's lines
setLines(
originalLines.map((l) => ({
account_number: l.account_number,
debit_amount: Number(l.debit_amount) > 0 ? String(Number(l.debit_amount)) : '',
credit_amount: Number(l.credit_amount) > 0 ? String(Number(l.credit_amount)) : '',
line_description: l.line_description || '',
}))
)
fetchAccounts()
}
}, [open, entry.id]) // eslint-disable-line react-hooks/exhaustive-deps
async function fetchAccounts() {
try {
const res = await fetch('/api/bookkeeping/accounts')
const { data } = await res.json()
setAccounts(data || [])
} catch {
// Accounts will be empty — user can still type account numbers manually
}
}
const updateLine = (index: number, field: keyof CorrectionLine, value: string) => {
setLines((prev) => prev.map((l, i) => (i === index ? { ...l, [field]: value } : l)))
}
const addLine = () => {
setLines((prev) => [...prev, { account_number: '', debit_amount: '', credit_amount: '', line_description: '' }])
}
const removeLine = (index: number) => {
setLines((prev) => prev.filter((_, i) => i !== index))
}
const totalDebit = lines.reduce((sum, l) => sum + (parseFloat(l.debit_amount) || 0), 0)
const totalCredit = lines.reduce((sum, l) => sum + (parseFloat(l.credit_amount) || 0), 0)
const roundedDebit = Math.round(totalDebit * 100) / 100
const roundedCredit = Math.round(totalCredit * 100) / 100
const isBalanced = roundedDebit === roundedCredit && roundedDebit > 0
const hasValidLines = lines.length >= 2 && lines.every((l) => l.account_number.length === 4)
async function handleSubmit() {
if (!isBalanced || !hasValidLines) return
setIsSubmitting(true)
try {
const apiLines = lines.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 res = await fetch(`/api/bookkeeping/journal-entries/${entry.id}/correct`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ lines: apiLines }),
})
if (!res.ok) {
const { error } = await res.json()
throw new Error(error || 'Failed to create correction')
}
toast({ title: 'Ändringsverifikation skapad', description: 'Storno och rättelse har bokförts.' })
onOpenChange(false)
onCorrected()
} catch (err) {
toast({
title: 'Fel',
description: err instanceof Error ? err.message : 'Kunde inte skapa ändringsverifikation',
variant: 'destructive',
})
} finally {
setIsSubmitting(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Skapa ändringsverifikation</DialogTitle>
</DialogHeader>
{/* Original entry (read-only) */}
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span className="font-mono">{entry.voucher_series}{entry.voucher_number}</span>
<span>{entry.entry_date}</span>
<Badge variant="outline" className="text-xs">Original</Badge>
</div>
<p className="text-sm">{entry.description}</p>
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="py-1.5 w-48">Konto</th>
<th className="py-1.5">Beskrivning</th>
<th className="py-1.5 w-28 text-right">Debet</th>
<th className="py-1.5 w-28 text-right">Kredit</th>
</tr>
</thead>
<tbody>
{originalLines.map((line) => (
<tr key={line.id} className="border-b last:border-0">
<td className="py-1.5"><AccountNumber number={line.account_number} showName /></td>
<td className="py-1.5 text-muted-foreground">{line.line_description || ''}</td>
<td className="py-1.5 text-right">
{Number(line.debit_amount) > 0
? Number(line.debit_amount).toLocaleString('sv-SE', { minimumFractionDigits: 2 })
: ''}
</td>
<td className="py-1.5 text-right">
{Number(line.credit_amount) > 0
? Number(line.credit_amount).toLocaleString('sv-SE', { minimumFractionDigits: 2 })
: ''}
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Divider */}
<div className="border-t my-2" />
{/* Corrected lines (editable) */}
<div className="space-y-2">
<p className="text-sm font-medium">Rättade rader</p>
<div className="space-y-2">
{lines.map((line, index) => (
<div key={index} className="grid grid-cols-[1fr_1fr_120px_120px_auto] gap-2 items-start">
<AccountCombobox
value={line.account_number}
accounts={accounts}
onChange={(v) => updateLine(index, 'account_number', v)}
/>
<Input
value={line.line_description}
onChange={(e) => updateLine(index, 'line_description', e.target.value)}
placeholder="Beskrivning"
className="h-8"
/>
<Input
type="number"
value={line.debit_amount}
onChange={(e) => updateLine(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) => updateLine(index, 'credit_amount', e.target.value)}
placeholder="Kredit"
className="h-8 text-right"
min={0}
step="0.01"
/>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => removeLine(index)}
disabled={lines.length <= 2}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</div>
<Button variant="outline" size="sm" onClick={addLine}>
<Plus className="h-4 w-4 mr-1" />
Lägg till rad
</Button>
{/* Balance summary */}
<div className="flex justify-end gap-6 text-sm pt-2 border-t">
<div>
<span className="text-muted-foreground mr-2">Debet:</span>
<span className={!isBalanced ? 'text-destructive font-medium' : 'font-medium'}>
{roundedDebit.toLocaleString('sv-SE', { minimumFractionDigits: 2 })}
</span>
</div>
<div>
<span className="text-muted-foreground mr-2">Kredit:</span>
<span className={!isBalanced ? 'text-destructive font-medium' : 'font-medium'}>
{roundedCredit.toLocaleString('sv-SE', { minimumFractionDigits: 2 })}
</span>
</div>
</div>
{!isBalanced && roundedDebit + roundedCredit > 0 && (
<p className="text-sm text-destructive">
Debet och kredit måste vara lika och större än 0.
</p>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSubmitting}>
Avbryt
</Button>
<Button
onClick={handleSubmit}
disabled={!isBalanced || !hasValidLines || isSubmitting}
>
{isSubmitting ? 'Skapar...' : 'Skapa ändringsverifikation'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}