Files
accounted/app/(dashboard)/bookkeeping/page.tsx
T
Jakob WennbergandClaude Opus 4.7 4cd0a55761 Copy voucher, MRU booking templates, and PDF export for reports (#303)
* 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>
2026-04-21 22:28:41 +02:00

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
&quot;Skapa ändringsverifikation&quot; 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>
)
}