PageSourceSearch

https://agrar-antrag-helfer.onrender.com/rechnungen.js?v=84

js agrar-antrag-helfer.onrender.com collected 2026-10-03 11:39:09 UTC 17,268 bytes, 421 lines download raw bytes

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.