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.