PageSourceSearch

https://lamuria.de/assets/js/viewer-ui.js

js lamuria.de collected 2026-10-02 01:51:48 UTC 13,046 bytes, 365 lines download raw bytes

1// viewer-ui.js - Seiten-Logik des E-Rechnungs-Viewers (/anzeigen/).
2//
3// Datei laden (Auswahl oder Drag & Drop) -> Format erkennen -> lesbarer
4// Beleg im Browser. Rechnungsinhalte bleiben lokal; Anzeigen ist aktuell
5// unbegrenzt kostenlos. pdf-lib (~1 MB) wird nur geladen, wenn tatsächlich
6// ein PDF hereinkommt (dynamic import beim Bedarf).
7//
8// Sicherheit: Alle Dateiinhalte gelten als nicht vertrauenswürdig und
9// werden ausschließlich über textContent ins DOM geschrieben (kein
10// innerHTML mit Daten -> kein XSS über präparierte Rechnungen).
11
12import { formatAmountDe, formatQuantity } from "./money.js";
13import { unitLabel } from "./units.js";
14import { extractXmlFromPdf, parseInvoiceXml } from "./viewer.js";
15
16const $ = (sel) => document.querySelector(sel);
17
18// --- Formatierung -----------------------------------------------------------
19
20function fmtAmount(value, currency) {
21  if (!Number.isFinite(value)) return "\u2013"; // Halbgeviertstrich
22  return `${formatAmountDe(value)}\u00a0${currency || "EUR"}`;
23}
24
25function fmtQty(value) {
26  if (!Number.isFinite(value)) return "\u2013";
27  return formatQuantity(value).replace(".", ",");
28}
29
30function fmtDate(iso) {
31  const v = String(iso ?? "").trim();
32  if (!/^\d{4}-\d{2}-\d{2}$/.test(v)) return v;
33  const [y, m, d] = v.split("-");
34  return `${d}.${m}.${y}`;
35}
36
37const TYPE_CODES = new Map([
38  ["380", "Rechnung"],
39  ["381", "Gutschrift"],
40  ["384", "Rechnungskorrektur"],
41  ["389", "Selbstfakturierte Rechnung"],
42  ["326", "Teilrechnung"],
43  ["875", "Abschlagsrechnung (Bau)"],
44  ["877", "Schlussrechnung (Bau)"],
45]);
46
47function docTypeLabel(typeCode) {
48  return TYPE_CODES.get(String(typeCode).trim()) || `Beleg (Typ ${typeCode})`;
49}
50
51/** Steuerzeilen-Label - generisch, ohne Annahmen über fremde Dateien. */
52function taxLabel(t) {
53  const rate = Number.isFinite(t.rate) ? t.rate : 0;
54  switch (t.categoryCode) {
55    case "S":
56    case "Z":
57      return `Umsatzsteuer ${rate}\u00a0%`;
58    case "E":
59      return "Umsatzsteuer 0\u00a0% (steuerbefreit)";
60    case "AE":
61      return "Umsatzsteuer 0\u00a0% (Reverse Charge)";
62    case "K":
63      return "Umsatzsteuer 0\u00a0% (innergemeinschaftlich)";
64    case "G":
65      return "Umsatzsteuer 0\u00a0% (Ausfuhr)";
66    case "O":
67      return "Nicht umsatzsteuerbar";
68    default:
69      return `Umsatzsteuer ${rate}\u00a0% (Kategorie ${t.categoryCode || "?"})`;
70  }
71}
72
73// --- DOM-Bau (XSS-sicher über textContent) -----------------------------------
74
75function el(tag, className, text) {
76  const node = document.createElement(tag);
77  if (className) node.className = className;
78  if (text != null && text !== "") node.textContent = text;
79  return node;
80}
81
82function row(label, value, opts = {}) {
83  const r = el("div", `v-row${opts.strong ? " strong" : ""}${opts.total ? " total" : ""}`);
84  r.append(el("span", "v-label", label), el("span", "v-value", value));
85  return r;
86}
87
88function partyLines(p) {
89  const lines = [];
90  if (p.name) lines.push(p.name);
91  if (p.street) lines.push(p.street);
92  const cityLine = [p.postalCode, p.city].filter(Boolean).join(" ");
93  if (cityLine) lines.push(cityLine);
94  if (p.countryCode && p.countryCode !== "DE") lines.push(p.countryCode);
95  if (p.vatId) lines.push(`USt-IdNr.: ${p.vatId}`);
96  if (p.taxNumber) lines.push(`Steuernummer: ${p.taxNumber}`);
97  return lines;
98}
99
100/** Baut die komplette Beleg-Ansicht aus dem Viewer-Datenmodell. */
101function renderInvoice(m, sourceInfo) {
102  const out = $("#viewer-result");
103  out.textContent = ""; // reset
104
105  // Format-/Quellenzeile
106  const meta = el("div", "v-source");
107  meta.append(el("span", "v-badge", m.format));
108  if (m.profile) meta.append(el("span", "v-profile", m.profile));
109  if (sourceInfo) meta.append(el("span", "v-file", sourceInfo));
110  out.append(meta);
111
112  const sheet = el("article", "v-sheet");
113  out.append(sheet);
114
115  // Kopf
116  const head = el("header", "v-head");
117  const sellerBox = el("div", "v-seller");
118  sellerBox.append(el("strong", null, m.seller.name || "(Verkäufer unbenannt)"));
119  const sellerAddr = [m.seller.street, [m.seller.postalCode, m.seller.city].filter(Boolean).join(" ")]
120    .filter(Boolean)
121    .join(" \u00b7 ");
122  if (sellerAddr) sellerBox.append(el("div", "v-muted", sellerAddr));
123  const titleBox = el("div", "v-doctitle");
124  titleBox.append(el("strong", null, docTypeLabel(m.typeCode)));
125  if (m.invoiceNumber) titleBox.append(el("div", "v-muted", `Nr. ${m.invoiceNumber}`));
126  head.append(sellerBox, titleBox);
127  sheet.append(head);
128
129  // Empfänger + Metadaten
130  const grid = el("div", "v-grid");
131  const buyerBox = el("div");
132  buyerBox.append(el("h3", null, "Rechnungsempfänger"));
133  for (const line of partyLines(m.buyer)) buyerBox.append(el("div", null, line));
134  if (m.buyer.email) buyerBox.append(el("div", "v-muted", m.buyer.email));
135  const metaBox = el("div");
136  metaBox.append(el("h3", null, "Angaben"));
137  const metaRows = [
138    ["Rechnungsdatum", fmtDate(m.issueDate)],
139    m.dueDate ? ["Fällig am", fmtDate(m.dueDate)] : null,
140    m.buyerReference ? ["Käuferreferenz", m.buyerReference] : null,
141    m.deliveryDate ? ["Leistungsdatum", fmtDate(m.deliveryDate)] : null,
142    m.periodStart || m.periodEnd
143      ? ["Leistungszeitraum", `${fmtDate(m.periodStart)} \u2013 ${fmtDate(m.periodEnd)}`]
144      : null,
145    m.currency ? ["Währung", m.currency] : null,
146  ].filter(Boolean);
147  for (const [label, value] of metaRows) metaBox.append(row(label, value));
148  grid.append(buyerBox, metaBox);
149  sheet.append(grid);
150
151  // Positionen
152  if (m.lines.length > 0) {
153    const table = el("table", "v-lines");
154    const thead = el("thead");
155    const hr = el("tr");
156    for (const [label, cls] of [
157      ["Pos.", null],
158      ["Bezeichnung", null],
159      ["Menge", "num"],
160      ["Einheit", null],
161      ["Einzelpreis", "num"],
162      ["USt.", "num"],
163      ["Netto", "num"],
164    ]) {
165      hr.append(el("th", cls, label));
166    }
167    thead.append(hr);
168    table.append(thead);
169    const tbody = el("tbody");
170    m.lines.forEach((line, i) => {
171      const tr = el("tr");
172      tr.append(el("td", null, line.id || String(i + 1)));
173      const descTd = el("td", "desc", line.description || "(ohne Bezeichnung)");
174      // BG-27/BG-28: Zeilen-Nachlässe/-Zuschläge unter der Bezeichnung
175      for (const a of line.allowances || []) {
176        if (!Number.isFinite(a.amount)) continue;
177        const sign = a.isCharge ? "zzgl." : "abzgl.";
178        const prefix = a.isCharge ? "+" : "\u2212";
179        descTd.append(
180          el(
181            "div",
182            "v-line-note",
183            `${sign} ${a.reason || (a.isCharge ? "Zuschlag" : "Nachlass")}: ${prefix}${formatAmountDe(a.amount)}\u00a0${m.currency || "EUR"}`
184          )
185        );
186      }
187      tr.append(descTd);
188      tr.append(el("td", "num", fmtQty(line.quantity)));
189      tr.append(el("td", null, unitLabel(line.unitCode)));
190      tr.append(el("td", "num", fmtAmount(line.unitPrice, m.currency)));
191      tr.append(el("td", "num", Number.isFinite(line.rate) ? `${line.rate}\u00a0%` : "\u2013"));
192      tr.append(el("td", "num", fmtAmount(line.net, m.currency)));
193      tbody.append(tr);
194    });
195    table.append(tbody);
196    sheet.append(table);
197  }
198
199  // Summenblock
200  const sums = el("div", "v-sums");
201  const hasAllowance = m.allowances.length > 0 || Number.isFinite(m.totals.allowanceTotal);
202  const hasCharge = m.charges.length > 0 || Number.isFinite(m.totals.chargeTotal);
203  if (hasAllowance || hasCharge) {
204    sums.append(row("Summe Positionen", fmtAmount(m.totals.lineTotal, m.currency)));
205    for (const a of m.allowances) {
206      sums.append(
207        row(a.reason || "Nachlass", `\u2212${formatAmountDe(a.amount)}\u00a0${m.currency || "EUR"}`)
208      );
209    }
210    for (const c of m.charges) {
211      sums.append(row(c.reason || "Zuschlag", fmtAmount(c.amount, m.currency)));
212    }
213  }
214  sums.append(row("Summe netto", fmtAmount(m.totals.taxExclusive, m.currency)));
215  for (const t of m.taxBreakdown) {
216    sums.append(row(taxLabel(t), fmtAmount(t.tax, m.currency)));
217  }
218  if (Number.isFinite(m.totals.prepaid) && m.totals.prepaid > 0) {
219    sums.append(row("Bruttobetrag", fmtAmount(m.totals.taxInclusive, m.currency)));
220    sums.append(
221      row("Bereits gezahlt", `\u2212${formatAmountDe(m.totals.prepaid)}\u00a0${m.currency || "EUR"}`)
222    );
223  }
224  sums.append(row("Zahlbetrag", fmtAmount(m.totals.payable, m.currency), { strong: true, total: true }));
225  sheet.append(sums);
226
227  // Befreiungstexte + Notiz
228  const notes = [];
229  for (const t of m.taxBreakdown) {
230    if (t.exemptionReason) notes.push(t.exemptionReason);
231  }
232  if (m.note) notes.push(m.note);
233  if (notes.length > 0) {
234    const noteBox = el("div", "v-notes");
235    for (const n of notes) noteBox.append(el("p", null, n));
236    sheet.append(noteBox);
237  }
238
239  // Zahlung + Kontakt
240  const foot = el("div", "v-grid");
241  const payBox = el("div");
242  payBox.append(el("h3", null, "Zahlung"));
243  if (m.payment.terms) payBox.append(el("div", null, m.payment.terms));
244  if (m.payment.iban) payBox.append(el("div", null, `IBAN: ${m.payment.iban}`));
245  if (m.payment.bic) payBox.append(el("div", null, `BIC: ${m.payment.bic}`));
246  if (m.payment.accountName) payBox.append(el("div", null, `Kontoinhaber: ${m.payment.accountName}`));
247  if (!m.payment.iban && !m.payment.terms) payBox.append(el("div", "v-muted", "Keine Angaben"));
248  const contactBox = el("div");
249  contactBox.append(el("h3", null, "Kontakt Rechnungssteller"));
250  const contactLines = [
251    m.seller.contactName,
252    m.seller.contactPhone,
253    m.seller.contactEmail || m.seller.email,
254  ].filter(Boolean);
255  for (const line of contactLines) contactBox.append(el("div", null, line));
256  if (contactLines.length === 0) contactBox.append(el("div", "v-muted", "Keine Angaben"));
257  foot.append(payBox, contactBox);
258  sheet.append(foot);
259
260  // Ansicht umschalten
261  $("#viewer-intro").hidden = true;
262  $("#viewer-output").hidden = false;
263  document.title = `${docTypeLabel(m.typeCode)} ${m.invoiceNumber}
263 \u2013 E-Rechnung anzeigen | Lamuria`;
264}
265
266// --- Datei-Handling -----------------------------------------------------------
267
268function showError(message) {
269  const box = $("#viewer-error");
270  box.textContent = message;
271  box.hidden = false;
272}
273
274function clearError() {
275  const box = $("#viewer-error");
276  box.textContent = "";
277  box.hidden = true;
278}
279
280function setBusy(message) {
281  const status = $("#viewer-status");
282  status.textContent = message ?? "";
283  status.hidden = !message;
284}
285
286async function handleFile(file) {
287  clearError();
288  if (!file) return;
289  if (file.size > 30 * 1024 * 1024) {
290    showError("Datei ist größer als 30 MB - das ist keine übliche E-Rechnung.");
291    return;
292  }
293  try {
294    const bytes = new Uint8Array(await file.arrayBuffer());
295    const head = String.fromCharCode(...bytes.slice(0, 5));
296    let model;
297    let sourceInfo = file.name;
298    if (head === "%PDF-") {
299      setBusy("PDF-Komponenten werden geladen \u2026");
300      const pdfLib = await import("../vendor/pdf-lib.esm.min.js");
301      setBusy("Eingebettetes XML wird gelesen \u2026");
302      const { xml, embeddedFileName } = await extractXmlFromPdf(bytes, pdfLib);
303      model = parseInvoiceXml(xml);
304      sourceInfo = `${file.name} \u2192 ${embeddedFileName}`;
305    } else {
306      const text = new TextDecoder("utf-8").decode(bytes).replace(/^\uFEFF/, "");
307      model = parseInvoiceXml(text);
308    }
309    renderInvoice(model, sourceInfo);
310  } catch (e) {
311    console.error("Viewer:", e);
312    showError(e && e.message ? e.message : "Datei konnte nicht gelesen werden.");
313  } finally {
314    setBusy("");
315  }
316}
317
318function resetViewer() {
319  clearError();
320  $("#viewer-output").hidden = true;
321  $("#viewer-result").textContent = "";
322  $("#viewer-intro").hidden = false;
323  $("#file-input").value = "";
324  document.title = $("#viewer-intro").dataset.pageTitle || document.title;
325}
326
327// --- Verdrahtung ---------------------------------------------------------------
328
329const dropZone = $("#drop-zone");
330const fileInput = $("#file-input");
331
332$("#viewer-intro").dataset.pageTitle = document.title;
333
334fileInput.addEventListener("change", () => handleFile(fileInput.files[0]));
335
336["dragover", "dragenter"].forEach((eventName) => {
337  dropZone.addEventListener(eventName, (event) => {
338    event.preventDefault();
339    dropZone.classList.add("dragging");
340  });
341});
342["dragleave", "drop"].forEach((eventName) => {
343  dropZone.addEventListener(eventName, (event) => {
344    event.preventDefault();
345    dropZone.classList.remove("dragging");
346  });
347});
348dropZone.addEventListener("drop", (event) => {
349  const file = event.dataTransfer?.files?.[0];
350  handleFile(file);
351});
352// Klick/Enter auf die Zone öffnet den Dateidialog
353dropZone.addEventListener("click", (event) => {
354  if (event.target.closest("label")) return; // Label triggert das Input selbst
355  fileInput.click();
356});
357dropZone.addEventListener("keydown", (event) => {
358  if (event.key === "Enter" || event.key === " ") {
359    event.preventDefault();
360    fileInput.click();
361  }
362});
363
364$("#print-view").addEventListener("click", () => window.print());
365$("#load-other").addEventListener("click", resetViewer);

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.