diff --git a/app/(dashboard)/bookkeeping/[id]/page.tsx b/app/(dashboard)/bookkeeping/[id]/page.tsx index f360b258..1a31567c 100644 --- a/app/(dashboard)/bookkeeping/[id]/page.tsx +++ b/app/(dashboard)/bookkeeping/[id]/page.tsx @@ -7,7 +7,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Button } from '@/components/ui/button' import { AccountNumber } from '@/components/ui/account-number' import { Textarea } from '@/components/ui/textarea' -import { Loader2, ArrowLeft, Paperclip, AlertTriangle, Lock, MessageSquare, Pencil, Check, X } from 'lucide-react' +import { Loader2, ArrowLeft, Paperclip, AlertTriangle, Lock, MessageSquare, Pencil, Check, X, Copy } from 'lucide-react' import { useCanWrite } from '@/lib/hooks/use-can-write' import JournalEntryAttachments from '@/components/bookkeeping/JournalEntryAttachments' import JournalEntryStatusBadge, { sourceTypeLabels } from '@/components/bookkeeping/JournalEntryStatusBadge' @@ -205,6 +205,17 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i Skapa ändringsverifikation )} + )} diff --git a/app/(dashboard)/bookkeeping/page.tsx b/app/(dashboard)/bookkeeping/page.tsx index 2148540b..d9612ec8 100644 --- a/app/(dashboard)/bookkeeping/page.tsx +++ b/app/(dashboard)/bookkeeping/page.tsx @@ -1,19 +1,94 @@ 'use client' -import { useState } from 'react' +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 from '@/components/bookkeeping/JournalEntryForm' +import JournalEntryForm, { type FormLine } from '@/components/bookkeeping/JournalEntryForm' import ChartOfAccountsManager from '@/components/bookkeeping/ChartOfAccountsManager' import { FiscalYearSelector } from '@/components/common/FiscalYearSelector' -import { Lock } from 'lucide-react' +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 [activeTab, setActiveTab] = useState('journal') + const [copyFromId] = useState(readCopyFromParam) + const [activeTab, setActiveTab] = useState(() => + copyFromId ? 'new-entry' : 'journal', + ) const [periodId, setPeriodId] = useState(null) + const [copyPrefill, setCopyPrefill] = useState(null) + const [isLoadingCopy, setIsLoadingCopy] = useState(() => 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 (
@@ -48,7 +123,40 @@ export default function BookkeepingPage() { - setRefreshKey((k) => k + 1)} /> + {isLoadingCopy ? ( +
+ + Laddar källverifikat... +
+ ) : ( + <> + {copyPrefill && ( +
+ +
+

+ Kopia av verifikat {copyPrefill.sourceVoucherLabel || '(okänt nummer)'} +

+

+ Ett nytt, fristående verifikat skapas med egen verifikationsserie och nummer. + Detta är inte en rättelse eller storno av originalet — använd + "Skapa ändringsverifikation" om du vill korrigera källverifikatet. +

+
+
+ )} + { + setRefreshKey((k) => k + 1) + setCopyPrefill(null) + }} + initialLines={copyPrefill?.lines} + initialDescription={copyPrefill?.description} + initialNotes={copyPrefill?.notes} + /> + + )}
diff --git a/app/(dashboard)/reports/page.tsx b/app/(dashboard)/reports/page.tsx index c37c5afc..ce65688f 100644 --- a/app/(dashboard)/reports/page.tsx +++ b/app/(dashboard)/reports/page.tsx @@ -617,6 +617,17 @@ function IncomeStatementView({ periodId, onNavigateToAccount }: { periodId: stri return (
+
+ +
+ {!monthlyLoading && monthlyData.length > 0 && ( )} @@ -751,6 +762,17 @@ function BalanceSheetView({ periodId, onNavigateToAccount }: { periodId: string; return (
+
+ +
+ {/* Assets */} diff --git a/app/api/reports/balance-sheet/pdf/route.ts b/app/api/reports/balance-sheet/pdf/route.ts new file mode 100644 index 00000000..9c904bca --- /dev/null +++ b/app/api/reports/balance-sheet/pdf/route.ts @@ -0,0 +1,115 @@ +import { createClient } from '@/lib/supabase/server' +import { NextResponse } from 'next/server' +import { renderToBuffer } from '@react-pdf/renderer' +import { generateBalanceSheet } from '@/lib/reports/balance-sheet' +import { FinancialStatementPDF } from '@/lib/reports/financial-statement-pdf-template' +import { requireCompanyId } from '@/lib/company/context' +import type { CompanySettings } from '@/types' + +export async function GET(request: Request) { + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const companyId = await requireCompanyId(supabase, user.id) + + const { searchParams } = new URL(request.url) + const periodId = searchParams.get('period_id') + + if (!periodId) { + return NextResponse.json({ error: 'period_id is required' }, { status: 400 }) + } + + const [{ data: period }, { data: companyRow }] = await Promise.all([ + supabase + .from('fiscal_periods') + .select('period_start, period_end') + .eq('id', periodId) + .eq('company_id', companyId) + .single(), + supabase + .from('company_settings') + .select('*') + .eq('company_id', companyId) + .single(), + ]) + + if (!companyRow) { + return NextResponse.json({ error: 'Företagsinställningar saknas' }, { status: 404 }) + } + // An identifiable period is part of räkenskapsinformation (BFL 7 kap). Refuse + // to render a PDF that can't be archived with the period it refers to. + if (!period) { + return NextResponse.json( + { error: 'Räkenskapsperioden kunde inte läsas. Välj en befintlig period innan du genererar PDF.' }, + { status: 400 } + ) + } + + try { + const report = await generateBalanceSheet(supabase, companyId, periodId) + report.period = { start: period.period_start, end: period.period_end } + + const totalAssets = report.total_assets + const totalEquityLiab = report.total_equity_liabilities + + // ÅRL 3 kap / K2 / K3 require balansräkningen to balance. Compare rounded + // to whole kronor — matches SFL 22:1's truncation convention for statutory + // reports and is immune to floating-point accumulation across hundreds of + // ledger lines (öresavrundning noise under half a krona is never a real + // accounting error). The on-screen view still surfaces a "Balanserar ej" + // warning at öre precision so users can diagnose smaller discrepancies. + const diffInKronor = Math.abs(Math.round(totalAssets) - Math.round(totalEquityLiab)) + if (diffInKronor >= 1) { + return NextResponse.json( + { + error: + 'Balansräkningen balanserar inte (tillgångar ≠ eget kapital och skulder). Åtgärda differensen innan du genererar PDF.', + }, + { status: 400 } + ) + } + + const pdfBuffer = await renderToBuffer( + FinancialStatementPDF({ + title: 'Balansräkning', + groups: [ + { + heading: 'Tillgångar', + sections: report.asset_sections, + totalLabel: 'Summa tillgångar', + total: totalAssets, + }, + { + heading: 'Eget kapital och skulder', + sections: report.equity_liability_sections, + totalLabel: 'Summa eget kapital och skulder', + total: totalEquityLiab, + }, + ], + period: report.period, + company: companyRow as CompanySettings, + generatedAt: new Date().toISOString(), + }) + ) + + // "-utkast" suffix keeps the draft status visible even after the file + // leaves the browser — complements the in-document ÅRL 2:7 disclaimer. + const filename = `balansrakning-${report.period.start}-utkast.pdf` + + return new Response(new Uint8Array(pdfBuffer), { + headers: { + 'Content-Type': 'application/pdf', + 'Content-Disposition': `attachment; filename="${filename}"`, + }, + }) + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : 'Kunde inte generera balansräkning' }, + { status: 500 } + ) + } +} diff --git a/app/api/reports/income-statement/pdf/route.ts b/app/api/reports/income-statement/pdf/route.ts new file mode 100644 index 00000000..0c56cd07 --- /dev/null +++ b/app/api/reports/income-statement/pdf/route.ts @@ -0,0 +1,215 @@ +import { createClient } from '@/lib/supabase/server' +import { NextResponse } from 'next/server' +import { renderToBuffer } from '@react-pdf/renderer' +import { generateIncomeStatement } from '@/lib/reports/income-statement' +import { FinancialStatementPDF, type FinancialStatementGroup, type FinancialStatementSection, type FinancialStatementSummaryRow } from '@/lib/reports/financial-statement-pdf-template' +import { requireCompanyId } from '@/lib/company/context' +import type { CompanySettings } from '@/types' + +// K2/K3 uppställningsform (ÅRL bilaga 2, kostnadsslagsindelad) splits class 8 +// into three named blocks with subtotals: +// 80–84 → Finansiella poster (followed by "Resultat efter finansiella poster") +// 88 → Bokslutsdispositioner +// 89 → Skatt på årets resultat +// The generator lumps these together under financial_sections, so we split +// here by the first row's account prefix. +const FINANSIELLA_POSTER_PREFIXES = ['80', '81', '82', '83', '84'] +const BOKSLUTSDISPOSITIONER_PREFIXES = ['88'] +const SKATT_PREFIXES = ['89'] +const KNOWN_CLASS_8_PREFIXES = [ + ...FINANSIELLA_POSTER_PREFIXES, + ...BOKSLUTSDISPOSITIONER_PREFIXES, + ...SKATT_PREFIXES, +] + +function sectionPrefix(section: FinancialStatementSection, prefixes: string[]): boolean { + if (section.rows.length === 0) return false + const acc = section.rows[0].account_number + return prefixes.some((p) => acc.startsWith(p)) +} + +export async function GET(request: Request) { + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const companyId = await requireCompanyId(supabase, user.id) + + const { searchParams } = new URL(request.url) + const periodId = searchParams.get('period_id') + + if (!periodId) { + return NextResponse.json({ error: 'period_id is required' }, { status: 400 }) + } + + const [{ data: period }, { data: companyRow }] = await Promise.all([ + supabase + .from('fiscal_periods') + .select('period_start, period_end') + .eq('id', periodId) + .eq('company_id', companyId) + .single(), + supabase + .from('company_settings') + .select('*') + .eq('company_id', companyId) + .single(), + ]) + + if (!companyRow) { + return NextResponse.json({ error: 'Företagsinställningar saknas' }, { status: 404 }) + } + // An identifiable period is part of räkenskapsinformation (BFL 7 kap). Refuse + // to render a PDF that can't be archived with the period it refers to. + if (!period) { + return NextResponse.json( + { error: 'Räkenskapsperioden kunde inte läsas. Välj en befintlig period innan du genererar PDF.' }, + { status: 400 } + ) + } + + try { + const report = await generateIncomeStatement(supabase, companyId, periodId) + report.period = { start: period.period_start, end: period.period_end } + + const operatingResult = Math.round((report.total_revenue - report.total_expenses) * 100) / 100 + + // Split class 8 into its three K2/K3 blocks plus a catch-all for any + // prefix the generator emits but we haven't explicitly mapped. If a future + // generator change adds sections for 85/86/87 or similar, this keeps them + // visible and arithmetically accounted for rather than silently dropped. + const finansiellaPosterSections = report.financial_sections.filter((s) => + sectionPrefix(s, FINANSIELLA_POSTER_PREFIXES), + ) + const bokslutsdispositionerSections = report.financial_sections.filter((s) => + sectionPrefix(s, BOKSLUTSDISPOSITIONER_PREFIXES), + ) + const skattSections = report.financial_sections.filter((s) => + sectionPrefix(s, SKATT_PREFIXES), + ) + const ovrigaFinansiellaPosterSections = report.financial_sections.filter( + (s) => !sectionPrefix(s, KNOWN_CLASS_8_PREFIXES), + ) + + const totalFinansiellaPoster = Math.round( + finansiellaPosterSections.reduce((sum, s) => sum + s.subtotal, 0) * 100, + ) / 100 + const totalBokslutsdispositioner = Math.round( + bokslutsdispositionerSections.reduce((sum, s) => sum + s.subtotal, 0) * 100, + ) / 100 + const totalSkatt = Math.round( + skattSections.reduce((sum, s) => sum + s.subtotal, 0) * 100, + ) / 100 + const totalOvrigaFinansiellaPoster = Math.round( + ovrigaFinansiellaPosterSections.reduce((sum, s) => sum + s.subtotal, 0) * 100, + ) / 100 + // Catch-all is treated as part of "finansiella poster" for the subtotal — + // 85-87 accounts in BAS are financial-adjacent (not tax, not bokslut). + const resultatEfterFinansiellaPoster = Math.round( + (operatingResult + totalFinansiellaPoster + totalOvrigaFinansiellaPoster) * 100, + ) / 100 + + const groups: FinancialStatementGroup[] = [ + { + heading: 'Rörelseintäkter', + sections: report.revenue_sections, + totalLabel: 'Summa rörelseintäkter', + total: report.total_revenue, + }, + { + heading: 'Rörelsekostnader', + sections: report.expense_sections, + totalLabel: 'Summa rörelsekostnader', + total: report.total_expenses, + negate: true, + }, + ] + + if (finansiellaPosterSections.length > 0) { + groups.push({ + heading: 'Finansiella poster', + sections: finansiellaPosterSections, + totalLabel: 'Summa finansiella poster', + total: totalFinansiellaPoster, + }) + } + if (ovrigaFinansiellaPosterSections.length > 0) { + groups.push({ + heading: 'Övriga finansiella poster', + sections: ovrigaFinansiellaPosterSections, + totalLabel: 'Summa övriga finansiella poster', + total: totalOvrigaFinansiellaPoster, + }) + } + if (bokslutsdispositionerSections.length > 0) { + groups.push({ + heading: 'Bokslutsdispositioner', + sections: bokslutsdispositionerSections, + totalLabel: 'Summa bokslutsdispositioner', + total: totalBokslutsdispositioner, + }) + } + if (skattSections.length > 0) { + groups.push({ + heading: 'Skatter', + sections: skattSections, + totalLabel: 'Summa skatter', + total: totalSkatt, + }) + } + + // K2/K3 uppställningsform (ÅRL bilaga 2) summary structure: + // Rörelseresultat + // Resultat efter finansiella poster (only if finansiella poster present) + // Bokslutsdispositioner (only if present) + // Skatt på årets resultat (always, so the reader can verify the tax calc) + // Årets resultat + const summary: FinancialStatementSummaryRow[] = [ + { label: 'Rörelseresultat', amount: operatingResult }, + ] + if ( + finansiellaPosterSections.length > 0 || + ovrigaFinansiellaPosterSections.length > 0 + ) { + summary.push({ + label: 'Resultat efter finansiella poster', + amount: resultatEfterFinansiellaPoster, + }) + } + if (bokslutsdispositionerSections.length > 0) { + summary.push({ label: 'Bokslutsdispositioner', amount: totalBokslutsdispositioner }) + } + summary.push({ label: 'Skatt på årets resultat', amount: totalSkatt }) + summary.push({ label: 'Årets resultat', amount: report.net_result, emphasis: true }) + + const pdfBuffer = await renderToBuffer( + FinancialStatementPDF({ + title: 'Resultaträkning', + groups, + summary, + period: report.period, + company: companyRow as CompanySettings, + generatedAt: new Date().toISOString(), + }) + ) + + // "-utkast" suffix keeps the draft status visible even after the file + // leaves the browser — complements the in-document ÅRL 2:7 disclaimer. + const filename = `resultatrakning-${report.period.start}-utkast.pdf` + + return new Response(new Uint8Array(pdfBuffer), { + headers: { + 'Content-Type': 'application/pdf', + 'Content-Disposition': `attachment; filename="${filename}"`, + }, + }) + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : 'Kunde inte generera resultaträkning' }, + { status: 500 } + ) + } +} diff --git a/app/api/settings/booking-templates/[id]/touch/route.ts b/app/api/settings/booking-templates/[id]/touch/route.ts new file mode 100644 index 00000000..5fa2ec7f --- /dev/null +++ b/app/api/settings/booking-templates/[id]/touch/route.ts @@ -0,0 +1,41 @@ +import { createClient } from '@/lib/supabase/server' +import { NextResponse } from 'next/server' +import { requireCompanyId } from '@/lib/company/context' + +/** + * POST /api/settings/booking-templates/[id]/touch + * + * Record that this template was applied by the current company. Upserts the + * (template_id, company_id) row in booking_template_usage, refreshing + * last_used_at. Used by the template pickers to drive MRU ordering. + * + * Fire-and-forget from the client — errors are non-fatal. + */ +export async function POST( + _request: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const { id } = await params + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const companyId = await requireCompanyId(supabase, user.id) + + const { error } = await supabase + .from('booking_template_usage') + .upsert( + { + template_id: id, + company_id: companyId, + last_used_at: new Date().toISOString(), + }, + { onConflict: 'template_id,company_id' }, + ) + + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }) + } + + return NextResponse.json({ data: { success: true } }) +} diff --git a/app/api/settings/booking-templates/route.ts b/app/api/settings/booking-templates/route.ts index fa4e0420..3fa5814c 100644 --- a/app/api/settings/booking-templates/route.ts +++ b/app/api/settings/booking-templates/route.ts @@ -31,24 +31,67 @@ const CreateBookingTemplateSchema = z.object({ * GET /api/settings/booking-templates * Returns all templates visible to the current user: * system + company + team templates. + * + * Ordering: most recently used (per current company) first, then by category + * and name for never-used templates. Usage is tracked in + * booking_template_usage via POST /[id]/touch. */ export async function GET() { const supabase = await createClient() const { data: { user } } = await supabase.auth.getUser() if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + const companyId = await requireCompanyId(supabase, user.id) + // RLS handles scoping (system OR company OR team) - const { data, error } = await supabase - .from('booking_template_library') - .select('*') - .eq('is_active', true) - .order('is_system', { ascending: false }) - .order('category') - .order('name') + const [templatesRes, usageRes] = await Promise.all([ + supabase + .from('booking_template_library') + .select('*') + .eq('is_active', true) + .order('category') + .order('name'), + supabase + .from('booking_template_usage') + .select('template_id, last_used_at') + .eq('company_id', companyId), + ]) - if (error) return NextResponse.json({ error: error.message }, { status: 500 }) + if (templatesRes.error) { + return NextResponse.json({ error: templatesRes.error.message }, { status: 500 }) + } + // usage lookup failing is non-fatal — we just fall back to default ordering + const usageByTemplate = new Map() + if (!usageRes.error && usageRes.data) { + for (const row of usageRes.data) { + usageByTemplate.set(row.template_id, row.last_used_at) + } + } - return NextResponse.json({ data }) + const templates = templatesRes.data ?? [] + const decorated = templates.map((t) => ({ + ...t, + last_used_at: usageByTemplate.get(t.id) ?? null, + })) + + // Stable-sort: templates with last_used_at come first (most-recent first). + // Templates without usage keep their category/name order from the query. + // ISO 8601 timestamps are fixed-width ASCII — plain relational comparison + // is correct and avoids any locale-dependent behaviour from localeCompare. + decorated.sort((a, b) => { + const aUsed = a.last_used_at + const bUsed = b.last_used_at + if (aUsed && bUsed) { + if (bUsed > aUsed) return -1 + if (bUsed < aUsed) return 1 + return 0 + } + if (aUsed) return -1 + if (bUsed) return 1 + return 0 + }) + + return NextResponse.json({ data: decorated }) } /** diff --git a/components/bookkeeping/BookingTemplatePicker.tsx b/components/bookkeeping/BookingTemplatePicker.tsx index 3c8f6925..cdac7c9f 100644 --- a/components/bookkeeping/BookingTemplatePicker.tsx +++ b/components/bookkeeping/BookingTemplatePicker.tsx @@ -101,6 +101,8 @@ export default function BookingTemplatePicker({ onApply, entityType }: Props) { return } const lines = applyTemplate(selected.lines, totalAmount) + // Fire-and-forget MRU bump so this template surfaces at the top next time. + fetch(`/api/settings/booking-templates/${selected.id}/touch`, { method: 'POST' }).catch(() => {}) onApply(lines, selected.name) setOpen(false) setSelectedId(null) diff --git a/components/transactions/TemplatePicker.tsx b/components/transactions/TemplatePicker.tsx index 2d4c8688..33a11a66 100644 --- a/components/transactions/TemplatePicker.tsx +++ b/components/transactions/TemplatePicker.tsx @@ -14,7 +14,7 @@ import { } from '@/lib/bookkeeping/booking-templates' import { formatAccountWithName } from '@/lib/bookkeeping/client-account-names' import { isCounterpartyTemplateId } from '@/lib/bookkeeping/counterparty-templates' -import { convertLibraryToBookingTemplate } from '@/lib/bookkeeping/template-library' +import { convertLibraryToBookingTemplate, LIBRARY_TEMPLATE_PREFIX, isLibraryTemplateId } from '@/lib/bookkeeping/template-library' import { getAccountName } from '@/lib/bookkeeping/client-account-names' import type { BookingTemplateLibrary, EntityType } from '@/types' import type { SuggestedTemplate } from '@/lib/transactions/category-suggestions' @@ -237,6 +237,11 @@ export default function TemplatePicker({ const advancedGrouped = useMemo(() => groupTemplates(allAdvanced), [allAdvanced]) const handleSelect = (template: BookingTemplate) => { + // For library-backed templates, bump MRU so they surface at the top next time. + if (isLibraryTemplateId(template.id)) { + const libraryId = template.id.slice(LIBRARY_TEMPLATE_PREFIX.length) + fetch(`/api/settings/booking-templates/${libraryId}/touch`, { method: 'POST' }).catch(() => {}) + } onSelect(template) } diff --git a/lib/reports/__tests__/financial-statement-pdf-template.test.ts b/lib/reports/__tests__/financial-statement-pdf-template.test.ts new file mode 100644 index 00000000..e15c176b --- /dev/null +++ b/lib/reports/__tests__/financial-statement-pdf-template.test.ts @@ -0,0 +1,140 @@ +import { describe, it, expect } from 'vitest' +import { renderToBuffer } from '@react-pdf/renderer' +import { FinancialStatementPDF } from '../financial-statement-pdf-template' +import type { CompanySettings } from '@/types' + +function fakeCompany(): CompanySettings { + return { + company_name: 'Gnubok AB', + trade_name: 'Gnubok', + org_number: '5566778899', + vat_number: 'SE556677889901', + address_line1: 'Kungsgatan 1', + postal_code: '11143', + city: 'Stockholm', + country: 'SE', + entity_type: 'aktiebolag', + } as unknown as CompanySettings +} + +describe('FinancialStatementPDF', () => { + it('renders a balance-sheet-shaped document to a PDF buffer', async () => { + const doc = FinancialStatementPDF({ + title: 'Balansräkning', + groups: [ + { + heading: 'Tillgångar', + sections: [ + { + title: 'Kassa och bank', + rows: [ + { account_number: '1930', account_name: 'Företagskonto', amount: 125_432.5 }, + ], + subtotal: 125_432.5, + }, + ], + totalLabel: 'Summa tillgångar', + total: 125_432.5, + }, + { + heading: 'Eget kapital och skulder', + sections: [ + { + title: 'Eget kapital', + rows: [ + { account_number: '2010', account_name: 'Eget kapital', amount: 100_000 }, + { account_number: '2091', account_name: 'Balanserat resultat', amount: 25_432.5 }, + ], + subtotal: 125_432.5, + }, + ], + totalLabel: 'Summa eget kapital och skulder', + total: 125_432.5, + }, + ], + period: { start: '2026-01-01', end: '2026-12-31' }, + company: fakeCompany(), + generatedAt: '2026-04-21T10:00:00Z', + }) + + const buffer = await renderToBuffer(doc) + expect(buffer).toBeInstanceOf(Buffer) + expect(buffer.length).toBeGreaterThan(1000) + // PDF files always start with "%PDF-" + expect(buffer.slice(0, 5).toString()).toBe('%PDF-') + }) + + it('renders an income-statement-shaped document with a summary block', async () => { + const doc = FinancialStatementPDF({ + title: 'Resultaträkning', + groups: [ + { + heading: 'Rörelseintäkter', + sections: [ + { + title: 'Huvudintäkter', + rows: [ + { account_number: '3001', account_name: 'Försäljning 25%', amount: 500_000 }, + ], + subtotal: 500_000, + }, + ], + totalLabel: 'Summa rörelseintäkter', + total: 500_000, + }, + { + heading: 'Rörelsekostnader', + sections: [ + { + title: 'Lokalkostnader', + rows: [ + { account_number: '5010', account_name: 'Lokalhyra', amount: 120_000 }, + ], + subtotal: 120_000, + }, + ], + totalLabel: 'Summa rörelsekostnader', + total: 120_000, + negate: true, + }, + ], + summary: [ + { label: 'Rörelseresultat', amount: 380_000 }, + { label: 'Årets resultat', amount: 380_000, emphasis: true }, + ], + period: { start: '2026-01-01', end: '2026-12-31' }, + company: fakeCompany(), + generatedAt: '2026-04-21T10:00:00Z', + }) + + const buffer = await renderToBuffer(doc) + expect(buffer).toBeInstanceOf(Buffer) + expect(buffer.slice(0, 5).toString()).toBe('%PDF-') + }) + + it('handles empty section groups gracefully', async () => { + const doc = FinancialStatementPDF({ + title: 'Balansräkning', + groups: [ + { + heading: 'Tillgångar', + sections: [], + totalLabel: 'Summa tillgångar', + total: 0, + }, + { + heading: 'Eget kapital och skulder', + sections: [], + totalLabel: 'Summa eget kapital och skulder', + total: 0, + }, + ], + period: { start: '', end: '' }, + company: fakeCompany(), + generatedAt: '2026-04-21T10:00:00Z', + }) + + const buffer = await renderToBuffer(doc) + expect(buffer.slice(0, 5).toString()).toBe('%PDF-') + }) +}) diff --git a/lib/reports/financial-statement-pdf-template.tsx b/lib/reports/financial-statement-pdf-template.tsx new file mode 100644 index 00000000..a1ac08ba --- /dev/null +++ b/lib/reports/financial-statement-pdf-template.tsx @@ -0,0 +1,382 @@ +import { + Document, + Page, + Text, + View, + StyleSheet, +} from '@react-pdf/renderer' +import type { CompanySettings } from '@/types' + +const styles = StyleSheet.create({ + page: { + paddingTop: 40, + paddingHorizontal: 40, + // Leave room for the fixed disclaimer + footer at the bottom of every page. + paddingBottom: 120, + fontSize: 10, + fontFamily: 'Helvetica', + }, + header: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'flex-start', + marginBottom: 24, + paddingBottom: 14, + borderBottomWidth: 1, + borderBottomColor: '#d4d4d4', + }, + titleBlock: { + flex: 1, + }, + title: { + fontSize: 20, + fontWeight: 'bold', + color: '#1a1a1a', + marginBottom: 4, + }, + subtitle: { + fontSize: 11, + color: '#333', + marginBottom: 2, + }, + period: { + fontSize: 10, + color: '#666', + }, + companyInfo: { + textAlign: 'right', + }, + companyName: { + fontSize: 11, + fontWeight: 'bold', + marginBottom: 2, + }, + companyMeta: { + fontSize: 9, + color: '#666', + }, + group: { + marginBottom: 18, + }, + groupHeading: { + fontSize: 12, + fontWeight: 'bold', + color: '#1a1a1a', + marginBottom: 8, + paddingBottom: 4, + borderBottomWidth: 1, + borderBottomColor: '#1a1a1a', + }, + section: { + marginBottom: 8, + }, + sectionTitle: { + fontSize: 10, + fontWeight: 'bold', + color: '#444', + marginBottom: 4, + marginTop: 6, + }, + row: { + flexDirection: 'row', + paddingVertical: 2, + }, + colAccount: { + width: 48, + color: '#666', + fontFamily: 'Courier', + }, + colName: { + flex: 1, + color: '#1a1a1a', + paddingRight: 12, + }, + colAmount: { + width: 110, + textAlign: 'right', + fontFamily: 'Courier', + color: '#1a1a1a', + }, + sectionSubtotalRow: { + flexDirection: 'row', + paddingVertical: 3, + marginTop: 2, + borderTopWidth: 0.5, + borderTopColor: '#d4d4d4', + }, + sectionSubtotalLabel: { + flex: 1, + fontStyle: 'italic', + color: '#444', + paddingLeft: 48, + }, + sectionSubtotalAmount: { + width: 110, + textAlign: 'right', + fontFamily: 'Courier', + fontStyle: 'italic', + color: '#444', + }, + groupTotalRow: { + flexDirection: 'row', + paddingVertical: 6, + marginTop: 6, + borderTopWidth: 1, + borderTopColor: '#1a1a1a', + }, + groupTotalLabel: { + flex: 1, + fontWeight: 'bold', + fontSize: 11, + }, + groupTotalAmount: { + width: 110, + textAlign: 'right', + fontFamily: 'Courier', + fontWeight: 'bold', + fontSize: 11, + }, + summaryBlock: { + marginTop: 20, + paddingTop: 10, + borderTopWidth: 2, + borderTopColor: '#1a1a1a', + }, + summaryRow: { + flexDirection: 'row', + paddingVertical: 4, + }, + summaryLabel: { + flex: 1, + color: '#1a1a1a', + }, + summaryAmount: { + width: 110, + textAlign: 'right', + fontFamily: 'Courier', + }, + summaryEmphasisLabel: { + flex: 1, + fontWeight: 'bold', + fontSize: 12, + }, + summaryEmphasisAmount: { + width: 110, + textAlign: 'right', + fontFamily: 'Courier', + fontWeight: 'bold', + fontSize: 12, + }, + disclaimer: { + position: 'absolute', + bottom: 52, + left: 40, + right: 40, + paddingTop: 6, + paddingBottom: 6, + paddingHorizontal: 10, + borderWidth: 0.8, + borderColor: '#b45309', + backgroundColor: '#fef3c7', + borderRadius: 3, + }, + disclaimerTitle: { + fontSize: 8, + fontWeight: 'bold', + color: '#78350f', + marginBottom: 2, + }, + disclaimerText: { + fontSize: 7.5, + color: '#78350f', + lineHeight: 1.3, + }, + footer: { + position: 'absolute', + bottom: 24, + left: 40, + right: 40, + borderTopWidth: 0.5, + borderTopColor: '#d4d4d4', + paddingTop: 6, + flexDirection: 'row', + justifyContent: 'space-between', + }, + footerText: { + fontSize: 8, + color: '#888', + }, +}) + +function formatAmount(amount: number): string { + return new Intl.NumberFormat('sv-SE', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }).format(amount) +} + +function formatOrgNumber(orgNumber: string): string { + const cleaned = orgNumber.replace(/\D/g, '') + if (cleaned.length === 10) { + return `${cleaned.slice(0, 6)}-${cleaned.slice(6)}` + } + return orgNumber +} + +function formatDateSv(iso: string): string { + if (!iso) return '' + return new Date(iso).toLocaleDateString('sv-SE') +} + +export interface FinancialStatementSection { + title: string + rows: { account_number: string; account_name: string; amount: number }[] + subtotal: number +} + +export interface FinancialStatementGroup { + heading: string + sections: FinancialStatementSection[] + totalLabel: string + total: number + negate?: boolean +} + +export interface FinancialStatementSummaryRow { + label: string + amount: number + emphasis?: boolean +} + +interface FinancialStatementPDFProps { + title: string + groups: FinancialStatementGroup[] + summary?: FinancialStatementSummaryRow[] + period: { start: string; end: string } + company: CompanySettings + generatedAt: string +} + +export function FinancialStatementPDF({ + title, + groups, + summary, + period, + company, + generatedAt, +}: FinancialStatementPDFProps) { + const companyDisplayName = company.trade_name || company.company_name || '' + const periodLabel = period.start && period.end + ? `${formatDateSv(period.start)} – ${formatDateSv(period.end)}` + : '' + + return ( + + + + + {title} + {companyDisplayName && ( + {companyDisplayName} + )} + {periodLabel && ( + Period: {periodLabel} + )} + + + {company.company_name && ( + {company.company_name} + )} + {company.org_number && ( + + Org.nr: {formatOrgNumber(company.org_number)} + + )} + {company.vat_number && ( + VAT: {company.vat_number} + )} + + + + {groups.map((group, gi) => ( + + {group.heading} + + {group.sections.length === 0 ? ( + + Inga poster i perioden. + + ) : ( + group.sections.map((section, si) => ( + + {section.title} + {section.rows.map((row, ri) => { + const displayAmount = group.negate ? -row.amount : row.amount + return ( + + {row.account_number} + {row.account_name} + {formatAmount(displayAmount)} + + ) + })} + {section.rows.length > 1 && ( + + Summa {section.title.toLowerCase()} + + {formatAmount(group.negate ? -section.subtotal : section.subtotal)} + + + )} + + )) + )} + + + {group.totalLabel} + + {formatAmount(group.negate ? -group.total : group.total)} + + + + ))} + + {summary && summary.length > 0 && ( + + {summary.map((row, i) => ( + + + {row.label} + + + {formatAmount(row.amount)} + + + ))} + + )} + + + Arbetsutkast – ej undertecknat + + Detta dokument är ett internt arbetsutkast och utgör inte en godkänd + årsredovisning enligt ÅRL 2 kap 7 §. Den formella årsredovisningen ska + undertecknas av samtliga styrelseledamöter och, i förekommande fall, VD + innan den lämnas in till Bolagsverket. + + + + + + {companyDisplayName} + {company.org_number ? ` · ${formatOrgNumber(company.org_number)}` : ''} + + `Genererad ${formatDateSv(generatedAt)} · Sida ${pageNumber} av ${totalPages}`} + /> + + + + ) +} diff --git a/supabase/migrations/20260421160000_booking_template_usage.sql b/supabase/migrations/20260421160000_booking_template_usage.sql new file mode 100644 index 00000000..f14e1e5b --- /dev/null +++ b/supabase/migrations/20260421160000_booking_template_usage.sql @@ -0,0 +1,53 @@ +-- ============================================================================= +-- Booking Template Usage (per-company MRU tracking) +-- ============================================================================= +-- +-- Tracks when a booking template was last used *within a specific company*. +-- Stored separately from booking_template_library because: +-- 1. System templates are shared globally (is_system = TRUE, company_id NULL) +-- so a per-row last_used_at would be useless — company A using a template +-- would surface it for company B too. +-- 2. Team templates are shared across a team's companies; each company should +-- track its own usage independently. +-- +-- One row per (template_id, company_id). Upsert on use. + +CREATE TABLE public.booking_template_usage ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + template_id UUID NOT NULL REFERENCES public.booking_template_library(id) ON DELETE CASCADE, + company_id UUID NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE, + last_used_at TIMESTAMPTZ NOT NULL DEFAULT now(), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + + UNIQUE (template_id, company_id) +); + +-- RLS +ALTER TABLE public.booking_template_usage ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "btu_select" ON public.booking_template_usage + FOR SELECT USING ( + company_id IN (SELECT public.user_company_ids()) + ); + +CREATE POLICY "btu_insert" ON public.booking_template_usage + FOR INSERT WITH CHECK ( + company_id IN (SELECT public.user_company_ids()) + ); + +CREATE POLICY "btu_update" ON public.booking_template_usage + FOR UPDATE USING ( + company_id IN (SELECT public.user_company_ids()) + ); + +CREATE POLICY "btu_delete" ON public.booking_template_usage + FOR DELETE USING ( + company_id IN (SELECT public.user_company_ids()) + ); + +-- Index for the sort query: fetch last_used_at for a given company. +CREATE INDEX idx_btu_company_last_used + ON public.booking_template_usage (company_id, last_used_at DESC); + +-- Schema reload for PostgREST +NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/20260421170000_booking_template_usage_updated_at.sql b/supabase/migrations/20260421170000_booking_template_usage_updated_at.sql new file mode 100644 index 00000000..6338b8da --- /dev/null +++ b/supabase/migrations/20260421170000_booking_template_usage_updated_at.sql @@ -0,0 +1,24 @@ +-- ============================================================================= +-- Booking Template Usage: add updated_at column + trigger +-- ============================================================================= +-- +-- Follow-up to 20260421160000_booking_template_usage.sql. The project +-- migration rules (CLAUDE.md) require every table to carry an updated_at +-- column maintained by the shared update_updated_at_column() trigger. The +-- initial migration omitted it because the row is touched via upsert +-- (which bumps last_used_at) — but the audit convention applies regardless. + +ALTER TABLE public.booking_template_usage + ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT now(); + +-- Seed updated_at for existing rows to match last_used_at so history is +-- coherent from day one. +UPDATE public.booking_template_usage + SET updated_at = last_used_at + WHERE updated_at < last_used_at; + +CREATE TRIGGER btu_updated_at + BEFORE UPDATE ON public.booking_template_usage + FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column(); + +NOTIFY pgrst, 'reload schema';