* feat: copy voucher, MRU booking templates, and PDF export for reports - Add "Kopiera verifikat" action on the journal-entry detail page that prefills a new draft with the source entry's lines, description, and notes. Date defaults to today so locked-period posts can't happen by accident; source_type resets to manual. - Track per-company MRU for booking_template_library rows via a new booking_template_usage table (fire-and-forget touch endpoint hooked into both pickers) and sort the list most-recently-used first for the active company. - Generate downloadable PDFs for balansräkning and resultaträkning using the existing @react-pdf/renderer toolchain. Adds a reusable parameterized template and two API routes, with download buttons on the matching report views. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: address PR review feedback on copy-voucher + report PDFs Compliance review (Swedish accounting): - Balance-sheet PDF now refuses to render when tillgångar ≠ eget kapital och skulder; the stale "Differens" summary row is gone. The on-screen view still surfaces the existing "Balanserar ej" warning so users can diagnose the imbalance before downloading. ÅRL 3 kap / K2 / K3 require exact balance. - Both PDF routes now 400 when the requested fiscal period cannot be resolved — identifiable period is part of räkenskapsinformation under BFL 7 kap. - Income-statement PDF adds the mandatory "Resultat efter finansiella poster" subtotal when financial items are present, per K2/K3 uppställningsform (ÅRL bilaga 2). - Copy-voucher flow now shows a clear banner ("Kopia av verifikat X — nytt, fristående verifikat skapas") so users cannot mistake the copy for a rättelse/storno. Code review (Greptile): - New migration adds updated_at column + trigger to booking_template_usage (project convention; applied to the Supabase project). - Replace localeCompare on ISO timestamps with plain relational comparison to avoid any locale-dependent ordering. - UUID-format validation on the copy_from query param before it goes into the fetch URL. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: second round of Swedish compliance fixes on report PDFs - Balance-sheet PDF imbalance check now compares rounded-to-whole-kronor totals (SFL 22:1 convention). The previous 0.5-öre tolerance could reject a legitimate balance sheet when accumulated floating-point noise across hundreds of ledger lines exceeded the threshold. The on-screen view still surfaces the öre-precise "Balanserar ej" badge for diagnostic visibility. - Both PDFs now carry a prominent "Arbetsutkast — ej undertecknat" notice per ÅRL 2 kap 7 §. Prevents a downloaded PDF from being mistaken for or filed as an approved årsredovisning. - Income-statement PDF now follows K2/K3 uppställningsform (ÅRL bilaga 2) by splitting class 8 into three blocks with named subtotals: Finansiella poster (80–84), Bokslutsdispositioner (88), Skatter (89). The summary now always shows a "Skatt på årets resultat" row so the reader can verify the tax calculation, and adds "Resultat efter finansiella poster" / "Bokslutsdispositioner" subtotals when each block is present. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: harden report PDFs against out-of-band filing + future BAS growth - Append "-utkast" to downloaded PDF filenames. The filename survives the PDF's disclaimer context — a file named balansrakning-2026-01-01.pdf in a Downloads folder or forwarded attachment is ambiguous, whereas balansrakning-2026-01-01-utkast.pdf makes the draft status legible even without opening the document. - Add a catch-all "Övriga finansiella poster" bucket in the income-statement PDF for any class-8 section whose account prefix isn't one of the known K2/K3 blocks (80–84 / 88 / 89). Counted in the "Resultat efter finansiella poster" subtotal so arithmetic stays consistent. Future-proofs the PDF against a generator change that starts emitting 85–87 sections. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
169 lines
6.4 KiB
TypeScript
169 lines
6.4 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useEffect } from 'react'
|
|
import Link from 'next/link'
|
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
|
|
import { Button } from '@/components/ui/button'
|
|
import JournalEntryList from '@/components/bookkeeping/JournalEntryList'
|
|
import JournalEntryForm, { type FormLine } from '@/components/bookkeeping/JournalEntryForm'
|
|
import ChartOfAccountsManager from '@/components/bookkeeping/ChartOfAccountsManager'
|
|
import { FiscalYearSelector } from '@/components/common/FiscalYearSelector'
|
|
import { useToast } from '@/components/ui/use-toast'
|
|
import { Lock, Loader2, Copy } from 'lucide-react'
|
|
import type { JournalEntry, JournalEntryLine } from '@/types'
|
|
|
|
interface CopyPrefill {
|
|
sourceId: string
|
|
sourceVoucherLabel: string
|
|
lines: FormLine[]
|
|
description: string
|
|
notes: string
|
|
}
|
|
|
|
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
|
|
|
function readCopyFromParam(): string | null {
|
|
if (typeof window === 'undefined') return null
|
|
const raw = new URLSearchParams(window.location.search).get('copy_from')
|
|
if (!raw) return null
|
|
// Guard against path-traversal or other malformed input in the fetch URL.
|
|
return UUID_RE.test(raw) ? raw : null
|
|
}
|
|
|
|
export default function BookkeepingPage() {
|
|
const { toast } = useToast()
|
|
const [refreshKey, setRefreshKey] = useState(0)
|
|
const [copyFromId] = useState<string | null>(readCopyFromParam)
|
|
const [activeTab, setActiveTab] = useState(() =>
|
|
copyFromId ? 'new-entry' : 'journal',
|
|
)
|
|
const [periodId, setPeriodId] = useState<string | null>(null)
|
|
const [copyPrefill, setCopyPrefill] = useState<CopyPrefill | null>(null)
|
|
const [isLoadingCopy, setIsLoadingCopy] = useState<boolean>(() => copyFromId !== null)
|
|
|
|
useEffect(() => {
|
|
if (!copyFromId) return
|
|
|
|
fetch(`/api/bookkeeping/journal-entries/${copyFromId}`)
|
|
.then((res) => res.json())
|
|
.then(({ data, error }: { data?: JournalEntry; error?: string }) => {
|
|
if (error || !data) {
|
|
toast({
|
|
title: 'Kunde inte kopiera verifikat',
|
|
description: error || 'Källverifikatet hittades inte.',
|
|
variant: 'destructive',
|
|
})
|
|
return
|
|
}
|
|
const sourceLines = ((data.lines || []) as JournalEntryLine[])
|
|
.slice()
|
|
.sort((a, b) => a.sort_order - b.sort_order)
|
|
const lines: FormLine[] = sourceLines.map((l) => {
|
|
const debit = Number(l.debit_amount) || 0
|
|
const credit = Number(l.credit_amount) || 0
|
|
return {
|
|
account_number: l.account_number,
|
|
debit_amount: debit > 0 ? debit.toFixed(2) : '',
|
|
credit_amount: credit > 0 ? credit.toFixed(2) : '',
|
|
line_description: l.line_description || '',
|
|
}
|
|
})
|
|
setCopyPrefill({
|
|
sourceId: copyFromId,
|
|
sourceVoucherLabel: `${data.voucher_series ?? ''}${data.voucher_number ?? ''}`,
|
|
lines,
|
|
description: data.description || '',
|
|
notes: data.notes || '',
|
|
})
|
|
})
|
|
.catch(() => {
|
|
toast({
|
|
title: 'Kunde inte kopiera verifikat',
|
|
description: 'Källverifikatet kunde inte hämtas.',
|
|
variant: 'destructive',
|
|
})
|
|
})
|
|
.finally(() => {
|
|
setIsLoadingCopy(false)
|
|
// Clean the URL so a page refresh doesn't re-trigger the copy prefill.
|
|
window.history.replaceState({}, '', '/bookkeeping')
|
|
})
|
|
}, [copyFromId, toast])
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
|
<div>
|
|
<h1 className="font-display text-2xl md:text-3xl font-medium tracking-tight">Bokföring</h1>
|
|
<p className="text-muted-foreground">
|
|
Skapa verifikationer, hantera kontoplanen och bifoga underlag
|
|
</p>
|
|
</div>
|
|
<Button variant="outline" asChild className="w-full sm:w-auto">
|
|
<Link href="/bookkeeping/year-end">
|
|
<Lock className="mr-2 h-4 w-4" />
|
|
Årsbokslut
|
|
</Link>
|
|
</Button>
|
|
</div>
|
|
|
|
{activeTab === 'journal' && (
|
|
<FiscalYearSelector value={periodId} onChange={setPeriodId} />
|
|
)}
|
|
|
|
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
|
<TabsList>
|
|
<TabsTrigger value="journal">Verifikationer</TabsTrigger>
|
|
<TabsTrigger value="new-entry">Ny verifikation</TabsTrigger>
|
|
<TabsTrigger value="accounts">Kontoplan</TabsTrigger>
|
|
</TabsList>
|
|
|
|
<TabsContent value="journal">
|
|
<JournalEntryList key={`${refreshKey}-${periodId ?? 'all'}`} periodId={periodId ?? undefined} />
|
|
</TabsContent>
|
|
|
|
<TabsContent value="new-entry">
|
|
{isLoadingCopy ? (
|
|
<div className="flex items-center gap-2 py-12 justify-center text-muted-foreground">
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
<span className="text-sm">Laddar källverifikat...</span>
|
|
</div>
|
|
) : (
|
|
<>
|
|
{copyPrefill && (
|
|
<div className="mb-4 flex items-start gap-3 rounded-lg border border-border bg-muted/30 p-3 text-sm">
|
|
<Copy className="h-4 w-4 mt-0.5 shrink-0 text-muted-foreground" />
|
|
<div className="flex-1">
|
|
<p className="font-medium">
|
|
Kopia av verifikat {copyPrefill.sourceVoucherLabel || '(okänt nummer)'}
|
|
</p>
|
|
<p className="text-muted-foreground mt-0.5">
|
|
Ett nytt, fristående verifikat skapas med egen verifikationsserie och nummer.
|
|
Detta är <strong>inte</strong> en rättelse eller storno av originalet — använd
|
|
"Skapa ändringsverifikation" om du vill korrigera källverifikatet.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
<JournalEntryForm
|
|
key={copyPrefill?.sourceId ?? 'fresh'}
|
|
onCreated={() => {
|
|
setRefreshKey((k) => k + 1)
|
|
setCopyPrefill(null)
|
|
}}
|
|
initialLines={copyPrefill?.lines}
|
|
initialDescription={copyPrefill?.description}
|
|
initialNotes={copyPrefill?.notes}
|
|
/>
|
|
</>
|
|
)}
|
|
</TabsContent>
|
|
|
|
<TabsContent value="accounts">
|
|
<ChartOfAccountsManager />
|
|
</TabsContent>
|
|
</Tabs>
|
|
</div>
|
|
)
|
|
}
|