@facturion/invoice-renderer 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/NOTICE +20 -0
- package/README.md +43 -0
- package/index.d.ts +33 -0
- package/package.json +55 -0
- package/src/document.js +43 -0
- package/src/i18n.js +140 -0
- package/src/index.js +6 -0
- package/src/render.js +421 -0
- package/styles/utilities.css +227 -0
- package/styles/variables.css +89 -0
- package/styles/view.css +902 -0
package/src/render.js
ADDED
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
// Render a "normal invoice" HTML fragment — the way a buyer, seller or
|
|
2
|
+
// auditor would expect a commercial document to look. Input: the simplified
|
|
3
|
+
// EN 16931 JSON modelled by @facturion/invoice.
|
|
4
|
+
//
|
|
5
|
+
// Sections are conditional: blocks with no data render nothing, so a bare
|
|
6
|
+
// invoice stays compact while a fully-specified one surfaces every field a
|
|
7
|
+
// traditional invoice document would carry.
|
|
8
|
+
//
|
|
9
|
+
// `t(key, vars?)` is injected — a label resolver for `view.*` document strings
|
|
10
|
+
// and `units.*` / `paymentMeans.*` vocabulary. `renderInvoiceDocument` supplies
|
|
11
|
+
// a default `t` (bundled strings + @facturion/codelists); advanced callers pass
|
|
12
|
+
// their own. The net/total math comes from @facturion/invoice.
|
|
13
|
+
import { computeTotals, lineNet } from "@facturion/invoice";
|
|
14
|
+
|
|
15
|
+
export { computeTotals, lineNet };
|
|
16
|
+
|
|
17
|
+
// Shared formatting helpers.
|
|
18
|
+
export function esc(s) {
|
|
19
|
+
return String(s ?? "")
|
|
20
|
+
.replace(/&/g, "&")
|
|
21
|
+
.replace(/</g, "<")
|
|
22
|
+
.replace(/>/g, ">")
|
|
23
|
+
.replace(/"/g, """);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function amt(value) {
|
|
27
|
+
const n = Number(value);
|
|
28
|
+
if (!isFinite(n)) return esc(value ?? "—");
|
|
29
|
+
return n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function curAmt(value, cur) {
|
|
33
|
+
// U+00A0 (no-break space) keeps the amount and its currency on one line.
|
|
34
|
+
return cur ? `${amt(value)} ${esc(cur)}` : amt(value);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function unitLabel(code, qty, t) {
|
|
38
|
+
if (!code) return "";
|
|
39
|
+
const plural = Number(qty) !== 1;
|
|
40
|
+
const key = "units." + code + (plural ? "_plural" : "");
|
|
41
|
+
const label = t(key);
|
|
42
|
+
return label === key ? code : label;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function dateRange(start, end) {
|
|
46
|
+
if (start && end && start !== end) return `${esc(start)} – ${esc(end)}`;
|
|
47
|
+
return esc(start || end || "");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function addressLines(addr) {
|
|
51
|
+
if (!addr) return [];
|
|
52
|
+
const lines = [];
|
|
53
|
+
if (addr.line1) lines.push(addr.line1);
|
|
54
|
+
if (addr.line2) lines.push(addr.line2);
|
|
55
|
+
if (addr.line3) lines.push(addr.line3);
|
|
56
|
+
const pcCity = [addr.post_code, addr.city].filter(Boolean).join(" ");
|
|
57
|
+
if (pcCity) lines.push(pcCity);
|
|
58
|
+
if (addr.country_subdivision) lines.push(addr.country_subdivision);
|
|
59
|
+
if (addr.country_code) lines.push(addr.country_code);
|
|
60
|
+
return lines;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ── Section builders ──────────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
function headerBlock(invoice, t) {
|
|
66
|
+
const typeLabel = invoice.invoice_type_code === "381"
|
|
67
|
+
? t("view.creditNote")
|
|
68
|
+
: t("view.invoice");
|
|
69
|
+
|
|
70
|
+
return `
|
|
71
|
+
<div class="inv-header">
|
|
72
|
+
<div class="inv-header-left">
|
|
73
|
+
<div class="inv-type">${esc(typeLabel)}</div>
|
|
74
|
+
<div class="inv-id">${esc(invoice.invoice_number)}</div>
|
|
75
|
+
</div>
|
|
76
|
+
<div class="inv-header-right">
|
|
77
|
+
<div><span class="inv-label">${t("view.issueDate")}</span> ${esc(invoice.issue_date)}</div>
|
|
78
|
+
${invoice.due_date ? `<div><span class="inv-label">${t("view.dueDate")}</span> ${esc(invoice.due_date)}</div>` : ""}
|
|
79
|
+
${invoice.currency_code ? `<div><span class="inv-label">${t("view.currency")}</span> ${esc(invoice.currency_code)}</div>` : ""}
|
|
80
|
+
</div>
|
|
81
|
+
</div>`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function partyBlock(party, label, extraLines) {
|
|
85
|
+
if (!party) return "";
|
|
86
|
+
const rows = [];
|
|
87
|
+
if (party.name) {
|
|
88
|
+
rows.push(`<div class="inv-party-name">${esc(party.name)}</div>`);
|
|
89
|
+
}
|
|
90
|
+
if (party.trading_name && party.trading_name !== party.name) {
|
|
91
|
+
rows.push(`<div class="inv-party-trading">${esc(party.trading_name)}</div>`);
|
|
92
|
+
}
|
|
93
|
+
for (const l of addressLines(party.address)) {
|
|
94
|
+
rows.push(`<div>${esc(l)}</div>`);
|
|
95
|
+
}
|
|
96
|
+
if (party.vat_id) {
|
|
97
|
+
rows.push(`<div><span class="inv-label">VAT</span> ${esc(party.vat_id)}</div>`);
|
|
98
|
+
}
|
|
99
|
+
if (party.tax_registration_id) {
|
|
100
|
+
rows.push(`<div><span class="inv-label">Tax-ID</span> ${esc(party.tax_registration_id)}</div>`);
|
|
101
|
+
}
|
|
102
|
+
if (party.legal_registration_id?.id) {
|
|
103
|
+
rows.push(`<div><span class="inv-label">Reg</span> ${esc(party.legal_registration_id.id)}</div>`);
|
|
104
|
+
}
|
|
105
|
+
if (party.additional_legal_info) {
|
|
106
|
+
rows.push(`<div class="inv-party-legal">${esc(party.additional_legal_info)}</div>`);
|
|
107
|
+
}
|
|
108
|
+
if (party.contact) {
|
|
109
|
+
const bits = [party.contact.name, party.contact.phone, party.contact.email].filter(Boolean);
|
|
110
|
+
if (bits.length) {
|
|
111
|
+
rows.push(`<div class="inv-party-contact">${bits.map(esc).join(" · ")}</div>`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
for (const extra of extraLines || []) rows.push(extra);
|
|
115
|
+
|
|
116
|
+
return `<div class="inv-party"><div class="inv-party-label">${esc(label)}</div>${rows.join("")}</div>`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function partiesBlock(invoice, t) {
|
|
120
|
+
const buyerExtras = invoice.buyer_reference
|
|
121
|
+
? [`<div><span class="inv-label">Ref</span> ${esc(invoice.buyer_reference)}</div>`]
|
|
122
|
+
: [];
|
|
123
|
+
|
|
124
|
+
const primary = `
|
|
125
|
+
<div class="inv-parties">
|
|
126
|
+
${partyBlock(invoice.seller, t("view.from"))}
|
|
127
|
+
${partyBlock(invoice.buyer, t("view.to"), buyerExtras)}
|
|
128
|
+
</div>`;
|
|
129
|
+
|
|
130
|
+
// Secondary parties only appear when present (payee, tax rep, deliver-to).
|
|
131
|
+
const secondaries = [];
|
|
132
|
+
|
|
133
|
+
if (invoice.payee) {
|
|
134
|
+
secondaries.push(partyBlock(invoice.payee, t("view.payee")));
|
|
135
|
+
}
|
|
136
|
+
if (invoice.seller_tax_representative) {
|
|
137
|
+
secondaries.push(partyBlock(invoice.seller_tax_representative, t("view.taxRep")));
|
|
138
|
+
}
|
|
139
|
+
const d = invoice.delivery;
|
|
140
|
+
if (d && (d.name || d.address?.line1 || d.location_id?.id)) {
|
|
141
|
+
const extras = [];
|
|
142
|
+
if (d.location_id?.id) {
|
|
143
|
+
extras.push(`<div><span class="inv-label">ID</span> ${esc(d.location_id.id)}</div>`);
|
|
144
|
+
}
|
|
145
|
+
secondaries.push(partyBlock(
|
|
146
|
+
{ name: d.name, address: d.address },
|
|
147
|
+
t("view.deliverTo"),
|
|
148
|
+
extras,
|
|
149
|
+
));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const secondaryHtml = secondaries.length
|
|
153
|
+
? `<div class="inv-parties-secondary">${secondaries.join("")}</div>`
|
|
154
|
+
: "";
|
|
155
|
+
|
|
156
|
+
return primary + secondaryHtml;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function referencesBlock(invoice, t) {
|
|
160
|
+
const refs = [];
|
|
161
|
+
const push = (label, value) => { if (value) refs.push([label, value]); };
|
|
162
|
+
|
|
163
|
+
push(t("view.purchaseOrder"), invoice.purchase_order_reference);
|
|
164
|
+
push(t("view.contract"), invoice.contract_reference);
|
|
165
|
+
push(t("view.project"), invoice.project_reference);
|
|
166
|
+
push(t("view.salesOrder"), invoice.sales_order_reference);
|
|
167
|
+
push(t("view.despatchAdvice"), invoice.despatch_advice_reference);
|
|
168
|
+
push(t("view.receivingAdvice"), invoice.receiving_advice_reference);
|
|
169
|
+
push(t("view.tender"), invoice.tender_or_lot_reference);
|
|
170
|
+
push(t("view.objectIdentifier"), invoice.object_identifier?.id);
|
|
171
|
+
for (const p of invoice.preceding_invoices || []) {
|
|
172
|
+
const val = p.issue_date ? `${p.reference} (${p.issue_date})` : p.reference;
|
|
173
|
+
push(t("view.preceding"), val);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (refs.length === 0) return "";
|
|
177
|
+
const rows = refs.map(([k, v]) =>
|
|
178
|
+
`<div class="inv-ref"><span class="inv-ref-label">${esc(k)}</span><span class="inv-ref-value">${esc(v)}</span></div>`
|
|
179
|
+
).join("");
|
|
180
|
+
return `<div class="inv-references">${rows}</div>`;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function periodBlock(invoice, t) {
|
|
184
|
+
const period = invoice.delivery?.period;
|
|
185
|
+
if (period?.start_date || period?.end_date) {
|
|
186
|
+
return `<div class="inv-period">
|
|
187
|
+
<span class="inv-label">${t("view.invoicingPeriod")}</span>
|
|
188
|
+
${dateRange(period.start_date, period.end_date)}
|
|
189
|
+
</div>`;
|
|
190
|
+
}
|
|
191
|
+
if (invoice.delivery?.date) {
|
|
192
|
+
return `<div class="inv-period">
|
|
193
|
+
<span class="inv-label">${t("view.deliveryDate")}</span>
|
|
194
|
+
${esc(invoice.delivery.date)}
|
|
195
|
+
</div>`;
|
|
196
|
+
}
|
|
197
|
+
return "";
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function notesBlock(invoice, t) {
|
|
201
|
+
const notes = invoice.notes || [];
|
|
202
|
+
if (notes.length === 0) return "";
|
|
203
|
+
const items = notes.map(n => `<p>${esc(n.text)}</p>`).join("");
|
|
204
|
+
return `<div class="inv-notes">
|
|
205
|
+
<div class="inv-section-label">${esc(t("view.notes"))}</div>
|
|
206
|
+
${items}
|
|
207
|
+
</div>`;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function linesBlock(invoice, t) {
|
|
211
|
+
const lines = invoice.lines || [];
|
|
212
|
+
const rows = lines.map(line => {
|
|
213
|
+
const qty = line.quantity;
|
|
214
|
+
const unit = line.unit_code;
|
|
215
|
+
const netPrice = line.price?.net_price;
|
|
216
|
+
const grossPrice = line.price?.gross_price;
|
|
217
|
+
const discount = line.price?.discount;
|
|
218
|
+
const taxRate = line.vat?.rate;
|
|
219
|
+
const total = lineNet(line);
|
|
220
|
+
const name = line.item?.name || "";
|
|
221
|
+
const desc = line.item?.description && line.item.description !== name
|
|
222
|
+
? line.item.description : "";
|
|
223
|
+
|
|
224
|
+
const periodHtml = (line.period?.start_date || line.period?.end_date)
|
|
225
|
+
? `<div class="inv-line-meta"><span class="inv-label">${t("view.linePeriod")}</span> ${dateRange(line.period.start_date, line.period.end_date)}</div>`
|
|
226
|
+
: "";
|
|
227
|
+
const noteHtml = line.note ? `<div class="inv-line-meta">${esc(line.note)}</div>` : "";
|
|
228
|
+
|
|
229
|
+
// Line-level allowances and charges: shown inline under the description.
|
|
230
|
+
const acBits = [];
|
|
231
|
+
for (const a of line.allowances || []) {
|
|
232
|
+
const label = a.reason || t("view.documentAllowance");
|
|
233
|
+
acBits.push(`<div class="inv-line-allowance">− ${amt(a.amount)} · ${esc(label)}</div>`);
|
|
234
|
+
}
|
|
235
|
+
for (const c of line.charges || []) {
|
|
236
|
+
const label = c.reason || t("view.documentCharge");
|
|
237
|
+
acBits.push(`<div class="inv-line-charge">+ ${amt(c.amount)} · ${esc(label)}</div>`);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Show gross + discount alongside the net price when the invoice carries
|
|
241
|
+
// both (e.g. "100.00 / was 120.00 (-16.67%)").
|
|
242
|
+
const priceCell = (grossPrice != null && (discount != null || grossPrice !== netPrice))
|
|
243
|
+
? `${amt(netPrice)}<div class="inv-line-price-gross">${amt(grossPrice)}</div>`
|
|
244
|
+
: amt(netPrice);
|
|
245
|
+
|
|
246
|
+
return `
|
|
247
|
+
<tr>
|
|
248
|
+
<td class="inv-col-id">${esc(line.id)}</td>
|
|
249
|
+
<td class="inv-col-desc">
|
|
250
|
+
<div class="inv-line-name">${esc(name)}</div>
|
|
251
|
+
${desc ? `<div class="inv-line-desc">${esc(desc)}</div>` : ""}
|
|
252
|
+
${periodHtml}
|
|
253
|
+
${noteHtml}
|
|
254
|
+
${acBits.join("")}
|
|
255
|
+
</td>
|
|
256
|
+
<td class="inv-col-num">${esc(qty)}${unit ? ` ${esc(unitLabel(unit, qty, t))}` : ""}</td>
|
|
257
|
+
<td class="inv-col-num">${priceCell}</td>
|
|
258
|
+
<td class="inv-col-num">${taxRate != null ? `${esc(taxRate)} %` : ""}</td>
|
|
259
|
+
<td class="inv-col-num">${amt(total)}</td>
|
|
260
|
+
</tr>`;
|
|
261
|
+
}).join("");
|
|
262
|
+
|
|
263
|
+
return `
|
|
264
|
+
<div class="inv-lines-wrap">
|
|
265
|
+
<table class="data-table inv-lines">
|
|
266
|
+
<thead><tr>
|
|
267
|
+
<th class="inv-col-id">${t("view.lineNo")}</th>
|
|
268
|
+
<th class="inv-col-desc">${t("view.description")}</th>
|
|
269
|
+
<th class="inv-col-num">${t("view.quantity")}</th>
|
|
270
|
+
<th class="inv-col-num">${t("view.unitPrice")}</th>
|
|
271
|
+
<th class="inv-col-num">${t("view.taxRate")}</th>
|
|
272
|
+
<th class="inv-col-num">${t("view.lineTotal")}</th>
|
|
273
|
+
</tr></thead>
|
|
274
|
+
<tbody>${rows}</tbody>
|
|
275
|
+
</table>
|
|
276
|
+
</div>`;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function totalsBlock(invoice, t, totals) {
|
|
280
|
+
const cur = invoice.currency_code;
|
|
281
|
+
const rows = [];
|
|
282
|
+
|
|
283
|
+
rows.push(`<tr><td>${t("view.subtotal")}</td><td class="inv-col-num">${curAmt(totals.lineExtension, cur)}</td></tr>`);
|
|
284
|
+
|
|
285
|
+
for (const a of invoice.document_allowances || []) {
|
|
286
|
+
const label = a.reason || t("view.documentAllowance");
|
|
287
|
+
rows.push(`<tr><td>${esc(label)}</td><td class="inv-col-num">− ${curAmt(a.amount, cur)}</td></tr>`);
|
|
288
|
+
}
|
|
289
|
+
for (const c of invoice.document_charges || []) {
|
|
290
|
+
const label = c.reason || t("view.documentCharge");
|
|
291
|
+
rows.push(`<tr><td>${esc(label)}</td><td class="inv-col-num">+ ${curAmt(c.amount, cur)}</td></tr>`);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Per-category VAT breakdown — rate, taxable base inline, then tax amount.
|
|
295
|
+
// Exemption reason (BT-120) renders as a subtle sub-row so readers see the
|
|
296
|
+
// compliance statement right next to the 0% line.
|
|
297
|
+
for (const g of totals.taxSubtotals) {
|
|
298
|
+
const rateLabel = t("view.taxTotal", { rate: g.rate });
|
|
299
|
+
rows.push(`<tr class="inv-vat-row">
|
|
300
|
+
<td>${esc(rateLabel)}<span class="inv-vat-base"> · ${t("view.taxableAmount")} ${curAmt(g.taxable, cur)}</span></td>
|
|
301
|
+
<td class="inv-col-num">${curAmt(g.tax, cur)}</td>
|
|
302
|
+
</tr>`);
|
|
303
|
+
if (g.reason) {
|
|
304
|
+
rows.push(`<tr class="inv-vat-reason"><td colspan="2">${esc(g.reason)}</td></tr>`);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (totals.prepaid !== 0) {
|
|
309
|
+
rows.push(`<tr><td>${t("view.prepaid")}</td><td class="inv-col-num">− ${curAmt(Math.abs(totals.prepaid), cur)}</td></tr>`);
|
|
310
|
+
}
|
|
311
|
+
if (totals.rounding !== 0) {
|
|
312
|
+
const sign = totals.rounding > 0 ? "+ " : "− ";
|
|
313
|
+
rows.push(`<tr><td>${t("view.rounding")}</td><td class="inv-col-num">${sign}${curAmt(Math.abs(totals.rounding), cur)}</td></tr>`);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
rows.push(`<tr class="inv-totals-payable"><td>${t("view.payable")}</td><td class="inv-col-num">${curAmt(totals.payable, cur)}</td></tr>`);
|
|
317
|
+
|
|
318
|
+
return `<div class="inv-totals-wrap">
|
|
319
|
+
<table class="inv-totals"><tbody>${rows.join("")}</tbody></table>
|
|
320
|
+
</div>`;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Resolve the payment means to a human-readable label. Prefer BT-82 free
|
|
324
|
+
// text; fall back to a lookup of the BT-81 (UNTDID 4461) code; fall back
|
|
325
|
+
// further to the raw code if the lookup misses.
|
|
326
|
+
function paymentMeansLabel(p, t) {
|
|
327
|
+
if (p.means_text) return p.means_text;
|
|
328
|
+
if (!p.means_code) return "";
|
|
329
|
+
const key = "paymentMeans." + p.means_code;
|
|
330
|
+
const label = t(key);
|
|
331
|
+
return label === key ? p.means_code : label;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function paymentBlock(invoice, t) {
|
|
335
|
+
const p = invoice.payment;
|
|
336
|
+
if (!p) return "";
|
|
337
|
+
const rows = [];
|
|
338
|
+
|
|
339
|
+
const means = paymentMeansLabel(p, t);
|
|
340
|
+
if (means) {
|
|
341
|
+
rows.push(`<div><span class="inv-label">${t("view.paymentMeans")}</span> ${esc(means)}</div>`);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// BG-17 credit transfers (one or more).
|
|
345
|
+
for (const ct of p.credit_transfers || []) {
|
|
346
|
+
if (ct.account_id) {
|
|
347
|
+
rows.push(`<div><span class="inv-label">${t("view.iban")}</span> ${esc(ct.account_id)}</div>`);
|
|
348
|
+
}
|
|
349
|
+
if (ct.service_provider_id) {
|
|
350
|
+
rows.push(`<div><span class="inv-label">${t("view.bic")}</span> ${esc(ct.service_provider_id)}</div>`);
|
|
351
|
+
}
|
|
352
|
+
if (ct.account_name) {
|
|
353
|
+
rows.push(`<div><span class="inv-label">${t("view.accountName")}</span> ${esc(ct.account_name)}</div>`);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// BG-18 payment card (masked PAN + holder name).
|
|
358
|
+
if (p.card) {
|
|
359
|
+
if (p.card.account_number) {
|
|
360
|
+
rows.push(`<div><span class="inv-label">${t("view.cardNumber")}</span> ${esc(p.card.account_number)}</div>`);
|
|
361
|
+
}
|
|
362
|
+
if (p.card.holder_name) {
|
|
363
|
+
rows.push(`<div><span class="inv-label">${t("view.cardHolder")}</span> ${esc(p.card.holder_name)}</div>`);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// BG-19 direct debit (mandate reference, creditor ID, debited account).
|
|
368
|
+
const dd = p.direct_debit;
|
|
369
|
+
if (dd) {
|
|
370
|
+
if (dd.mandate_reference) {
|
|
371
|
+
rows.push(`<div><span class="inv-label">${t("view.mandateReference")}</span> ${esc(dd.mandate_reference)}</div>`);
|
|
372
|
+
}
|
|
373
|
+
if (dd.creditor_id) {
|
|
374
|
+
rows.push(`<div><span class="inv-label">${t("view.creditorId")}</span> ${esc(dd.creditor_id)}</div>`);
|
|
375
|
+
}
|
|
376
|
+
if (dd.debited_account_id) {
|
|
377
|
+
rows.push(`<div><span class="inv-label">${t("view.debitedAccount")}</span> ${esc(dd.debited_account_id)}</div>`);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
if (p.remittance_information) {
|
|
382
|
+
rows.push(`<div><span class="inv-label">${t("view.remittance")}</span> ${esc(p.remittance_information)}</div>`);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
if (rows.length === 0) return "";
|
|
386
|
+
return `<div class="inv-payment">
|
|
387
|
+
<div class="inv-section-label">${esc(t("view.paymentInstructions"))}</div>
|
|
388
|
+
${rows.join("")}
|
|
389
|
+
</div>`;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function paymentTermsBlock(invoice, t) {
|
|
393
|
+
if (!invoice.payment_terms) return "";
|
|
394
|
+
return `<div class="inv-payment-terms">
|
|
395
|
+
<span class="inv-label">${t("view.paymentTerms")}</span> ${esc(invoice.payment_terms)}
|
|
396
|
+
</div>`;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// ── Entry point ──────────────────────────────────────────────────────────
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Render the invoice as an HTML fragment (no document chrome). `opts.t` is a
|
|
403
|
+
* label resolver: `(key, vars?) => string`, returning the key unchanged on a
|
|
404
|
+
* miss. See `renderInvoiceDocument` for a batteries-included wrapper.
|
|
405
|
+
*/
|
|
406
|
+
export function renderInvoice(invoice, { t }) {
|
|
407
|
+
const totals = computeTotals(invoice);
|
|
408
|
+
|
|
409
|
+
return `
|
|
410
|
+
<div class="invoice-paper">
|
|
411
|
+
${headerBlock(invoice, t)}
|
|
412
|
+
${partiesBlock(invoice, t)}
|
|
413
|
+
${referencesBlock(invoice, t)}
|
|
414
|
+
${periodBlock(invoice, t)}
|
|
415
|
+
${notesBlock(invoice, t)}
|
|
416
|
+
${linesBlock(invoice, t)}
|
|
417
|
+
${totalsBlock(invoice, t, totals)}
|
|
418
|
+
${paymentBlock(invoice, t)}
|
|
419
|
+
${paymentTermsBlock(invoice, t)}
|
|
420
|
+
</div>`;
|
|
421
|
+
}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/* ── Utilities ─────────────────────────────────────────────────────────────── */
|
|
2
|
+
|
|
3
|
+
/* Data table */
|
|
4
|
+
.data-table {
|
|
5
|
+
width: 100%;
|
|
6
|
+
border-collapse: collapse;
|
|
7
|
+
font-size: var(--text-sm);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
.data-table th,
|
|
11
|
+
.data-table td {
|
|
12
|
+
text-align: left;
|
|
13
|
+
padding: var(--space-2) var(--space-3);
|
|
14
|
+
border-bottom: 1px solid var(--border);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
.data-table th {
|
|
18
|
+
color: var(--text-secondary);
|
|
19
|
+
font-weight: 500;
|
|
20
|
+
font-size: var(--text-xs);
|
|
21
|
+
text-transform: uppercase;
|
|
22
|
+
letter-spacing: 0.04em;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
.data-table td {
|
|
26
|
+
color: var(--text-secondary);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
.data-table code {
|
|
30
|
+
font-size: var(--text-xs);
|
|
31
|
+
color: var(--primary-light);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/* Alert */
|
|
35
|
+
.alert {
|
|
36
|
+
padding: var(--space-3) var(--space-4);
|
|
37
|
+
border-radius: var(--radius-md);
|
|
38
|
+
margin-bottom: var(--space-4);
|
|
39
|
+
font-size: var(--text-sm);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
.alert-info {
|
|
43
|
+
background: var(--info-dim);
|
|
44
|
+
color: var(--info);
|
|
45
|
+
border: 1px solid rgba(59, 130, 246, 0.2);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
.alert-error {
|
|
49
|
+
background: var(--error-dim);
|
|
50
|
+
color: var(--error);
|
|
51
|
+
border: 1px solid rgba(239, 68, 68, 0.2);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
.alert code {
|
|
55
|
+
display: block;
|
|
56
|
+
margin-top: var(--space-2);
|
|
57
|
+
word-break: break-all;
|
|
58
|
+
font-size: var(--text-xs);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/* Transitions for interactive elements */
|
|
62
|
+
.card {
|
|
63
|
+
transition: border-color var(--duration) var(--ease-out);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
.btn {
|
|
67
|
+
transition: background var(--duration) var(--ease-out),
|
|
68
|
+
border-color var(--duration) var(--ease-out),
|
|
69
|
+
opacity var(--duration) var(--ease-out),
|
|
70
|
+
transform 80ms var(--ease-out);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
.btn:active:not(:disabled) {
|
|
74
|
+
transform: scale(0.98);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/* Inline spinner (for buttons) */
|
|
78
|
+
@keyframes spin { to { transform: rotate(360deg); } }
|
|
79
|
+
|
|
80
|
+
.spinner {
|
|
81
|
+
display: inline-block;
|
|
82
|
+
width: 16px;
|
|
83
|
+
height: 16px;
|
|
84
|
+
border: 2px solid currentColor;
|
|
85
|
+
border-right-color: transparent;
|
|
86
|
+
border-radius: 50%;
|
|
87
|
+
animation: spin 0.6s linear infinite;
|
|
88
|
+
vertical-align: middle;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/* ── Info tooltip ─────────────────────────────────────────────────────────
|
|
92
|
+
Shared component for inline help affordances. Markup pattern:
|
|
93
|
+
|
|
94
|
+
<span class="info-tip" tabindex="0" role="button" aria-label="More info">
|
|
95
|
+
<span class="info-tip-icon" aria-hidden="true">i</span>
|
|
96
|
+
<span class="info-tip-popover" role="tooltip">…content…</span>
|
|
97
|
+
</span>
|
|
98
|
+
|
|
99
|
+
Hover or focus on the wrapper reveals the popover. The popover sits flush
|
|
100
|
+
against the icon's bottom edge so the cursor doesn't cross a non-hovered
|
|
101
|
+
gap on its way from the icon down into the popover (which would close it
|
|
102
|
+
prematurely). Content can be plain text or include rich children
|
|
103
|
+
(links, source notes, etc.). */
|
|
104
|
+
.info-tip {
|
|
105
|
+
position: relative;
|
|
106
|
+
display: inline-flex;
|
|
107
|
+
align-items: center;
|
|
108
|
+
flex-shrink: 0;
|
|
109
|
+
cursor: help;
|
|
110
|
+
outline: none;
|
|
111
|
+
vertical-align: middle;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
.info-tip-icon {
|
|
115
|
+
display: inline-flex;
|
|
116
|
+
align-items: center;
|
|
117
|
+
justify-content: center;
|
|
118
|
+
width: 14px;
|
|
119
|
+
height: 14px;
|
|
120
|
+
border-radius: var(--radius-full);
|
|
121
|
+
background: var(--bg-elevated);
|
|
122
|
+
color: var(--text-muted);
|
|
123
|
+
font-size: 10px;
|
|
124
|
+
font-style: italic;
|
|
125
|
+
font-weight: 700;
|
|
126
|
+
/* Pin line-height to font-size so the character sits at the geometric
|
|
127
|
+
center of the circle. Without this, the line-box inherits the parent's
|
|
128
|
+
line-height (~1.5) and the "i" rides high inside the icon, looking
|
|
129
|
+
misaligned with adjacent text. */
|
|
130
|
+
line-height: 1;
|
|
131
|
+
user-select: none;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
.info-tip:hover .info-tip-icon,
|
|
135
|
+
.info-tip:focus-visible .info-tip-icon {
|
|
136
|
+
background: var(--border);
|
|
137
|
+
color: var(--text);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/* Popover is `position: fixed` so it escapes any parent overflow/clip
|
|
141
|
+
context (cards, scroll containers, etc.) and can be JS-positioned in
|
|
142
|
+
viewport coordinates. `installInfoTipPositioning` in `$lib/info-tip.ts`
|
|
143
|
+
computes top/left/max-width on mouseover/focusin and on scroll/resize.
|
|
144
|
+
Hidden state uses opacity + visibility (not display:none) so the
|
|
145
|
+
popover always has measurable dimensions for the positioning math. */
|
|
146
|
+
.info-tip-popover {
|
|
147
|
+
position: fixed;
|
|
148
|
+
top: 0;
|
|
149
|
+
left: 0;
|
|
150
|
+
z-index: 10;
|
|
151
|
+
display: flex;
|
|
152
|
+
flex-direction: column;
|
|
153
|
+
gap: var(--space-2);
|
|
154
|
+
min-width: 240px;
|
|
155
|
+
max-width: 340px;
|
|
156
|
+
padding: var(--space-3);
|
|
157
|
+
background: var(--bg-card);
|
|
158
|
+
border: 1px solid var(--border);
|
|
159
|
+
border-radius: var(--radius-md);
|
|
160
|
+
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.18);
|
|
161
|
+
font-family: var(--font-sans);
|
|
162
|
+
font-size: var(--text-xs);
|
|
163
|
+
font-style: normal;
|
|
164
|
+
font-weight: 400;
|
|
165
|
+
line-height: 1.5;
|
|
166
|
+
color: var(--text);
|
|
167
|
+
text-align: left;
|
|
168
|
+
white-space: normal;
|
|
169
|
+
cursor: default;
|
|
170
|
+
opacity: 0;
|
|
171
|
+
visibility: hidden;
|
|
172
|
+
pointer-events: none;
|
|
173
|
+
transition: opacity 0.1s ease;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
.info-tip:hover .info-tip-popover,
|
|
177
|
+
.info-tip:focus-within .info-tip-popover {
|
|
178
|
+
opacity: 1;
|
|
179
|
+
visibility: visible;
|
|
180
|
+
pointer-events: auto;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/* Subdued source line + KB link inside an info-tip popover (used by the
|
|
184
|
+
step-annotation tooltips). Optional. */
|
|
185
|
+
.info-tip-source {
|
|
186
|
+
color: var(--text-secondary);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
.info-tip-link {
|
|
190
|
+
color: var(--primary-light);
|
|
191
|
+
text-decoration: none;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
.info-tip-link:hover {
|
|
195
|
+
text-decoration: underline;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/* Site footer */
|
|
199
|
+
.site-footer {
|
|
200
|
+
display: flex;
|
|
201
|
+
justify-content: center;
|
|
202
|
+
gap: var(--space-6);
|
|
203
|
+
padding: var(--space-6) var(--space-4);
|
|
204
|
+
border-top: 1px solid var(--border);
|
|
205
|
+
font-size: var(--text-xs);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
.site-footer a {
|
|
209
|
+
color: var(--text-secondary);
|
|
210
|
+
text-decoration: none;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
.site-footer a:hover {
|
|
214
|
+
color: var(--text-secondary);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
.site-footer-note {
|
|
218
|
+
color: var(--text-secondary);
|
|
219
|
+
font-style: italic;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/* Responsive helpers */
|
|
223
|
+
@media (max-width: 640px) {
|
|
224
|
+
#app {
|
|
225
|
+
padding: var(--space-4) var(--space-4);
|
|
226
|
+
}
|
|
227
|
+
}
|