feat(reports): reskontra per valfritt datum + PDF-export (#1039)

Kundreskontra and leverantörsreskontra were effectively always "as of
today": the UI never passed a date, the xlsx export ignored the chosen
fiscal year, and no PDF existed.

- Both ledger generators reconstruct the ledger as it stood on a
  backdated as-of date: invoices dated on or before it (including ones
  fully paid since) with outstanding recomputed from the payment-row
  history; paid_at dates row-less full payments; undateable legacy
  amounts degrade to the live values. Today/future dates keep the live
  computation byte-identical.
- New shared reskontra PDF template (aging per counterparty + invoice
  detail for kundreskontra) with PDF routes for both ledgers.
- Both report views get a "Per datum" date control; the export menu
  offers PDF + Excel and passes the chosen date through.

Note: the PDF template deliberately avoids react-pdf's `break` prop:
it deadlocks layout when the section spills across pages (reproduced
at 40+ rows, documented in the template).

Fixes #1020
Fixes #1021

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-16 18:14:45 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent f8611f2e89
commit 14f7478abb
12 changed files with 1318 additions and 32 deletions
+65 -6
View File
@@ -8,6 +8,7 @@ import React, { useState, useEffect, useCallback } from 'react'
import Link from 'next/link'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Badge } from '@/components/ui/badge'
import { AlertCircle, ChevronDown, ChevronRight, ExternalLink, FileCode, FileDown, Percent } from 'lucide-react'
@@ -2035,16 +2036,63 @@ interface SupplierLedgerData {
} | null
}
// Local calendar date (YYYY-MM-DD) for the reskontra "per datum" default:
// toISOString() is UTC and rolls the date over an hour early in Sweden.
function localIsoDate(): string {
const now = new Date()
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
}
// Shared "Per datum" control + export menu header for the two reskontra views
// (#1020/#1021): pick an arbitrary as-of date and export PDF/Excel for it.
function ReskontraToolbar({
asOfDate,
onAsOfDateChange,
inputId,
exportBase,
}: {
asOfDate: string
onAsOfDateChange: (date: string) => void
inputId: string
exportBase: string
}) {
return (
<div className="flex flex-wrap items-end justify-between gap-4">
<div className="space-y-1">
<Label htmlFor={inputId} className="text-xs text-muted-foreground">
Per datum
</Label>
<Input
id={inputId}
type="date"
value={asOfDate}
onChange={(e) => {
if (e.target.value) onAsOfDateChange(e.target.value)
}}
className="w-40"
/>
</div>
<ReportExportMenu
items={[
{ format: 'pdf', href: `${exportBase}/pdf?as_of_date=${asOfDate}` },
{ format: 'xlsx', href: `${exportBase}/xlsx?as_of_date=${asOfDate}` },
]}
/>
</div>
)
}
export function SupplierLedgerView({ periodId }: { periodId: string }) {
const [data, setData] = useState<SupplierLedgerData | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [asOfDate, setAsOfDate] = useState(localIsoDate)
const fetchData = async () => {
setLoading(true)
setError(null)
try {
const res = await fetch(`/api/reports/supplier-ledger?period_id=${periodId}`)
const res = await fetch(`/api/reports/supplier-ledger?period_id=${periodId}&as_of_date=${asOfDate}`)
const result = await res.json()
if (result.error) {
setError(result.error)
@@ -2060,7 +2108,7 @@ export function SupplierLedgerView({ periodId }: { periodId: string }) {
useEffect(() => {
if (periodId) fetchData()
}, [periodId])
}, [periodId, asOfDate])
if (loading) {
return (
@@ -2097,7 +2145,12 @@ export function SupplierLedgerView({ periodId }: { periodId: string }) {
return (
<div className="space-y-4">
<ReportExportMenu items={[{ format: 'xlsx', href: `/api/reports/supplier-ledger/xlsx?period_id=${periodId}` }]} />
<ReskontraToolbar
asOfDate={asOfDate}
onAsOfDateChange={setAsOfDate}
inputId="supplier-ledger-as-of"
exportBase="/api/reports/supplier-ledger"
/>
{/* Summary cards */}
<div className="grid md:grid-cols-3 gap-4">
<Card>
@@ -2815,12 +2868,13 @@ export function ARLedgerView({ periodId }: { periodId: string }) {
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [expandedCustomers, setExpandedCustomers] = useState<Set<string>>(new Set())
const [asOfDate, setAsOfDate] = useState(localIsoDate)
const fetchData = async () => {
setLoading(true)
setError(null)
try {
const res = await fetch(`/api/reports/ar-ledger?period_id=${periodId}`)
const res = await fetch(`/api/reports/ar-ledger?period_id=${periodId}&as_of_date=${asOfDate}`)
const result = await res.json()
if (result.error) {
setError(result.error)
@@ -2836,7 +2890,7 @@ export function ARLedgerView({ periodId }: { periodId: string }) {
useEffect(() => {
if (periodId) fetchData()
}, [periodId])
}, [periodId, asOfDate])
const toggleCustomer = (customerId: string) => {
setExpandedCustomers((prev) => {
@@ -2885,7 +2939,12 @@ export function ARLedgerView({ periodId }: { periodId: string }) {
return (
<div className="space-y-4">
<ReportExportMenu items={[{ format: 'xlsx', href: `/api/reports/ar-ledger/xlsx?period_id=${periodId}` }]} />
<ReskontraToolbar
asOfDate={asOfDate}
onAsOfDateChange={setAsOfDate}
inputId="ar-ledger-as-of"
exportBase="/api/reports/ar-ledger"
/>
{/* Summary cards */}
<div className="grid md:grid-cols-3 gap-4">
<Card>