mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 21:21:10 +02:00
feat(invoices): M5 Swiss QR-Bill — SCOR reference + PDF overlay
Adds swissqrbill integration so CHF/EUR invoices get the Zahlteil (payment
part) rendered in the bottom 105mm of the last page.
Integration path (pdf/qr-bill.ts)
- swissqrbill/pdf targets PDFKit, not pdf-lib; so we use swissqrbill/svg,
rasterise the SVG to PNG in a browser canvas at ~300 DPI target, then
embed the PNG via pdf-lib's embedPng
- Eligibility gate via QRBillError: validates currency (CHF/EUR), IBAN
(swissqrbill's isIBANValid), parseable sender address, positive amount
- Address parser: heuristic for two-line Swiss/DE addresses
(street + number on line 1, "{zip} {city}" on line 2). Fails loud —
the renderer silently omits the Zahlteil and the UI surfaces a warning
- SCOR reference (ISO 11649) generated from invoice.number as payload,
truncated to 21 chars, checksum via swissqrbill/utils. Persisted on
invoice.referenceNumber at create time so it stays stable across edits
and re-renders
Renderer wiring
- renderInvoicePdf(..., { includeQRBill?: boolean }) — defaults true
- QRBillError is caught and absorbed; other errors propagate
- qrBillStatus(invoice, settings) — cheap pure check, returns
{ ok: true } or { ok: false, message, reason } for UI hints
DetailView
- Warning banner above PDF preview when QR-Bill is not eligible, with
a "Einstellungen öffnen →" deep link
- Preview iframe now shows the PNG-embedded Zahlteil on CHF/EUR
invoices
Addressed §"Offene Fragen" from the plan
- QR-Bill-Scope: CHF + EUR per swissqrbill spec, not USD
- Address parsing: heuristic now, structured fields to be added in M7
(tracked in renderer warning path — user sees exactly what's missing)
Plan: docs/plans/invoices-module.md §M5.
Next: M6 send flow (open mail compose with PDF attached).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
166d6c6ffb
commit
5af23d30b6
6 changed files with 398 additions and 5 deletions
|
|
@ -89,6 +89,7 @@
|
|||
"svelte-dnd-action": "^0.9.68",
|
||||
"svelte-i18n": "^4.0.0",
|
||||
"svelte-sonner": "^1.0.5",
|
||||
"swissqrbill": "^4.3.0",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"type": "module"
|
||||
|
|
|
|||
|
|
@ -34,6 +34,9 @@ export {
|
|||
|
||||
export { computeLineTotal, computeInvoiceTotals, EMPTY_TOTALS } from './totals';
|
||||
|
||||
export { renderInvoicePdf, renderInvoicePdfBlob, qrBillStatus } from './pdf/renderer';
|
||||
export { generateSCORReference, QRBillError } from './pdf/qr-bill';
|
||||
|
||||
export { invoicesStore } from './stores/invoices.svelte';
|
||||
export { invoiceSettingsStore, ensureSettings } from './stores/settings.svelte';
|
||||
|
||||
|
|
|
|||
295
apps/mana/apps/web/src/lib/modules/invoices/pdf/qr-bill.ts
Normal file
295
apps/mana/apps/web/src/lib/modules/invoices/pdf/qr-bill.ts
Normal file
|
|
@ -0,0 +1,295 @@
|
|||
/**
|
||||
* Swiss QR-Bill integration.
|
||||
*
|
||||
* ## Architecture
|
||||
*
|
||||
* `swissqrbill/pdf` targets PDFKit, not pdf-lib. So we use the `svg`
|
||||
* export, rasterise the SVG to PNG in the browser, and embed that PNG
|
||||
* at the bottom of the last page of the pdf-lib invoice.
|
||||
*
|
||||
* invoice + settings
|
||||
* │
|
||||
* ▼
|
||||
* buildQRBillData() ← validation: CHF/EUR + valid IBAN + parseable address
|
||||
* │
|
||||
* ▼
|
||||
* new SwissQRBill(data).toString() ← SVG markup
|
||||
* │
|
||||
* ▼
|
||||
* rasteriseSvgToPng() ← canvas, 300 DPI target
|
||||
* │
|
||||
* ▼
|
||||
* doc.embedPng(bytes) → page.drawImage(at y=0, full width, 105mm tall)
|
||||
*
|
||||
* Browser-only — canvas + Image are not available in SSR. The renderer
|
||||
* never runs during prerender (DetailView is client-only for PDF), so
|
||||
* this is safe.
|
||||
*
|
||||
* ## Address parsing
|
||||
*
|
||||
* The QR-Bill spec (v2022) requires structured addresses: street, city,
|
||||
* zip, country. Our sender/client snapshots hold free-text multi-line
|
||||
* addresses. `parseAddress()` is a heuristic for Swiss/DE addresses:
|
||||
*
|
||||
* "Bahnhofstrasse 1\n8000 Zürich"
|
||||
* ↓
|
||||
* { street: "Bahnhofstrasse 1", zip: "8000", city: "Zürich" }
|
||||
*
|
||||
* If parsing fails (missing zip+city line), we throw `QRBillError` and
|
||||
* the renderer skips the Zahlteil with a visible warning to the user.
|
||||
*/
|
||||
|
||||
import type { PDFDocument } from 'pdf-lib';
|
||||
import { SwissQRBill } from 'swissqrbill/svg';
|
||||
import { isIBANValid, calculateSCORReferenceChecksum } from 'swissqrbill/utils';
|
||||
import type { Data } from 'swissqrbill/types';
|
||||
import type { Invoice, InvoiceSettings } from '../types';
|
||||
import { CURRENCIES } from '../constants';
|
||||
import { A4, mm } from './templates/default';
|
||||
|
||||
export class QRBillError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly reason:
|
||||
| 'invalid-currency'
|
||||
| 'missing-iban'
|
||||
| 'invalid-iban'
|
||||
| 'unparseable-sender-address'
|
||||
| 'unparseable-client-address'
|
||||
| 'missing-amount'
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'QRBillError';
|
||||
}
|
||||
}
|
||||
|
||||
// ─── SCOR reference ──────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Generate an ISO 11649 Creditor Reference (SCOR) for the invoice. Uses
|
||||
* the invoice number as payload so the reference is stable across re-
|
||||
* renders. Format: `RF{check}{payload}`.
|
||||
*
|
||||
* invoice.number "2026-0042" → payload "20260042" → RF{check}20260042
|
||||
*
|
||||
* Non-alphanumerics are stripped (the spec allows only [0-9A-Z] in the
|
||||
* payload). The payload is truncated to 21 chars (SCOR max).
|
||||
*/
|
||||
export function generateSCORReference(invoiceNumber: string): string {
|
||||
const payload = invoiceNumber
|
||||
.replace(/[^0-9A-Za-z]/g, '')
|
||||
.toUpperCase()
|
||||
.slice(0, 21);
|
||||
if (!payload) {
|
||||
// Degenerate input (e.g. all dashes) — fall back to a literal.
|
||||
return `RF${calculateSCORReferenceChecksum('INVOICE')}INVOICE`;
|
||||
}
|
||||
const checksum = calculateSCORReferenceChecksum(payload);
|
||||
return `RF${checksum}${payload}`;
|
||||
}
|
||||
|
||||
// ─── Address parsing ─────────────────────────────────────
|
||||
|
||||
interface StructuredAddress {
|
||||
street: string;
|
||||
zip: string;
|
||||
city: string;
|
||||
country: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a free-text multi-line address into structured fields.
|
||||
* Expects two non-empty lines:
|
||||
* Line 1: "Street + number"
|
||||
* Line 2: "<zip> <city>" where zip is 4-5 digits (CH=4, DE=5, AT=4)
|
||||
* Returns null if the format isn't recognised.
|
||||
*/
|
||||
function parseAddress(text: string | undefined, defaultCountry = 'CH'): StructuredAddress | null {
|
||||
if (!text) return null;
|
||||
const lines = text
|
||||
.split('\n')
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean);
|
||||
if (lines.length < 2) return null;
|
||||
const last = lines[lines.length - 1];
|
||||
const match = last.match(/^(\d{4,5})\s+(.+)$/);
|
||||
if (!match) return null;
|
||||
const [, zip, city] = match;
|
||||
const street = lines.slice(0, -1).join(', ');
|
||||
if (!street) return null;
|
||||
return { street, zip, city, country: defaultCountry };
|
||||
}
|
||||
|
||||
// ─── QR-Bill data assembly ────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build the data object for swissqrbill, or throw QRBillError with the
|
||||
* specific reason. The caller catches and surfaces a warning in the UI.
|
||||
*/
|
||||
export function buildQRBillData(invoice: Invoice, settings: InvoiceSettings): Data {
|
||||
if (invoice.currency !== 'CHF' && invoice.currency !== 'EUR') {
|
||||
throw new QRBillError(
|
||||
`QR-Rechnung unterstützt nur CHF und EUR (nicht ${invoice.currency}).`,
|
||||
'invalid-currency'
|
||||
);
|
||||
}
|
||||
const iban = (settings.senderIban ?? '').replace(/\s+/g, '');
|
||||
if (!iban) {
|
||||
throw new QRBillError(
|
||||
'IBAN fehlt in den Rechnungs-Einstellungen. Bitte ergänzen.',
|
||||
'missing-iban'
|
||||
);
|
||||
}
|
||||
if (!isIBANValid(iban)) {
|
||||
throw new QRBillError('Die hinterlegte IBAN ist ungültig.', 'invalid-iban');
|
||||
}
|
||||
|
||||
const creditorAddr = parseAddress(settings.senderAddress);
|
||||
if (!creditorAddr) {
|
||||
throw new QRBillError(
|
||||
'Absender-Adresse konnte nicht geparst werden (erwartet: Strasse + Nummer, dann "PLZ Ort").',
|
||||
'unparseable-sender-address'
|
||||
);
|
||||
}
|
||||
|
||||
const amount = invoice.totals.gross / CURRENCIES[invoice.currency].minorUnit;
|
||||
if (!Number.isFinite(amount) || amount <= 0) {
|
||||
throw new QRBillError('Rechnungsbetrag muss grösser als 0 sein.', 'missing-amount');
|
||||
}
|
||||
|
||||
const debtorAddr = parseAddress(invoice.clientSnapshot.address);
|
||||
// Debtor is optional per spec; if the client address doesn't parse, we
|
||||
// still emit the QR-Bill without a debtor (user can fill it in by hand).
|
||||
|
||||
// Prefer the reference persisted on the invoice (set at create time) so
|
||||
// the reference is stable even if invoice.number is later edited. Fall
|
||||
// back to a fresh derivation for pre-M5 invoices that have null.
|
||||
const reference = invoice.referenceNumber ?? generateSCORReference(invoice.number);
|
||||
|
||||
const data: Data = {
|
||||
currency: invoice.currency as 'CHF' | 'EUR',
|
||||
amount,
|
||||
reference,
|
||||
message: invoice.subject ?? undefined,
|
||||
creditor: {
|
||||
account: iban,
|
||||
name: settings.senderName,
|
||||
address: creditorAddr.street,
|
||||
zip: creditorAddr.zip,
|
||||
city: creditorAddr.city,
|
||||
country: creditorAddr.country,
|
||||
},
|
||||
};
|
||||
|
||||
if (debtorAddr && invoice.clientSnapshot.name) {
|
||||
data.debtor = {
|
||||
name: invoice.clientSnapshot.name,
|
||||
address: debtorAddr.street,
|
||||
zip: debtorAddr.zip,
|
||||
city: debtorAddr.city,
|
||||
country: debtorAddr.country,
|
||||
};
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
// ─── SVG → PNG rasterisation ─────────────────────────────
|
||||
|
||||
/**
|
||||
* Rasterise the QR-Bill SVG to a PNG byte array via the browser's native
|
||||
* Image + Canvas path. Target ~300 DPI across 210×105mm so the QR code
|
||||
* stays crisp when scaled back down to the embedded size.
|
||||
*
|
||||
* QR-Bill modules need to stay at least 1mm per module — at 300 DPI
|
||||
* that's ~12 px per module, safely above any scanner threshold.
|
||||
*/
|
||||
async function rasteriseSvgToPng(svg: string): Promise<Uint8Array> {
|
||||
// Target canvas pixel size at 300 DPI (25.4mm per inch):
|
||||
// 210mm × (300/25.4) ≈ 2480 px
|
||||
// 105mm × (300/25.4) ≈ 1240 px
|
||||
const widthPx = 2480;
|
||||
const heightPx = 1240;
|
||||
|
||||
const blob = new Blob([svg], { type: 'image/svg+xml;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
try {
|
||||
const img = await loadImage(url);
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = widthPx;
|
||||
canvas.height = heightPx;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) throw new Error('Canvas 2D-Kontext nicht verfügbar');
|
||||
// White background so the QR has the required contrast (spec requires
|
||||
// the Zahlteil to be printed on white).
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillRect(0, 0, widthPx, heightPx);
|
||||
ctx.drawImage(img, 0, 0, widthPx, heightPx);
|
||||
|
||||
const pngBlob: Blob = await new Promise((resolve, reject) => {
|
||||
canvas.toBlob(
|
||||
(b) => (b ? resolve(b) : reject(new Error('toBlob() lieferte null'))),
|
||||
'image/png'
|
||||
);
|
||||
});
|
||||
const arrayBuf = await pngBlob.arrayBuffer();
|
||||
return new Uint8Array(arrayBuf);
|
||||
} finally {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
|
||||
function loadImage(src: string): Promise<HTMLImageElement> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = () => reject(new Error('SVG konnte nicht in Bild geladen werden'));
|
||||
img.src = src;
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Public API ──────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Render the QR-Bill for `invoice + settings` into a PNG + metadata. Does
|
||||
* not touch pdf-lib — callers embed the PNG where they need it. Throws
|
||||
* `QRBillError` if the invoice isn't eligible.
|
||||
*/
|
||||
export async function renderQRBillPng(
|
||||
invoice: Invoice,
|
||||
settings: InvoiceSettings
|
||||
): Promise<{ bytes: Uint8Array; widthPt: number; heightPt: number }> {
|
||||
const data = buildQRBillData(invoice, settings);
|
||||
const svg = new SwissQRBill(data).toString();
|
||||
const bytes = await rasteriseSvgToPng(svg);
|
||||
return {
|
||||
bytes,
|
||||
widthPt: A4.width, // 210mm
|
||||
heightPt: mm(105), // official QR-Bill height
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach the QR-Bill to the given pdf-lib document — embeds the PNG at
|
||||
* the bottom of the LAST page, spanning the full page width × 105mm.
|
||||
*
|
||||
* Assumes the renderer reserved those bottom 105mm (BODY_MIN_Y guard in
|
||||
* templates/default.ts). If the invoice content overflowed and opened a
|
||||
* continuation page, that last page becomes the QR-Bill carrier.
|
||||
*/
|
||||
export async function attachQRBillToPdf(
|
||||
doc: PDFDocument,
|
||||
invoice: Invoice,
|
||||
settings: InvoiceSettings
|
||||
): Promise<void> {
|
||||
const { bytes, widthPt, heightPt } = await renderQRBillPng(invoice, settings);
|
||||
const png = await doc.embedPng(bytes);
|
||||
const pages = doc.getPages();
|
||||
const lastPage = pages[pages.length - 1];
|
||||
lastPage.drawImage(png, {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: widthPt,
|
||||
height: heightPt,
|
||||
});
|
||||
}
|
||||
|
|
@ -46,6 +46,7 @@ import {
|
|||
SPACE,
|
||||
LINE_HEIGHT,
|
||||
} from './templates/default';
|
||||
import { attachQRBillToPdf, buildQRBillData, QRBillError } from './qr-bill';
|
||||
|
||||
// ─── Small geometry helpers ────────────────────────────────
|
||||
|
||||
|
|
@ -480,13 +481,25 @@ function renderFooter(ctx: RenderContext, settings: InvoiceSettings): void {
|
|||
|
||||
// ─── Public API ───────────────────────────────────────────
|
||||
|
||||
export interface RenderOptions {
|
||||
/**
|
||||
* Attach the Swiss QR-Bill at the bottom of the last page when the
|
||||
* invoice is eligible (CHF/EUR + valid IBAN + parseable addresses).
|
||||
* Defaults to true. Set to false for draft previews where the user
|
||||
* hasn't filled settings yet and you want a fast, non-rasterising
|
||||
* render path.
|
||||
*/
|
||||
includeQRBill?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render an invoice to PDF bytes. Call-site is responsible for wrapping the
|
||||
* output into a Blob, iframe URL, or File attachment.
|
||||
*/
|
||||
export async function renderInvoicePdf(
|
||||
invoice: Invoice,
|
||||
settings: InvoiceSettings
|
||||
settings: InvoiceSettings,
|
||||
opts?: RenderOptions
|
||||
): Promise<Uint8Array> {
|
||||
const doc = await PDFDocument.create();
|
||||
doc.setTitle(`Rechnung ${invoice.number}`);
|
||||
|
|
@ -531,6 +544,24 @@ export async function renderInvoicePdf(
|
|||
const firstCtx: RenderContext = { ...ctx, page: firstPage };
|
||||
renderFooter(firstCtx, settings);
|
||||
|
||||
// Swiss QR-Bill overlay, bottom 105mm of the last page. Only attached
|
||||
// if the invoice is eligible (CHF/EUR + valid IBAN + parseable
|
||||
// addresses). Non-eligible invoices just get the plain PDF; the UI
|
||||
// surfaces a warning separately via qrBillStatus().
|
||||
if (opts?.includeQRBill !== false) {
|
||||
try {
|
||||
await attachQRBillToPdf(doc, invoice, settings);
|
||||
} catch (err) {
|
||||
if (err instanceof QRBillError) {
|
||||
// Silently omit — the DetailView calls qrBillStatus() to show the
|
||||
// user why the Zahlteil is missing. We don't want PDF generation
|
||||
// to fail just because the IBAN isn't set yet.
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return await doc.save();
|
||||
}
|
||||
|
||||
|
|
@ -540,10 +571,34 @@ export async function renderInvoicePdf(
|
|||
*/
|
||||
export async function renderInvoicePdfBlob(
|
||||
invoice: Invoice,
|
||||
settings: InvoiceSettings
|
||||
settings: InvoiceSettings,
|
||||
opts?: RenderOptions
|
||||
): Promise<Blob> {
|
||||
const bytes = await renderInvoicePdf(invoice, settings);
|
||||
const bytes = await renderInvoicePdf(invoice, settings, opts);
|
||||
// `.slice(0)` copies into a fresh ArrayBuffer-backed Uint8Array so the
|
||||
// Blob constructor types match (SharedArrayBuffer is not a BlobPart).
|
||||
return new Blob([bytes.slice(0) as BlobPart], { type: 'application/pdf' });
|
||||
}
|
||||
|
||||
// ─── QR-Bill status for UI ───────────────────────────────
|
||||
|
||||
/**
|
||||
* Returns `{ ok: true }` if the invoice is QR-Bill-eligible, or
|
||||
* `{ ok: false, message, reason }` describing what's missing so the UI
|
||||
* can show a targeted hint. Does NOT render — pure validation, cheap to
|
||||
* call on every form change.
|
||||
*/
|
||||
export function qrBillStatus(
|
||||
invoice: Invoice,
|
||||
settings: InvoiceSettings
|
||||
): { ok: true } | { ok: false; message: string; reason: QRBillError['reason'] } {
|
||||
try {
|
||||
buildQRBillData(invoice, settings);
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
if (err instanceof QRBillError) {
|
||||
return { ok: false, message: err.message, reason: err.reason };
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import { encryptRecord } from '$lib/data/crypto';
|
|||
import { emitDomainEvent } from '$lib/data/events';
|
||||
import { invoiceTable } from '../collections';
|
||||
import { computeInvoiceTotals } from '../totals';
|
||||
import { generateSCORReference } from '../pdf/qr-bill';
|
||||
import type {
|
||||
LocalInvoice,
|
||||
LocalInvoiceLine,
|
||||
|
|
@ -63,6 +64,12 @@ export const invoicesStore = {
|
|||
const lines = input.lines ?? [];
|
||||
const totals = computeInvoiceTotals(lines);
|
||||
|
||||
// Pre-compute the SCOR reference so it's stable across re-renders of
|
||||
// the PDF — the number is derived from invoice.number, which is
|
||||
// already locked in at this point. Only used for CHF/EUR QR-Bills;
|
||||
// other currencies ignore it.
|
||||
const referenceNumber = generateSCORReference(number);
|
||||
|
||||
const newLocal: LocalInvoice = {
|
||||
id: crypto.randomUUID(),
|
||||
number,
|
||||
|
|
@ -79,7 +86,7 @@ export const invoicesStore = {
|
|||
subject: input.subject ?? null,
|
||||
notes: input.notes ?? null,
|
||||
terms: input.terms ?? defaults.terms,
|
||||
referenceNumber: null,
|
||||
referenceNumber,
|
||||
pdfBlobKey: null,
|
||||
totals,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
import StatusBadge from '../components/StatusBadge.svelte';
|
||||
import { invoicesStore } from '../stores/invoices.svelte';
|
||||
import { invoiceSettingsStore } from '../stores/settings.svelte';
|
||||
import { renderInvoicePdfBlob } from '../pdf/renderer';
|
||||
import { renderInvoicePdfBlob, qrBillStatus } from '../pdf/renderer';
|
||||
import { formatAmount } from '../queries';
|
||||
import type { Invoice, InvoiceSettings } from '../types';
|
||||
import { STATUS_LABELS } from '../constants';
|
||||
|
|
@ -29,12 +29,18 @@
|
|||
let pdfUrl = $state<string | null>(null);
|
||||
let pdfError = $state<string | null>(null);
|
||||
let renderingPdf = $state(false);
|
||||
let qrWarning = $state<string | null>(null);
|
||||
|
||||
async function renderPdf() {
|
||||
renderingPdf = true;
|
||||
pdfError = null;
|
||||
try {
|
||||
const settings: InvoiceSettings = await invoiceSettingsStore.get();
|
||||
// Compute QR-Bill eligibility first so we can show a warning even
|
||||
// if the rest of the PDF renders fine. The renderer will silently
|
||||
// omit the Zahlteil when not eligible.
|
||||
const status = qrBillStatus(invoice, settings);
|
||||
qrWarning = status.ok ? null : status.message;
|
||||
const blob = await renderInvoicePdfBlob(invoice, settings);
|
||||
if (pdfUrl) URL.revokeObjectURL(pdfUrl);
|
||||
pdfUrl = URL.createObjectURL(blob);
|
||||
|
|
@ -170,6 +176,13 @@
|
|||
<span class="preview-status">Rendert …</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if qrWarning}
|
||||
<div class="warning">
|
||||
<strong>QR-Rechnung nicht eingefügt:</strong>
|
||||
{qrWarning}
|
||||
<a href="/invoices/settings">Einstellungen öffnen →</a>
|
||||
</div>
|
||||
{/if}
|
||||
{#if pdfError}
|
||||
<div class="error">PDF-Fehler: {pdfError}</div>
|
||||
{:else if pdfUrl}
|
||||
|
|
@ -355,6 +368,25 @@
|
|||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.warning {
|
||||
background: #fffbeb;
|
||||
border: 1px solid #fde68a;
|
||||
color: #92400e;
|
||||
padding: 0.65rem 0.9rem;
|
||||
border-radius: 0.4rem;
|
||||
font-size: 0.85rem;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.warning a {
|
||||
color: #92400e;
|
||||
font-weight: 500;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.preview-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue