1// Rechnungen: neuer Bereich im Betriebsprofil zum Hochladen von 2// Eingangsrechnungen (siehe "Rechnungen" in index.html, POST/GET/DELETE 3// /api/stammdaten-dokumente mit kind=rechnung in server.js). Die KI liest 4// Rechnungssteller, Betrag, Datum und eine grobe Kategorie aus. Anders als 5// bei Maschinen/Versicherungen/Pachtverträgen gibt es keine "manuelle 6// Liste", in die das eingetragen wird â die hochgeladenen Dokumente SIND 7// hier die Liste, durchsuchbar/filterbar nach Jahr und Kategorie. 8// 9// Lädt nach bescheide.js (siehe index.html) und nutzt dessen bereits 10// vorhandene Konventionen sowie globale Funktionen aus script.js 11// (formatSavedDate, formatGermanDate, isReadOnlyRole) und net.js 12// (fetchWithTimeout, parseJsonSafely). 13 14const rechnungFileInput = document.getElementById("rechnung-file-input"); 15const rechnungUploadStatusEl = document.getElementById("rechnung-upload-status"); 16const rechnungenDropZone = document.getElementById("rechnung-drop-zone"); 17const rechnungenFilterBar = document.getElementById("rechnungen-filter-bar"); 18const rechnungenSearchInput = document.getElementById("rechnungen-search"); 19const rechnungenFilterJahr = document.getElementById("rechnungen-filter-jahr"); 20const rechnungenFilterKategorie = document.getElementById("rechnungen-filter-kategorie"); 21const rechnungenStatusEl = document.getElementById("rechnungen-status"); 22const rechnungenListEl = document.getElementById("rechnungen-list"); 23const rechnungReviewEl = document.getElementById("rechnung-review"); 24 25const RECHNUNG_KATEGORIE_LABELS = { 26 betriebsmittel: "Betriebsmittel", 27 maschinen: "Maschinen", 28 dienstleistung: "Dienstleistung", 29 sonstiges: "Sonstiges", 30}; 31 32// Felder, die POST /api/stammdaten-dokumente/vorschau (kind=rechnung) 33// liefert â siehe buildRechnungSchema/sanitizeStammdatenFelder in 34// server.js. Grundlage für die Erkennungs-Zusammenfassung ("X von Y 35// Angaben erkannt", siehe dokument-erkennung.js). 36const RECHNUNG_FELDER_DEFS = [ 37 { key: "rechnungssteller", label: "Rechnungssteller" }, 38 { key: "betrag", label: "Betrag" }, 39 { key: "datum", label: "Datum" }, 40 { key: "kategorie", label: "Kategorie" }, 41 // Wird u. a. als Belegfeld 1 im DATEV-Export für den Steuerberater 42 // verwendet (siehe datevExport.js) -- optional, da nicht jede Rechnung 43 // eine erkennbare Rechnungsnummer hat. 44 { key: "rechnungsnummer", label: "Rechnungsnummer" }, 45 // Einzelne Artikel/Zeilen bei einer mehrzeiligen Rechnung (siehe 46 // server.js: buildRechnungSchema, Feld "positionen") -- fehlt bei 47 // Rechnungen mit nur einem einzigen Posten, dann taucht dieses Feld in 48 // der "nicht automatisch erkannt"-Liste auf, was hier korrekt ist (siehe 49 // dokument-erkennung.js: istFeldErkannt -- leeres Array zählt als nicht 50 // erkannt). 51 { key: "positionen", label: "Einzelne Rechnungspositionen" }, 52]; 53 54// "Erkunden"-Modus (siehe dokument-erkennung.js: renderDemoErgebnis): 55// plausible Beispielwerte statt einer echten Auslese, wenn kein aktives 56// Abo/Trial vorliegt. 57const RECHNUNG_DEMO_FELDER = { 58 rechnungssteller: "Landtechnik Müller GmbH (Beispielwert)", 59 betrag: "1.284,50 ⬠(Beispielwert)", 60 datum: "12.03.2026 (Beispielwert)", 61 kategorie: "Betriebsmittel (Beispielwert)", 62 rechnungsnummer: "RE-2026-00427 (Beispielwert)", 63}; 64 65let allRechnungen = []; // zuletzt geladene Antwort von GET /api/stammdaten-dokumente?kind=rechnung 66 67async function loadRechnungen() { 68 rechnungenStatusEl.classList.add("hidden"); 69 rechnungenDropZone.classList.toggle("hidden", isReadOnlyRole()); 70 rechnungenListEl.innerHTML = '<p class="hint">Rechnungen werden geladen â¦</p>'; 71 72 try { 73 const response = await fetchWithTimeout( 74 "/api/stammdaten-dokumente?kind=rechnung", 75 { method: "GET" }, 76 APP_CONFIG.exportTimeoutMs, 77 ); 78 const data = await parseJsonSafely(response); 79 if (!response.ok) { 80 throw new Error((data && data.error) || "Rechnungen konnten nicht geladen werden."); 81 } 82 allRechnungen = Array.isArray(data) ? data : []; 83 setupRechnungenFilters(); 84 applyRechnungenFilters(); 85 setDatevExportDefaultRange();
86 loadDatevStammdaten(); 87 } catch (err) { 88 console.error(err); 89 rechnungenListEl.innerHTML = ""; 90 rechnungenStatusEl.textContent = err.message || "Rechnungen konnten nicht geladen werden."; 91 rechnungenStatusEl.classList.remove("hidden"); 92 } 93} 94 95// Bevorzugt das von der KI erkannte Rechnungsdatum, fällt sonst auf das 96// Hochlade-Datum zurück â so lässt sich auch eine Rechnung ohne erkanntes 97// Datum noch nach Jahr einordnen (statt aus der Jahresliste zu verschwinden). 98function getRechnungYear(rechnung) { 99 const felderDatum = rechnung.felder && rechnung.felder.datum; 100 const dateStr = felderDatum || rechnung.createdAt; 101 if (!dateStr) return null; 102 const d = new Date(felderDatum ? `${felderDatum}T00:00:00` : dateStr.replace(" ", "T") + "Z"); 103 return Number.isNaN(d.getTime()) ? null : d.getFullYear(); 104} 105 106function setupRechnungenFilters() { 107 const hasRechnungen = allRechnungen.length > 0; 108 rechnungenFilterBar.classList.toggle("hidden", !hasRechnungen); 109 if (!hasRechnungen) return; 110 111 const previousYear = rechnungenFilterJahr.value; 112 const years = Array.from(new Set(allRechnungen.map(getRechnungYear).filter((y) => y !== null))).sort( 113 (a, b) => b - a, 114 ); 115 rechnungenFilterJahr.innerHTML = '<option value="">Alle Jahre</option>'; 116 years.forEach((year) => { 117 const option = document.createElement("option"); 118 option.value = String(year); 119 option.textContent = String(year); 120 rechnungenFilterJahr.appendChild(option); 121 }); 122 if (years.map(String).includes(previousYear)) { 123 rechnungenFilterJahr.value = previousYear; 124 } 125 126 const previousKategorie = rechnungenFilterKategorie.value; 127 rechnungenFilterKategorie.innerHTML = '<option value="">Alle Kategorien</option>'; 128 Object.entries(RECHNUNG_KATEGORIE_LABELS).forEach(([key, label]) => { 129 const option = document.createElement("option"); 130 option.value = key; 131 option.textContent = label; 132 rechnungenFilterKategorie.appendChild(option); 133 }); 134 rechnungenFilterKategorie.value = previousKategorie; 135} 136 137// Live-Filterung ohne Neuladen, rein client-seitig über die bereits 138// geladene Liste â analog zu applyApplicationFilters in script.js. 139function applyRechnungenFilters() { 140 const searchTerm = rechnungenSearchInput.value.trim().toLowerCase(); 141 const yearFilter = rechnungenFilterJahr.value; 142 const kategorieFilter = rechnungenFilterKategorie.value; 143 144 const filtered = allRechnungen.filter((rechnung) => { 145 const felder = rechnung.felder || {}; 146 if (yearFilter && String(getRechnungYear(rechnung)) !== yearFilter) return false; 147 if (kategorieFilter && (felder.kategorie || "") !== kategorieFilter) return false; 148 if (searchTerm) { 149 const haystack = `${felder.rechnungssteller || ""} ${rechnung.originalFilename || ""}`.toLowerCase(); 150 if (!haystack.includes(searchTerm)) return false; 151 } 152 return true; 153 }); 154 155 renderRechnungenList(filtered); 156} 157 158rechnungenSearchInput.addEventListener("input", applyRechnungenFilters); 159rechnungenFilterJahr.addEventListener("change", applyRechnungenFilters); 160rechnungenFilterKategorie.addEventListener("change", applyRechnungenFilters); 161 162function renderRechnungenList(list) { 163 rechnungenListEl.innerHTML = ""; 164 165 if (list.length === 0) { 166 const empty = document.createElement("p"); 167 empty.className = "hint"; 168 empty.textContent = 169 allRechnungen.length === 0 170 ? "Noch keine Rechnungen hochgeladen." 171 : "Keine Rechnungen für diese Filterauswahl/Suche gefunden."; 172 rechnungenListEl.appendChild(empty); 173 return; 174 } 175 176 list.forEach((rechnung) => rechnungenListEl.appendChild(buildRechnungItem(rechnung))); 177} 178 179function buildRechnungItem(rechnung) { 180 const felder = rechnung.felder || {}; 181 const item = document.createElement("div"); 182 item.className = "bescheide-item"; 183 184 const header = document.createElement("div"); 185 header.className = "bescheide-item-header"; 186 187 const title = document.createElement("strong"); 188 title.textContent = felder.rechnungssteller || rechnung.originalFilename || "Rechnung"; 189 header.appendChild(title); 190 191 if (felder.kategorie && RECHNUNG_KATEGORIE_LABELS[felder.kategorie]) { 192 const badge = document.createElement("span"); 193 badge.className = "bescheide-art-badge"; 194 badge.textContent = RECHNUNG_KATEGORIE_LABELS[felder.kategorie]; 195 header.appendChild(badge); 196 } 197 198 item.appendChild(header); 199 200 const meta = document.createElement("div"); 201 meta.className = "bescheide-item-meta"; 202 const metaParts = []; 203 if (felder.betrag) metaParts.push(felder.betrag); 204 if (felder.datum) metaParts.push(formatGermanDate(new Date(`${felder.datum}T00:00:00`))); 205 metaParts.push(`hochgeladen ${formatSavedDate(rechnung.createdAt)}`); 206 meta.textContent = metaParts.join(" · "); 207 item.appendChild(meta); 208 209 if (!felder.rechnungssteller && !felder.betrag && !felder.datum && !felder.kategorie && !felder.rechnungsnummer) { 210 const hint = document.createElement("p"); 211 hint.className = "bescheide-item-summary"; 212 hint.textContent = "Keine automatische Auswertung verfügbar â bitte die Rechnung öffnen und Angaben bei Bedarf selbst notieren."; 213 item.appendChild(hint); 214 } 215 216 if (rechnung.kiHinweis) { 217 const hinweis = document.createElement("p"); 218 hinweis.className = "hint"; 219 hinweis.textContent = rechnung.kiHinweis; 220 item.appendChild(hinweis); 221 } 222 223 const actions = document.createElement("div"); 224 actions.className = "bescheide-item-actions"; 225 226 if (rechnung.hasFile) { 227 const fileLink = document.createElement("a");
228 fileLink.href = `/api/stammdaten-dokumente/${rechnung.id}/file`; 229 fileLink.target = "_blank"; 230 fileLink.rel = "noopener"; 231 fileLink.className = "application-action-button"; 232 fileLink.textContent = "Rechnung ansehen"; 233 actions.appendChild(fileLink); 234 } 235 236 if (!isReadOnlyRole()) { 237 const deleteButton = document.createElement("button"); 238 deleteButton.type = "button"; 239 deleteButton.className = "application-action-button application-delete-button"; 240 deleteButton.textContent = "Löschen"; 241 deleteButton.addEventListener("click", () => deleteRechnung(rechnung.id)); 242 actions.appendChild(deleteButton); 243 } 244 245 item.appendChild(actions); 246 return item; 247} 248 249async function deleteRechnung(id) { 250 if (!window.confirm("Diese Rechnung wirklich löschen? Die hochgeladene Datei wird dabei ebenfalls entfernt.")) { 251 return; 252 } 253 try { 254 const response = await fetchWithTimeout(`/api/stammdaten-dokumente/${id}`, { method: "DELETE" }, APP_CONFIG.exportTimeoutMs); 255 const data = await parseJsonSafely(response); 256 if (!response.ok) { 257 throw new Error((data && data.error) || "Rechnung konnte nicht gelöscht werden."); 258 } 259 await loadRechnungen(); 260 } catch (err) { 261 console.error(err); 262 window.alert(err.message || "Rechnung konnte nicht gelöscht werden."); 263 } 264} 265 266// --- Hochladen --------------------------------------------------------- 267 268rechnungFileInput.addEventListener("change", () => { 269 const file = rechnungFileInput.files[0]; 270 rechnungFileInput.value = ""; 271 if (file) { 272 uploadRechnung(file); 273 } 274}); 275 276async function uploadRechnung(file) { 277 await starteDokumentErkennungsAblauf({ 278 file, 279 vorschauUrl: "/api/stammdaten-dokumente/vorschau", 280 saveUrl: "/api/stammdaten-dokumente", 281 extraFields: { kind: "rechnung" }, 282 felderDefs: RECHNUNG_FELDER_DEFS, 283 demoFelder: RECHNUNG_DEMO_FELDER, 284 reviewContainerEl: rechnungReviewEl, 285 statusEl: rechnungUploadStatusEl, 286 fehlermeldungFallback: "Rechnung konnte nicht hochgeladen werden.", 287 triggerEl: rechnungFileInput, 288 onSaved: async () => { 289 await loadRechnungen(); 290 }, 291 }); 292} 293 294// --- DATEV-Export für den Steuerberater --------------------------------- 295// Zwei Bausteine (siehe GET/PUT /api/datev/stammdaten, GET /api/datev/export 296// in server.js): einmalige Stammdaten je Betrieb (Berater-/Mandantennummer, 297// Kontenrahmen) und der eigentliche Export als ZIP-Download. Läuft wie der 298// Rest der App über fetchWithTimeout â ein 402 (kein aktives Abo, z. B. im 299// Erkunden-Modus) zeigt dadurch automatisch den zentralen Abo-Hinweis-Banner 300// (siehe net.js), ohne dass hier gesondert danach gesucht werden muss. 301 302const datevStammdatenForm = document.getElementById("datev-stammdaten-form"); 303const datevBeraternummerInput = document.getElementById("datev-beraternummer"); 304const datevMandantennummerInput = document.getElementById("datev-mandantennummer"); 305const datevKontenrahmenSelect = document.getElementById("datev-kontenrahmen"); 306const datevStammdatenErrorEl = document.getElementById("datev-stammdaten-error"); 307const datevStammdatenStatusEl = document.getElementById("datev-stammdaten-status"); 308const datevStammdatenSaveButton = document.getElementById("datev-stammdaten-save-button"); 309 310const datevExportForm = document.getElementById("datev-export-form"); 311const datevExportVonInput = document.getElementById("datev-export-von"); 312const datevExportBisInput = document.getElementById("datev-export-bis"); 313const datevExportErrorEl = document.getElementById("datev-export-error"); 314const datevExportButton = document.getElementById("datev-export-button"); 315 316// Sinnvoller Vorschlag als Startwert: laufendes Kalenderjahr bis heute â 317// per Datumsfeld jederzeit änderbar. 318function setDatevExportDefaultRange() { 319 const today = new Date(); 320 const jahresanfang = `${today.getFullYear()}-01-01`; 321 const heute = today.toISOString().slice(0, 10); 322 if (!datevExportVonInput.value) datevExportVonInput.value = jahresanfang; 323 if (!datevExportBisInput.value) datevExportBisInput.value = heute; 324} 325 326async function loadDatevStammdaten() { 327 datevStammdatenSaveButton.disabled = isReadOnlyRole(); 328 try { 329 const response = await fetchWithTimeout("/api/datev/stammdaten", { method: "GET" }, APP_CONFIG.exportTimeoutMs); 330 const data = await parseJsonSafely(response); 331 if (!response.ok) return; 332 datevBeraternummerInput.value = data.beraternummer || ""; 333 datevMandantennummerInput.value = data.mandantennummer || ""; 334 if (data.kontenrahmen) datevKontenrahmenSelect.value = data.kontenrahmen; 335 } catch (err) { 336 console.error(err); 337 } 338} 339 340datevStammdatenForm.addEventListener("submit", async (event) => { 341 event.preventDefault(); 342 datevStammdatenErrorEl.classList.add("hidden"); 343 datevStammdatenStatusEl.classList.add("hidden"); 344 datevStammdatenSaveButton.disabled = true; 345 346 try { 347 const response = await fetchWithTimeout( 348 "/api/datev/stammdaten", 349 { 350 method: "PUT", 351 headers: { "Content-Type": "application/json" }, 352 body: JSON.stringify({ 353 beraternummer: datevBeraternummerInput.value.trim(), 354 mandantennummer: datevMandantennummerInput.value.trim(), 355 kontenrahmen: datevKontenrahmenSelect.value, 356 }), 357 }, 358 APP_CONFIG.exportTimeoutMs, 359 ); 360 const data = await parseJsonSafely(response); 361 if (!response.ok) { 362 throw new Error((data && data.error) || "DATEV-Stammdaten konnten nicht gespeichert werden."); 363 } 364 datevStammdatenStatusEl.textContent = "DATEV-Stammdaten gespeichert."; 365 datevStammdatenStatusEl.classList.remove("hidden"); 366 } catch (err) { 367 console.error(err); 368 datevStammdatenErrorEl.textContent = err.message || "DATEV-Stammdaten konnten nicht gespeichert werden."; 369 datevStammdatenErrorEl.classList.remove("hidden"); 370 } finally { 371 datevStammdatenSaveButton.disabled = isReadOnlyRole(); 372 } 373}); 374 375datevExportForm.addEventListener("submit", async (event) => { 376 event.preventDefault(); 377 datevExportErrorEl.classList.add("hidden"); 378 379 const von = datevExportVonInput.value; 380 const bis = datevExportBisInput.value; 381 if (!von || !bis || von > bis) { 382 datevExportErrorEl.textContent = "Bitte wählen Sie einen gültigen Zeitraum (Von-Datum darf nicht nach dem Bis-Datum liegen)."; 383 datevExportErrorEl.classList.remove("hidden"); 384 return; 385 } 386 387 datevExportButton.disabled = true; 388 datevExportButton.textContent = "Export wird erstellt â¦"; 389 try { 390 const response = await fetchWithTimeout( 391 `/api/datev/export?von=${encodeURIComponent(von)}&bis=${encodeURIComponent(bis)}`, 392 { method: "GET" }, 393 APP_CONFIG.exportTimeoutMs, 394 ); 395 if (!response.ok) { 396 const data = await parseJsonSafely(response); 397 throw new Error((data && data.error) || "Der DATEV-Export konnte nicht erstellt werden."); 398 } 399 400 const disposition = response.headers.get("Content-Disposition") || ""; 401 const match = disposition.match(/filename="?([^"]+)"?/); 402 const filename = match ? match[1] : `datev_export_${von}_bis_${bis}.zip`; 403 404 const blob = await response.blob(); 405 const url = URL.createObjectURL(blob); 406 const link = document.createElement("a"); 407 link.href = url; 408 link.download = filename; 409 document.body.appendChild(link); 410 link.click(); 411 link.remove(); 412 URL.revokeObjectURL(url); 413 } catch (err) { 414 console.error(err); 415 datevExportErrorEl.textContent = err.message || "Der DATEV-Export konnte nicht erstellt werden."; 416 datevExportErrorEl.classList.remove("hidden"); 417 } finally { 418 datevExportButton.disabled = false;
419 datevExportButton.textContent = "Für Steuerberater exportieren"; 420 } 421});
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.