Files
accounted/components/invoices/InvoiceReviewContent.tsx
MattssonandClaude Fable 5.1 7a30f623ba fix(invoices): invoice PDF draft stamp, page breaks, wrapping, units, multi-line descriptions (#2369)
* fix(invoices): draft stamp out of flow, page-break control, whole-word wrapping, English units, multi-line descriptions

A user reported five things about the invoice PDF after the English
translation shipped:

- The yellow draft banner pushed the whole document down, so a draft
  previewed differently from the final invoice. It is now a small stamp in
  the page's top margin (absolute, fixed), repeated on every page, out of
  the flow.
- Table rows, totals, the payment box and the notice boxes could split
  across a page break, and a section heading could be left alone at the
  bottom of a page. Those blocks now carry wrap={false}; headings and the
  table header carry minPresenceAhead.
- react-pdf hyphenated Swedish words with English patterns ("Septem-ber").
  Descriptions, notes, notices and the footer now wrap whole words.
- "st" printed verbatim on an English invoice. The editor's known units map
  to English labels at render time; user-typed units print as stored.
- Descriptions were single-line inputs, so a user could never choose where
  a line breaks. The editor field is now an auto-growing textarea; the PDF
  and the on-screen views keep the line breaks; the Peppol item name
  collapses them (single-line field).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BuYbae3WWwYzBucTUVayoW

* fix(invoices): keep long tokens printable and oversize text splittable in the invoice PDF

Skeptic findings on the first commit:

- A word wider than its column was dropped from the page or overprinted the
  quantity column, because the no-hyphenation callback gave react-pdf no
  break point inside it. Words up to 16 characters still wrap whole; longer
  tokens (URLs, e-mail addresses, references) break after separators and
  every 16 characters.
- wrap={false} on a text row or notes taller than a page clipped everything
  past the page edge. Line descriptions and notes are now kept together only
  while a line estimate says they fit; past that they split.
- A multi-line description reached the periodisering voucher text and broke
  the SIE export (one record per line). The accrual builder and the Peppol
  item name share toSingleLine(); the SIE writer collapses line breaks in
  quoted text as a format guard.
- The English no-number draft stamp ended 1.3pt below the top margin.

Tests lay the document out with @react-pdf/layout and assert that no text
node ends past the page edge and that every line's ink stays inside its
column, for 80-line descriptions and notes, a 3000-character description,
and four long tokens.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BuYbae3WWwYzBucTUVayoW

* fix(invoices): count rendered lines and cap the kept-together budget so no font can clip a row

Skeptic cycle 2: with the bundled Source Serif 4 font (13.7pt per line)
and a description of 20 source lines of wide glyphs, the estimate said
"fits" while the chunked token wrapping produced 58 rendered lines; the
non-splittable row then ran off the page. The estimate now counts the
chunks a long token is broken into, and the cap is 12 lines: at 20pt per
line that is under a third of the page for any font a company can pick.

The page-edge test now measures absolute positions (box.top is
parent-relative) and covers the bundled serif case through
prepareInvoiceFont().

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BuYbae3WWwYzBucTUVayoW

* fix(invoices): keep words whole by estimated ink width, not a character cap

Skeptic cycle 2 (regression): the flat 16-character cap gave react-pdf a
break point inside ordinary Swedish compounds (Fastighetsskötse-l,
Företagsförsäkri-ng), which the breaker used whenever it filled the line
better. Words are now kept whole whenever a rough per-glyph width estimate
says they fit the column; only a token wider than the column gets parts,
after separators and where the column is full. The page-fit estimate uses
the same widths.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BuYbae3WWwYzBucTUVayoW

* fix(invoices): estimate word width from real Helvetica metrics

Skeptic cycle 3: the hand-rolled per-glyph estimate over-counted ordinary
lowercase (6.5pt for a 5.56pt glyph), so 24 to 28 character compounds
(Momskompensationsansökan, Mervärdesskattedeklarationen) were still handed
a break point while they fit the column, and under-counted rare glyphs
(æ, œ, Cyrillic) so a token of those could overflow. The estimate now uses
Helvetica advances measured through react-pdf's own metrics
(lib/invoices/pdf-glyph-widths.ts) with a 10% margin for the bundled
fonts, and counts any glyph outside Helvetica at the widest Latin advance.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BuYbae3WWwYzBucTUVayoW

* docs(invoices): state the bundled-font width margin accurately

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BuYbae3WWwYzBucTUVayoW

* fix(invoices): review round: SIE backslash escaping, text-row budget, ROT/RUT box keep-together

- CodeQL: the SIE quoted-text escaper left backslashes alone while backslash
  is the escape character. It now writes a literal backslash as two, and the
  parser unescapes both that and the escaped quote, so text round-trips.
- CodeRabbit: a free-text row renders at full table width but was budgeted
  at the description column, so it could split unnecessarily. It uses the
  full-width budget now.
- CodeRabbit: the ROT/RUT box was unconditionally kept on one page although
  its per-line breakdown carries the (possibly multi-line) descriptions. It
  uses the same keep-together estimate as rows and notes, and its line texts
  wrap whole words.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BuYbae3WWwYzBucTUVayoW

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 14:45:38 +02:00

304 lines
12 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
import { useTranslations } from 'next-intl'
import { CalendarClock } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Separator } from '@/components/ui/separator'
import { formatCurrency, formatDate } from '@/lib/utils'
import { getDisplayTotal } from '@/lib/invoices/rounding'
import { isTextLikeLine } from '@/lib/invoices/display'
import { computeLineNet } from '@/lib/invoices/line-amounts'
import { itemHasAccrual } from '@/lib/bookkeeping/accruals/account-suggestions'
import type { Customer, Currency } from '@/types'
interface ReviewItem {
description: string
quantity: number
unit: string
unit_price: number
/** Line discount 0-100; amounts render net of it. */
discount_percent?: number | null
vat_rate?: number
/** 'text' rows are free-text/blank lines: description only, no amounts. */
line_type?: 'product' | 'text'
// Periodisering: when both dates are set, the revenue books to the 29xx
// interim account and dissolves monthly over the period.
accrual_period_start?: string | null
accrual_period_end?: string | null
accrual_balance_account?: string | null
}
const accrualMonth = (date: string): string => date.slice(0, 7)
interface InvoiceReviewContentProps {
customer: Customer
invoiceDate: string
dueDate: string
currency: Currency
items: ReviewItem[]
subtotal: number
vatAmount: number
total: number
yourReference?: string
ourReference?: string
invoiceMarking?: string
notes?: string
/** The invoice number that will be assigned on confirm. Null when unknown
* (e.g. delivery notes use a different sequence) or unfetched. */
numberPreview?: string | null
/** Mirrors `company_settings.ore_rounding`. Defaults to true to match `getDisplayTotal`. */
oreRounding?: boolean
/** Mirrors `company_settings.vat_registered`. When false and the invoice carries
* no VAT, the moms row is suppressed to match the PDF (pdf-template.tsx:876). */
vatRegistered?: boolean
/** Payment-link förval on this invoice: Stripe auto-link on send, a manually
* pasted link, or none. Renders in the Förval summary line when set. */
paymentLink?: 'auto' | 'manual' | null
/** Quotes show their expiry ("Giltig till") where an invoice shows its due date. */
dueDateLabelKey?: 'due_date' | 'valid_until'
}
export function InvoiceReviewContent({
customer,
invoiceDate,
dueDate,
currency,
items,
subtotal,
vatAmount,
total,
yourReference,
ourReference,
invoiceMarking,
dueDateLabelKey = 'due_date',
notes,
numberPreview,
oreRounding,
vatRegistered,
paymentLink,
}: InvoiceReviewContentProps) {
const t = useTranslations('invoice_review')
const rounding = getDisplayTotal({ total, currency }, { ore_rounding: oreRounding ?? true })
const customerTypeLabel: Record<string, string> = {
individual: t('customer_type_individual'),
swedish_business: t('customer_type_swedish_business'),
eu_business: t('customer_type_eu_business'),
non_eu_business: t('customer_type_non_eu_business'),
}
// Calculate per-rate VAT breakdown (free-text and amount-less rows carry
// no amounts and must not seed an empty rate group).
const vatByRate = new Map<number, number>()
for (const item of items) {
if (isTextLikeLine(item)) continue
const rate = item.vat_rate ?? 0
const lineTotal = computeLineNet(item.quantity, item.unit_price, item.discount_percent)
const lineVat = Math.round(lineTotal * rate / 100 * 100) / 100
vatByRate.set(rate, (vatByRate.get(rate) || 0) + lineVat)
}
const showVatColumn = vatByRate.size > 1
return (
<div className="space-y-4">
{numberPreview && (
<div className="text-sm text-muted-foreground">
{t('assigned_number_prefix')}{' '}
<span className="font-medium tabular-nums text-foreground">{numberPreview}</span>
</div>
)}
{/* Customer info */}
<div className="bg-muted rounded-lg p-3 sm:p-4 flex flex-col sm:flex-row sm:items-center gap-2 sm:justify-between">
<div className="min-w-0">
<p className="font-medium text-base truncate">{customer.name}</p>
<p className="text-sm text-muted-foreground truncate">{customer.email}</p>
</div>
<Badge variant="outline" className="self-start sm:self-auto shrink-0">
{customerTypeLabel[customer.customer_type] || customer.customer_type}
</Badge>
</div>
{/* Dates */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 text-sm">
<div>
<span className="text-muted-foreground">{t('invoice_date')}</span>
<p className="font-medium">{invoiceDate ? formatDate(invoiceDate) : ''}</p>
</div>
<div>
<span className="text-muted-foreground">{t(dueDateLabelKey)}</span>
<p className="font-medium">{dueDate ? formatDate(dueDate) : ''}</p>
</div>
</div>
{/* Applied förval: the collapsed defaults the invoice will carry
(currency, öresavrundning, payment-link state). One muted line so
the review states what the settings panel may have been hiding. */}
<p className="text-xs text-muted-foreground">
{[
t('forval_currency', { currency }),
currency === 'SEK'
? (oreRounding ?? true)
? t('forval_ore_on')
: t('forval_ore_off')
: null,
paymentLink === 'auto'
? t('forval_link_auto')
: paymentLink === 'manual'
? t('forval_link_manual')
: null,
]
.filter(Boolean)
.join(' · ')}
</p>
{/* Line items: table on desktop, cards on mobile */}
<div className="hidden sm:block">
<table className="w-full text-sm">
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
<tr className="border-b text-left">
<th className="py-2">{t('th_description')}</th>
<th className="py-2 w-16 text-right">{t('th_quantity')}</th>
<th className="py-2 w-16 text-center">{t('th_unit')}</th>
<th className="py-2 w-24 text-right">{t('th_unit_price')}</th>
{showVatColumn && <th className="py-2 w-16 text-right">{t('th_vat')}</th>}
<th className="py-2 w-28 text-right">{t('th_amount')}</th>
</tr>
</thead>
<tbody>
{items.map((item, index) =>
isTextLikeLine(item) ? (
<tr key={index} className="border-b last:border-0">
<td className="whitespace-pre-line py-2 text-muted-foreground" colSpan={showVatColumn ? 6 : 5}>
{item.description || ' '}
</td>
</tr>
) : (
<tr key={index} className="border-b last:border-0">
<td className="whitespace-pre-line py-2">
{item.description}
{itemHasAccrual(item) && (
<p className="mt-1 flex items-center gap-1 text-xs text-muted-foreground">
<CalendarClock className="h-3 w-3 shrink-0" />
<span className="tabular-nums">
{t('accrual_line_info', {
from: accrualMonth(item.accrual_period_start!),
to: accrualMonth(item.accrual_period_end!),
})}
</span>
</p>
)}
</td>
<td className="py-2 text-right">{item.quantity}</td>
<td className="py-2 text-center">{item.unit}</td>
<td className="py-2 text-right">
{formatCurrency(item.unit_price, currency)}
{(item.discount_percent ?? 0) > 0 && (
<span className="ml-1 text-xs text-muted-foreground">
&minus;{item.discount_percent}%
</span>
)}
</td>
{showVatColumn && (
<td className="py-2 text-right">{item.vat_rate ?? 0}%</td>
)}
<td className="py-2 text-right">
{formatCurrency(computeLineNet(item.quantity, item.unit_price, item.discount_percent), currency)}
</td>
</tr>
)
)}
</tbody>
</table>
</div>
<div className="sm:hidden space-y-2">
{items.map((item, index) =>
isTextLikeLine(item) ? (
<p key={index} className="whitespace-pre-line text-sm text-muted-foreground px-1">{item.description || ' '}</p>
) : (
<div key={index} className="border rounded-lg p-3 text-sm space-y-1.5">
<p className="whitespace-pre-line font-medium">{item.description}</p>
{itemHasAccrual(item) && (
<p className="flex items-center gap-1 text-xs text-muted-foreground">
<CalendarClock className="h-3 w-3 shrink-0" />
<span className="tabular-nums">
{t('accrual_line_info', {
from: accrualMonth(item.accrual_period_start!),
to: accrualMonth(item.accrual_period_end!),
})}
</span>
</p>
)}
<div className="flex items-center justify-between text-muted-foreground">
<span>
{item.quantity} {item.unit} × {formatCurrency(item.unit_price, currency)}
{(item.discount_percent ?? 0) > 0 && <> &minus;{item.discount_percent}%</>}
</span>
{showVatColumn && <span className="text-xs">{t('mobile_vat_suffix', { rate: item.vat_rate ?? 0 })}</span>}
</div>
<p className="text-right font-medium">
{formatCurrency(computeLineNet(item.quantity, item.unit_price, item.discount_percent), currency)}
</p>
</div>
)
)}
</div>
{/* Totals */}
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">{t('subtotal')}</span>
<span>{formatCurrency(subtotal, currency)}</span>
</div>
{Array.from(vatByRate.entries())
.filter(([, vat]) => vat > 0)
.sort(([a], [b]) => b - a)
.map(([rate, vat]) => (
<div key={rate} className="flex justify-between">
<span className="text-muted-foreground">{t('vat_at_rate', { rate })}</span>
<span>{formatCurrency(vat, currency)}</span>
</div>
))}
{Array.from(vatByRate.values()).every((vat) => vat === 0) && !(vatRegistered === false && vatAmount === 0) && (
<div className="flex justify-between">
<span className="text-muted-foreground">{t('vat_label')}</span>
<span>{formatCurrency(0, currency)}</span>
</div>
)}
{rounding.applies && (
<div className="flex justify-between">
<span className="text-muted-foreground">{t('ore_rounding')}</span>
<span>{formatCurrency(rounding.roundingDelta, currency)}</span>
</div>
)}
<Separator />
<div className="flex justify-between font-bold text-xl sm:text-2xl">
<span>{t('total')}</span>
<span>{formatCurrency(rounding.displayed, currency)}</span>
</div>
</div>
{/* References/notes */}
{(yourReference || ourReference || invoiceMarking || notes) && (
<div className="border-t pt-3 space-y-2 text-sm text-muted-foreground">
{yourReference && (
<p>
<span>{t('your_reference')}</span> {yourReference}
</p>
)}
{ourReference && (
<p>
<span>{t('our_reference')}</span> {ourReference}
</p>
)}
{invoiceMarking && (
<p>
<span>{t('invoice_marking')}</span> {invoiceMarking}
</p>
)}
{notes && <p>{t('notes_prefix', { notes })}</p>}
</div>
)}
</div>
)
}