PageSourceSearch

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

js agrar-antrag-helfer.onrender.com collected 2026-10-03 11:39:01 UTC 116,117 bytes, 2,762 lines download raw bytes

1const typeSection = document.getElementById("type-section");
2const typeSelector = document.getElementById("type-selector");
3const uploadSection = document.getElementById("upload-section");
4const fileInput = document.getElementById("file-input");
5const dropZone = document.getElementById("drop-zone");
6const fileNameEl = document.getElementById("file-name");
7const uploadErrorEl = document.getElementById("upload-error");
8const previewSection = document.getElementById("preview-section");
9const filePreview = document.getElementById("file-preview");
10const extractionFieldsWrapper = document.getElementById("extraction-fields-wrapper");
11const extractionStatus = document.getElementById("extraction-status");
12const previewForm = document.getElementById("preview-form");
13const fieldsContainer = document.getElementById("fields-container");
14const submitButton = document.getElementById("submit-button");
15const saveSuccessBanner = document.getElementById("save-success-banner");
16const uploadProgressEl = document.getElementById("upload-progress");
17const documentsQueueSection = document.getElementById("documents-queue-section");
18const documentsQueueList = document.getElementById("documents-queue-list");
19const exportSection = document.getElementById("export-section");
20const exportToggleButton = document.getElementById("export-toggle-button");
21const exportOptions = document.getElementById("export-options");
22const exportPdfButton = document.getElementById("export-pdf-button");
23const exportTextButton = document.getElementById("export-text-button");
24const exportStatus = document.getElementById("export-status");
25const applicationsSection = document.getElementById("applications-section");
26const applicationsList = document.getElementById("applications-list");
27const applicationsFilterBar = document.getElementById("applications-filter-bar");
28const applicationsSearchInput = document.getElementById("applications-search");
29const applicationsFilterType = document.getElementById("applications-filter-type");
30const applicationsFilterYear = document.getElementById("applications-filter-year");
31const applicationsFilterBetriebField = document.getElementById("applications-filter-betrieb-field");
32const applicationsFilterBetrieb = document.getElementById("applications-filter-betrieb");
33const applicationsFilterStatus = document.getElementById("applications-filter-status");
34const psmExportSection = document.getElementById("psm-export-section");
35const psmExportVonInput = document.getElementById("psm-export-von");
36const psmExportBisInput = document.getElementById("psm-export-bis");
37const psmExportFlaecheInput = document.getElementById("psm-export-flaeche");
38const psmExportErrorEl = document.getElementById("psm-export-error");
39const psmExportXlsxButton = document.getElementById("psm-export-xlsx-button");
40const psmExportCsvButton = document.getElementById("psm-export-csv-button");
41const sbkExportSection = document.getElementById("sbk-export-section");
42const sbkExportVonInput = document.getElementById("sbk-export-von");
43const sbkExportBisInput = document.getElementById("sbk-export-bis");
44const sbkExportErrorEl = document.getElementById("sbk-export-error");
45const sbkExportXlsxButton = document.getElementById("sbk-export-xlsx-button");
46const sbkExportCsvButton = document.getElementById("sbk-export-csv-button");
47const duengungHintEl = document.getElementById("duengung-hint");
48const duengungHintTextEl = document.getElementById("duengung-hint-text");
49const psmVorschlagPanel = document.getElementById("psm-vorschlag-panel");
50const psmVorschlagButton = document.getElementById("psm-vorschlag-button");
51const psmVorschlagStatusEl = document.getElementById("psm-vorschlag-status");
52const psmVorschlagPreviewEl = document.getElementById("psm-vorschlag-preview");
53const submissionNoticeEl = document.getElementById("submission-notice");
54const extractionSummaryEl = document.getElementById("extraction-summary");
55const extractionBestaetigungContainer = document.getElementById("extraction-bestaetigung-container");
56const errorHelperSection = document.getElementById("error-helper-section");
57const fristenListEl = document.getElementById("fristen-list");
58const errorHelperInput = document.getElementById("error-helper-input");
59const errorHelperSubmitButton = document.getElementById("error-helper-submit");
60const errorHelperStatus = document.getElementById("error-helper-status");
61const errorHelperResult = document.getElementById("error-helper-result");
62const errorHelperExplanation = document.getElementById("error-helper-explanation");
63const checklistJumpButton = document.getElementById("checklist-jump-button");
64const sbkEintragPanel = document.getElementById("sbk-eintrag-panel");
65const sbkDatumInput = document.getElementById("sbk-datum");
66const sbkKoederstelleInput = document.getElementById("sbk-koederstelle");
67const sbkArtSelect = document.getElementById("sbk-art");
68const sbkSchaedlingInput = document.getElementById("sbk-schaedling");
69const sbkBeobachtungInput = document.getElementById("sbk-beobachtung");
70const sbkKoederMittelInput = document.getElementById("sbk-koeder-mittel");
71const sbkVerbrauchInput = document.getElementById("sbk-verbrauch");
72const sbkAnwenderInput = document.getElementById("sbk-anwender");
73const sbkBestaetigtCheckbox = document.getElementById("sbk-bestaetigt");
74const sbkHinzufuegenButton = document.getElementById("sbk-eintrag-hinzufuegen-button");
75const sbkEintragStatusEl = document.getElementById("sbk-eintrag-status");
76const sbkStatusUebersichtEl = document.getElementById("sbk-status-uebersicht");
77const sbkEintraegeListeEl = document.getElementById("sbk-eintraege-liste");
78
79const ALLOWED_MIME_TYPES = new Set(APP_CONFIG.allowedMimeTypes);
80const MAX_FILE_SIZE_MB = Math.floor(APP_CONFIG.maxFileSizeBytes / (1024 * 1024));
81
82let selectedType = null;
83let hasUploadedDocument = false;
84// The File/Blob behind the current preview, so "Angaben übernehmen" can send
85// the original document along when saving the application.
86let currentFile = null;
87// Der ursprüngliche Dateiname zu currentFile — separat gehalten, weil ein
88// wieder geöffneter gespeicherter Antrag (siehe openApplication) die Datei
89// als Blob nachlädt, und Blob (anders als File) keine .name-Eigenschaft
90// hat. Ohne das würde erneutes Speichern den Dateinamen auf "dokument"
91// zurücksetzen.
92let currentFileName = null;
93// Wenn ein bereits gespeicherter Antrag geöffnet wurde (siehe
94// openApplication), verweist das auf dessen id — "Angaben übernehmen"
95// aktualisiert dann diesen Antrag (PUT), statt einen neuen anzulegen
96// (POST). null bedeutet: es wird ein neuer Antrag gespeichert.
97let currentApplicationId = null;
98
99// Warteschlange für Mehrfach-Upload: ein Eintrag pro ausgewählter/
100// gedroppter Datei dieser Sitzung, unabhängig davon, ob schon geöffnet oder
101// gespeichert. Jedes Dokument wird einzeln analysiert und nie mit einem
102// anderen vermischt — die Vorschau (Schritt 3) zeigt jeweils nur EIN
103// Dokument gleichzeitig, ausgewählt über currentQueueDocId.
104let documentQueue = [];
105let nextDocLocalId = 1;
106let currentQueueDocId = null;
107
108// Ungefilterte Liste aus dem letzten GET /api/applications — Grundlage für
109// die client-seitige Live-Filterung (siehe applyApplicationFilters). Die
110// Betrieb-Dimension wird hier NICHT mitgefiltert: der Server liefert je
111// Aufruf ohnehin nur den aktiven Betrieb, daher wechselt die Betrieb-Auswahl
112// im Filter tatsächlich den aktiven Betrieb (siehe applicationsFilterBetrieb
113// weiter unten) statt diese Liste zu filtern.
114let allApplications = [];
115
116// fetchWithTimeout() and parseJsonSafely() come from net.js.
117
118// --- File validation ---------------------------------------------------
119
120function validateFile(file) {
121  if (!ALLOWED_MIME_TYPES.has(file.type)) {
122    return `Dieser Dateityp wird nicht unterstützt. Bitte laden Sie ${APP_CONFIG.allowedFileTypesLabel} hoch.`;
123  }
124  if (file.size === 0) {
125    return "Die Datei ist leer oder beschädigt. Bitte wählen Sie eine andere Datei.";
126  }
127  if (file.size > APP_CONFIG.maxFileSizeBytes) {
128    return `Die Datei ist zu groß. Bitte laden Sie eine Datei bis maximal ${MAX_FILE_SIZE_MB} MB hoch.`;
129  }
130  return null;
131}
132
133function showUploadError(message) {
134  uploadErrorEl.textContent = message;
135  uploadErrorEl.classList.remove("hidden");
136}
137
138function hideUploadError() {
139  uploadErrorEl.classList.add("hidden");
140}
141
142// --- Field rendering ---------------------------------------------------
143
144function renderFields(typeKey) {
145  const config = ANTRAGSTYPEN[typeKey];
146  fieldsContainer.innerHTML = "";
147
148  // Der Mehrfachantrag läuft über unterschiedliche Landesportale (FIONA,
149  // iBALIS, ...) — Portalname und Registerhinweise hängen daher vom
150  // Bundesland des aktiven Betriebs ab. Tierhaltungskennzeichnung,
151  // Weinbaukartei und Bienenseuchenkasse haben zwar keine unterschiedlichen
152  // Register-Begriffe, aber je Bundesland eine andere zuständige Stelle
153  // bzw. einen anderen Meldetermin (siehe TIERHALTUNGSKENNZEICHNUNG_
154  // MELDESTELLEN/WEINBAUKARTEI_MELDETERMINE/BIENENSEUCHENKASSE_MELDETERMINE
155  // in antragstypen.js) — daher nur der Hinweistext, nicht die Feld-Labels,
156  // bundeslandabhängig. Bei den übrigen Antragstypen (SVLFG, DüngeVO, HIT,
157  // Nebentätigkeiten, Ausgleichszulage) gilt bundesweit dieselbe Struktur,
158  // dort bleibt es beim statischen submissionNotice/register aus
159  // antragstypen.js.
160  const bundesland = window.currentBetrieb ? window.currentBetrieb.bundesland : null;
161  const portalInfo = typeKey === "mehrfachantrag" ? getMehrfachantragPortalInfo(bundesland) : null;
162  if (typeKey === "mehrfachantrag") {
163    submissionNoticeEl.textContent = getMehrfachantragSubmissionNotice(bundesland);
164  } else if (typeKey === "tierhaltungskennzeichnung") {
165    submissionNoticeEl.textContent = getTierhaltungskennzeichnungSubmissionNotice(bundesland);
166  } else if (typeKey === "weinbaukartei") {
167    submissionNoticeEl.textContent = getWeinbaukarteiSubmissionNotice(bundesland);
168  } else if (typeKey === "bienenseuchenkasse") {
169    submissionNoticeEl.textContent = getBienenseuchenkasseSubmissionNotice(bundesland);
170  } else {
171    submissionNoticeEl.textContent = config.submissionNotice || "";
172  }
173
174  config.fields.forEach((field) => {
175    // "Interne" Felder wie "dosierungVorschlagKi" (siehe antragstypen.js)
176    // dienen nur dem späteren Plausibilitätsabgleich und werden NIE
177    // angezeigt — trotzdem als echtes <input> im DOM, damit die generische
178    // Sammel-/Befüll-Logik (getFilledValues/applyExtractedValues, die
179    // schlicht document.getElementById(field.key) nutzt) unverändert
180    // funktioniert, ohne diese Felder je in Formular, Export oder Warnungen
181    // auftauchen zu lassen (siehe getFilledValues/exportTextButton/
182    // extractFilledValues in server.js für die passenden Ausschlüsse).
183    if (field.hidden) {
184      const hiddenInput = document.createElement("input");
185      hiddenInput.type = "hidden";
186      hiddenInput.id = field.key;
187      hiddenInput.name = field.key;
188      fieldsContainer.appendChild(hiddenInput);
189      return;
190    }
191
192    const wrapper = document.createElement("div");
193    wrapper.className = "field";
194
195    // Beim Mehrfachantrag können Label/Register/Platzhalter je Bundesland
196    // abweichen (z. B. ELAN-NRW: "Feldblock" statt "Feldstück") — siehe
197    // fieldOverrides in antragstypen.js.
198    const display =
199      typeKey === "mehrfachantrag" ? getMehrfachantragFieldDisplay(field, bundesland) : field;
200
201    const label = document.createElement("label");
202    label.setAttribute("for", field.key);
203    // Registerhinweis nur zeigen, wenn wir für dieses Bundesland auch die
204    // korrekten Register-Begriffe kennen (aktuell: Baden-Württemberg,
205    // Bayern, Nordrhein-Westfalen, Hessen) — sonst wäre er möglicherweise
206    // falsch für das echte Portal.
207    const showRegister = typeKey !== "mehrfachantrag" ? Boolean(field.register) : portalInfo.knownRegisters;
208    label.textContent = showRegister ? `${display.label} (Register: ${display.register})` : display.label;
209
210    // Array-Felder (z. B. "flaechennachweis" beim Mehrfachantrag, siehe
211    // antragstypen.js: arrayField/itemFields) halten mehrere Zeilen (jede ein
212    // eigenes Objekt aus Unterfeldern) statt eines einzelnen Texts. Das
213    // <input> bleibt technisch der Wertträger (JSON-String), damit
214    // getFilledValues/applyExtractedValues weiterhin einheitlich per
215    // document.getElementById(field.key) arbeiten -- sichtbar ist stattdessen
216    // ein separates, schreibgeschütztes Anzeige-Element (befüllt aus der
217    // KI-Erkennung oder einem gespeicherten Antrag; manuelle Eingabe mehrerer
218    // Zeilen ist hier bewusst nicht vorgesehen).
219    const input = document.createElement("input");
220    input.type = field.arrayField ? "hidden" : "text";
221    input.id = field.key;
222    input.name = field.key;
223    input.placeholder = display.placeholder;
224    input.addEventListener("input", () => setFieldWarning(field.key, false));
225    // Auffälligkeits-Hinweis bei Düngemengen: nur für dieses eine Feld
226    // relevant, live (mit Verzögerung) geprüft, während der Nutzer tippt.
227    if (typeKey === "duengeverordnung" && field.key === "duengermenge") {
228      input.addEventListener("input", scheduleDuengungCheck);
229    }
230    // KI-Vorschlag-Button für PSM-Dokumentation: erst nutzbar, wenn "Mittel"
231    // und "Zielorganismus" ausgefüllt sind (siehe updatePsmVorschlagButtonState).
232    if (typeKey === "psm_dokumentation" && (field.key === "mittel" || field.key === "zielorganismus")) {
233      input.addEventListener("input", updatePsmVorschlagButtonState);
234    }
235    // EPPO-Code sofort aus der festen, lokalen Tabelle ausfüllen, sobald die
236    // Kulturpflanze feststeht (siehe eppoCodes.js) -- kein Klick nötig.
237    if (typeKey === "psm_dokumentation" && field.key === "kulturpflanze") {
238      input.addEventListener("input", updateEppoCodeAutoFill);
239    }
240    // Manuelle Eingabe in "eppoCode" selbst beendet die automatische
241    // Befüllung für diesen Wert (siehe handleEppoCodeManualEdit).
242    if (typeKey === "psm_dokumentation" && field.key === "eppoCode") {
243      input.addEventListener("input", handleEppoCodeManualEdit);
244    }
245
246    const warning = document.createElement("span");
247    warning.className = "field-warning hidden";
248    warning.dataset.warningFor = field.key;
249    warning.textContent = "Konnte nicht automatisch erkannt werden – bitte manuell prüfen.";
250
251    wrapper.appendChild(label);
252    wrapper.appendChild(input);
253    if (field.arrayField) {
254      const arrayDisplay = document.createElement("div");
255      arrayDisplay.className = "field-array-display hint";
256      arrayDisplay.id = `${field.key}-display`;
257      arrayDisplay.textContent = "(keine Zeilen erkannt)";
258      wrapper.appendChild(arrayDisplay);
259    }
260    wrapper.appendChild(warning);
261    // Sichtbare Bestätigung, dass der EPPO-Code automatisch aus der festen
262    // Tabelle stammt (nicht aus einer KI-Websuche) -- eigener, klar von der
263    // "Konnte nicht automatisch erkannt werden"-Warnung unterscheidbarer
264    // Hinweis, siehe style.css: .field-hint-auto.
265    if (typeKey === "psm_dokumentation" && field.key === "eppoCode") {
266      const autoHint = document.createElement("span");
267      autoHint.className = "field-hint-auto hidden";
268      autoHint.id = "eppo-code-auto-hint";
269      autoHint.textContent = "✓ Automatisch aus offizieller EPPO-Code-Tabelle ausgefüllt.";
270      wrapper.appendChild(autoHint);
271    }
272    fieldsContainer.appendChild(wrapper);
273  });
274
275  psmVorschlagPanel.classList.toggle("hidden", typeKey !== "psm_dokumentation");
276  if (typeKey === "psm_dokumentation") {
277    resetPsmVorschlagPreview();
278    updatePsmVorschlagButtonState();
279  }
280
281  sbkEintragPanel.classList.toggle("hidden", typeKey !== "schaedlingsbekaempfung");
282
283  applyRoleRestrictionsToPreview();
284}
285
286// Team-Mitglieder mit der Rolle "nur_ansehen" dürfen Anträge einsehen, aber
287// nichts ausfüllen oder speichern (der Server lehnt POST /api/applications
288// für diese Rolle ohnehin ab — hier geht es nur darum, das in der
289// Oberfläche gar nicht erst anzubieten).
290function isReadOnlyRole() {
291  return Boolean(window.currentBetrieb && window.currentBetrieb.role === "nur_ansehen");
292}
293
294function applyRoleRestrictionsToPreview() {
295  const readOnly = isReadOnlyRole();
296  fieldsContainer.querySelectorAll("input").forEach((input) => {
297    input.disabled = readOnly;
298  });
299  submitButton.classList.toggle("hidden", readOnly);
300}
301
302// Blendet den Schritt "Antragstyp auswählen" (und damit den gesamten
303// Hochlade-Einstieg) für "nur_ansehen"-Mitglieder komplett aus — sie können
304// weiterhin über "Meine gespeicherten Anträge" bestehende Anträge öffnen
305// und exportieren.
306function applyRoleRestrictionsGlobal() {
307  const readOnly = isReadOnlyRole();
308  typeSection.classList.toggle("hidden", readOnly);
309}
310
311function setFieldWarning(fieldKey, show) {
312  const warning = document.querySelector(`[data-warning-for="${fieldKey}"]`);
313  if (warning) {
314    warning.classList.toggle("hidden", !show);
315  }
316}
317
318function applyExtractedValues(values) {
319  const config = ANTRAGSTYPEN[selectedType];
320  config.fields.forEach((field) => {
321    const value = values ? values[field.key] : null;
322    const input = document.getElementById(field.key);
323    if (field.arrayField) {
324      const zeilen = Array.isArray(value) ? value : [];
325      const displayEl = document.getElementById(`${field.key}-display`);
326      if (zeilen.length > 0) {
327        input.value = JSON.stringify(zeilen);
328        if (displayEl) displayEl.textContent = formatFeldWert(zeilen);
329        setFieldWarning(field.key, false);
330      } else {
331        input.value = "";
332        if (displayEl) displayEl.textContent = "(keine Zeilen erkannt)";
333        setFieldWarning(field.key, true);
334      }
335      return;
336    }
337    if (value) {
338      input.value = value;
339      setFieldWarning(field.key, false);
340    } else {
341      input.value = "";
342      setFieldWarning(field.key, true);
343    }
344  });
345
346  // Werte können hier aus einer KI-Erkennung, einem gespeicherten Antrag
347  // oder einem Zurücksetzen stammen — in jedem Fall ist das der richtige
348  // Zeitpunkt, den Düngemengen-Hinweis neu zu prüfen (siehe unten).
349  if (selectedType === "duengeverordnung") {
350    runDuengungCheck();
351  } else {
352    hideDuengungHint();
353  }
354
355  // Beim Öffnen eines gespeicherten PSM-Antrags: Vorschau eines evtl. alten
356  // Vorschlags nicht übernehmen (bezieht sich auf eine frühere Sitzung),
357  // aber der "KI-Vorschlag holen"-Button soll sofort nutzbar sein, wenn
358  // Mittel/Zielorganismus schon ausgefüllt sind.
359  if (selectedType === "psm_dokumentation") {
360    resetPsmVorschlagPreview();
361    updatePsmVorschlagButtonState();
362  }
363
364  if (selectedType === "schaedlingsbekaempfung") {
365    renderSbkEintraegeUi();
366  }
367}
368
369// "Erkunden"-Modus: die Analyse ist ausschließlich an der Abo-Sperre
370// gescheitert (402, siehe extractFieldsForFile/processDocumentQueue oben) —
371// zeigt statt einer echten Auslese plausible, klar als Beispiel markierte
372// Werte, abgeleitet aus den ohnehin vorhandenen Platzhaltertexten je Feld
373// (z. B. "z. B. 45 ha" -> "45 ha (Beispielwert)"), damit Interessenten den
374// Mehrwert der echten Funktion sehen -- ohne dass das eigene Dokument dabei
375// je ausgewertet wird (siehe buildDemoBanner in dokument-erkennung.js). Die
376// Felder werden zusätzlich deaktiviert und "Angaben übernehmen" ausgeblendet,
377// weil es serverseitig ohnehin nichts zu speichern gäbe (dasselbe aktive
378// Abo wird auch beim Speichern verlangt).
379function applyDemoValues() {
380  const config = ANTRAGSTYPEN[selectedType];
381  const bundesland = window.currentBetrieb ? window.currentBetrieb.bundesland : null;
382
383  config.fields.forEach((field) => {
384    const display = selectedType === "mehrfachantrag" ? getMehrfachantragFieldDisplay(field, bundesland) : field;
385    const rohwert = (display.placeholder || "").replace(/^z\.\s*B\.\s*/i, "");
386    const input = document.getElementById(field.key);
387    if (input) {
388      input.value = rohwert ? `${rohwert} (Beispielwert)` : "(Beispielwert)";
389      input.disabled = true;
390    }
391    setFieldWarning(field.key, false);
392  });
393
394  extractionSummaryEl.innerHTML = "";
395  extractionSummaryEl.classList.remove("hidden");
396  extractionSummaryEl.appendChild(buildDemoBanner());
397
398  extractionBestaetigungContainer.innerHTML = "";
399  submitButton.disabled = true;
400  submitButton.classList.add("hidden");
401
402  // Der KI-Vorschlag kostet eine echte Websuche und verlangt serverseitig
403  // dasselbe aktive Abo wie das Speichern — im Erkunden-Modus gibt es davon
404  // also ohnehin nichts zu zeigen.
405  psmVorschlagPanel.classList.add("hidden");
406}
407
408// Blendet die Erkennungs-Zusammenfassung/Bestätigung aus und stellt sicher,
409// dass "Angaben übernehmen" normal nutzbar bleibt — für alle Fälle, in
410// denen KEINE tatsächliche Auslese vorliegt (rein manuelle Eingabe, noch
411// nicht analysiertes Dokument in der Warteschlange, geöffneter
412// gespeicherter Antrag, siehe openApplication).
413function clearExtractionGating() {
414  extractionSummaryEl.innerHTML = "";
415  extractionSummaryEl.classList.add("hidden");
416  extractionBestaetigungContainer.innerHTML = "";
417  submitButton.disabled = false;
418}
419
420// Zeigt die Erkennungs-Zusammenfassung ("X von Y Angaben erkannt") oben in
421// der Vorschau und sperrt "Angaben übernehmen", bis entweder alle Felder
422// erkannt wurden oder die Bestätigungs-Checkbox angehakt ist (siehe
423// dokument-erkennung.js) — nur aufgerufen, wenn tatsächlich eine Auslese
424// stattgefunden hat (siehe openQueueDocForReview).
425function updateExtractionGating(values) {
426  const felderDefs = ANTRAGSTYPEN[selectedType].fields
427    .filter((f) => !f.hidden)
428    .map((f) => ({ key: f.key, label: f.label }));
429  const zusammenfassung = berechneErkennungsZusammenfassung(felderDefs, values || {});
430
431  extractionSummaryEl.innerHTML = "";
432  extractionSummaryEl.classList.remove("hidden");
433  extractionSummaryEl.appendChild(buildErkennungsBanner(zusammenfassung));
434
435  extractionBestaetigungContainer.innerHTML = "";
436  const bestaetigung = buildErkennungsBestaetigung(zusammenfassung, submitButton);
437  if (bestaetigung) {
438    extractionBestaetigungContainer.appendChild(bestaetigung.wrapper);
439  }
440}
441
442// --- Auffälligkeits-Hinweis bei Düngemengen --------------------------------
443//
444// Vergleicht die "Ausgebrachte Düngermenge" (Düngeverordnung) mit der
445// Flächengröße aus dem zuletzt gespeicherten Mehrfachantrag desselben
446// Betriebs (siehe GET /api/duengung-check, server.js/duengeCheck.js) — rein
447// als freundliche, unverbindliche Rückfrage, niemals als Fehlermeldung.
448
449let duengungCheckTimeout = null;
450
451function scheduleDuengungCheck() {
452  if (duengungCheckTimeout) clearTimeout(duengungCheckTimeout);
453  duengungCheckTimeout = setTimeout(runDuengungCheck, 600);
454}
455
456function showDuengungHint(text) {
457  duengungHintTextEl.textContent = text;
458  duengungHintEl.classList.remove("hidden");
459}
460
461function hideDuengungHint() {
462  duengungHintEl.classList.add("hidden");
463  duengungHintTextEl.textContent = "";
464}
465
466async function runDuengungCheck() {
467  if (selectedType !== "duengeverordnung") {
468    hideDuengungHint();
469    return;
470  }
471
472  const input = document.getElementById("duengermenge");
473  const duengermenge = input ? input.value.trim() : "";
474  if (!duengermenge) {
475    hideDuengungHint();
476    return;
477  }
478
479  try {
480    const response = await fetchWithTimeout(
481      `/api/duengung-check?duengermenge=${encodeURIComponent(duengermenge)}`,
482      { method: "GET" },
483      APP_CONFIG.exportTimeoutMs,
484    );
485    const data = await parseJsonSafely(response);
486    if (!response.ok || !data || !data.hint) {
487      hideDuengungHint();
488      return;
489    }
490    showDuengungHint(data.hint);
491  } catch (err) {
492    console.error("Düngemengen-Hinweis konnte nicht geprüft werden:", err);
493    hideDuengungHint();
494  }
495}
496
497// --- PSM-Dokumentation: KI-Vorschlag (Websuche) ----------------------------
498//
499// Genau das gleiche Vorsichts-/Bestätigungsprinzip wie bei der Dokument-
500// Erkennung (siehe dokument-erkennung.js): der Server liefert eine Vorschau
501// mit klarer "Vorschlag — bitte prüfen"-Kennzeichnung, NICHTS wird
502// automatisch in die Formularfelder übernommen — erst ein aktiver Klick auf
503// "Vorschläge übernehmen" füllt die (weiterhin frei editierbaren) Felder.
504// Der letzte abgerufene Dosierungs-Vorschlag wird dabei IMMER (auch ohne
505// "Übernehmen") in das versteckte Feld "dosierungVorschlagKi" geschrieben —
506// nur so kann die spätere Plausibilitätsprüfung (server.js:
507// psmCheck.compareDosierung) erkennen, ob die tatsächlich eingetragene
508// Dosierung von der laut Zulassung üblichen abweicht, unabhängig davon, ob
509// der Nutzer den Vorschlag übernommen oder eine eigene Menge eingetragen hat.
510
511const PSM_VORSCHLAG_FELDER = [
512  { key: "registriernummer", label: "Registriernummer des Mittels", quelleUrlKey: "registriernummerQuelleUrl" },
513  { key: "eppoCode", label: "EPPO-Code (Kultur)", quelleUrlKey: "eppoCodeQuelleUrl" },
514  { key: "bbchStadium", label: "BBCH-Wachstumsstadium", quelleUrlKey: "bbchStadiumQuelleUrl" },
515  { key: "dosierung", label: "Empfohlene Dosierung laut Zulassung", quelleUrlKey: "dosierungQuelleUrl" },
516];
517
518let letzterPsmVorschlag = null;
519
520// True, solange der aktuelle Wert des "eppoCode"-Felds selbst automatisch aus
521// EPPO_CODES_NACH_KULTUR (siehe eppoCodes.js) stammt -- verhindert, dass
522// updateEppoCodeAutoFill() einen vom Nutzer manuell eingetragenen/
523// geänderten Wert überschreibt (wird bei einer echten Tastatureingabe im
524// Feld selbst wieder auf false gesetzt, siehe handleEppoCodeManualEdit).
525let eppoCodeAutoGefuellt = false;
526
527function resetPsmVorschlagPreview() {
528  letzterPsmVorschlag = null;
529  psmVorschlagPreviewEl.innerHTML = "";
530  psmVorschlagPreviewEl.classList.add("hidden");
531  psmVorschlagStatusEl.classList.add("hidden");
532  psmVorschlagStatusEl.textContent = "";
533  psmVorschlagStatusEl.classList.remove("export-error");
534  eppoCodeAutoGefuellt = false;
535  updateEppoCodeAutoFill();
536}
537
538// Füllt den EPPO-Code SOFORT aus der festen, lokalen Tabelle (eppoCodes.js),
539// sobald die "Kulturpflanze" bekannt ist -- ohne Klick auf "KI-Vorschlag
540// holen" und ohne Websuche, da EPPO-Codes amtlich genormt und damit für eine
541// gegebene Kultur statisch sind (siehe eppoCodes.js-Kommentar für die
542// Quellenprüfung). Überschreibt NIE einen bereits vorhandenen Wert, es sei
543// denn, dieser stammt selbst aus einer früheren automatischen Befüllung
544// (eppoCodeAutoGefuellt) -- ein manuell eingetragener/bestätigter Wert bleibt
545// unangetastet. Ändert sich die Kultur so, dass keine bekannte Kultur mehr
546// erkennbar ist, wird ein zuvor automatisch eingetragener (jetzt nicht mehr
547// passender) Code wieder entfernt, statt veraltet stehen zu bleiben.
548function updateEppoCodeAutoFill() {
549  if (selectedType !== "psm_dokumentation") return;
550  const kulturInput = document.getElementById("kulturpflanze");
551  const eppoInput = document.getElementById("eppoCode");
552  const autoHint = document.getElementById("eppo-code-auto-hint");
553  if (!kulturInput || !eppoInput) return;
554
555  const code = findEppoCodeFuerKultur(kulturInput.value);
556  if (code) {
557    if (!eppoInput.value.trim() || eppoCodeAutoGefuellt) {
558      eppoInput.value = code;
559      eppoCodeAutoGefuellt = true;
560      setFieldWarning("eppoCode", false);
561      if (autoHint) autoHint.classList.remove("hidden");
562    }
563  } else if (eppoCodeAutoGefuellt) {
564    eppoInput.value = "";
565    eppoCodeAutoGefuellt = false;
566    if (autoHint) autoHint.classList.add("hidden");
567  }
568}
569
570// Sobald der Nutzer selbst in das Feld tippt, gilt der Wert als manuell --
571// updateEppoCodeAutoFill() überschreibt ihn danach nicht mehr automatisch
572// (programmatisches Setzen von input.value, wie es updateEppoCodeAutoFill
573// selbst tut, löst KEIN "input"-Ereignis aus, daher kein Konflikt).
574function handleEppoCodeManualEdit() {
575  eppoCodeAutoGefuellt = false;
576  const autoHint = document.getElementById("eppo-code-auto-hint");
577  if (autoHint) autoHint.classList.add("hidden");
578}
579
580function updatePsmVorschlagButtonState() {
581  if (selectedType !== "psm_dokumentation") return;
582  const mittel = document.getElementById("mittel");
583  const zielorganismus = document.getElementById("zielorganismus");
584  const bereit = Boolean(mittel && mittel.value.trim() && zielorganismus && zielorganismus.value.trim());
585  psmVorschlagButton.disabled = !bereit || isReadOnlyRole();
586}
587
588// quelle: "bvl" für einen strukturierten Treffer aus der amtlichen BVL-PSM-
589// API (siehe bvlPsmApi.js), "ki"/undefined für eine unverbindliche
590// KI-Websuche -- steuert ausschließlich die Beschriftung/Optik, NICHT die
591// Übernahme-Logik (auch ein BVL-Wert bleibt eine Checkbox, die der Nutzer
592// aktiv bestätigen muss, siehe applyPsmVorschlag).
593// quelleUrl: bei quelle "ki" die von der KI zurückgegebene, geprüfte
594// Quell-URL (siehe psmCheck.js: sanitizeQuelleUrl) -- wird als anklickbarer
595// Link gezeigt, damit der Vorschlag nachvollziehbar bleibt, statt nur als
596// unbelegte Behauptung im Formular zu stehen. Fehlt sie, wird das
597// transparent so benannt ("keine Quelle gefunden"), statt einen Link zu
598// erfinden oder einfach wegzulassen.
599// bvlPruefUrl: bei quelle "bvl" der Link auf die amtliche BVL-Online-
600// Datenbank (siehe psmCheck.js: vorschlag.bvlPruefUrl) -- auch ein
601// strukturierter API-Treffer bleibt fehleranfällig (Mapping-Bug in unserer
602// eigenen Anbindung, veralteter Wert o. Ä.); ohne eigene Nachprüfmöglichkeit
603// stünde er sonst mit vollem "amtlich"-Anschein ungeprüft im Formular.
604function buildPsmVorschlagZeile(feld, wert, quelle, quelleUrl, bvlPruefUrl) {
605  const zeile = document.createElement("div");
606  zeile.className = "psm-vorschlag-zeile";
607
608  const kopf = document.createElement("div");
609  kopf.className = "psm-vorschlag-zeile-kopf";
610
611  if (wert) {
612    const checkbox = document.createElement("input");
613    checkbox.type = "checkbox";
614    checkbox.id = `psm-vorschlag-uebernehmen-${feld.key}`;
615    checkbox.checked = true;
616    checkbox.dataset.psmVorschlagFeld = feld.key;
617    const checkboxLabel = document.createElement("label");
618    checkboxLabel.setAttribute("for", checkbox.id);
619    checkboxLabel.textContent = `${feld.label}: `;
620
621    const wertBadge = document.createElement("span");
622    wertBadge.className = "psm-vorschlag-wert";
623    wertBadge.textContent = wert;
624
625    const quellBadge = document.createElement("span");
626    if (quelle === "bvl") {
627      quellBadge.className = "psm-vorschlag-badge-amtlich";
628      quellBadge.textContent = "✓ Aus der offiziellen BVL-Zulassungsdatenbank";
629    } else {
630      quellBadge.className = "psm-vorschlag-badge";
631      quellBadge.textContent = "Vorschlag einer unverbindlichen KI-Websuche — bitte anhand der offiziellen Zulassung prüfen";
632    }
633
634    kopf.appendChild(checkbox);
635    kopf.appendChild(checkboxLabel);
636    kopf.appendChild(wertBadge);
637    zeile.appendChild(kopf);
638    zeile.appendChild(quellBadge);
639
640    if (quelle === "bvl") {
641      if (bvlPruefUrl) {
642        const quelleZeile = document.createElement("div");
643        quelleZeile.className = "psm-vorschlag-quelle-zeile";
644        const link = document.createElement("a");
645        link.href = bvlPruefUrl;
646        link.target = "_blank";
647        link.rel = "noopener noreferrer";
648        // Kein Deep-Link zu einer einzelnen Detailseite möglich (siehe
649        // bvlPsmApi.js: BVL_ONLINE_DB_URL) -- der Hinweis nennt daher
650        // ausdrücklich, wonach in der (allgemeinen) Suchmaske gesucht
651        // werden muss.
652        link.textContent = `In der BVL-Datenbank prüfen (nach „${wert}“ suchen) ↗`;
653        quelleZeile.appendChild(link);
654        zeile.appendChild(quelleZeile);
655      }
656    } else {
657      const quelleZeile = document.createElement("div");
658      quelleZeile.className = "psm-vorschlag-quelle-zeile";
659      if (quelleUrl) {
660        const link = document.createElement("a");
661        link.href = quelleUrl;
662        link.target = "_blank";
663        link.rel = "noopener noreferrer";
664        link.textContent = "Quelle ansehen ↗";
665        quelleZeile.appendChild(link);
666      } else {
667        const keineQuelle = document.createElement("span");
668        keineQuelle.className = "psm-vorschlag-keine-quelle";
669        keineQuelle.textContent = "Keine Quelle gefunden";
670        quelleZeile.appendChild(keineQuelle);
671      }
672      zeile.appendChild(quelleZeile);
673    }
674  } else {
675    const label = document.createElement("span");
676    label.textContent = `${feld.label}: `;
677    const hinweis = document.createElement("span");
678    hinweis.className = "psm-vorschlag-unsicher";
679    hinweis.textContent = "Kein verlässlicher Vorschlag gefunden — bitte manuell eintragen.";
680    kopf.appendChild(label);
681    kopf.appendChild(hinweis);
682    zeile.appendChild(kopf);
683  }
684
685  return zeile;
686}
687
688function renderPsmVorschlagPreview(vorschlag) {
689  letzterPsmVorschlag = vorschlag;
690  psmVorschlagPreviewEl.innerHTML = "";
691
692  const intro = document.createElement("p");
693  intro.className = "hint";
694  intro.textContent =
695    "Registriernummer wird zuerst in der amtlichen BVL-Zulassungsdatenbank nachgeschlagen; alle übrigen Werte (und die Registriernummer als Rückfalloption, falls das Mittel dort nicht gefunden wird) stammen aus einer unverbindlichen KI-Websuche. Bitte jeden Wert anhand der offiziellen Zulassung prüfen, bevor Sie ihn übernehmen.";
696  psmVorschlagPreviewEl.appendChild(intro);
697
698  PSM_VORSCHLAG_FELDER.forEach((feld) => {
699    const quelle = feld.key === "registriernummer" ? vorschlag.registriernummerQuelle : "ki";
700    const quelleUrl = vorschlag[feld.quelleUrlKey] || null;
701    psmVorschlagPreviewEl.appendChild(
702      buildPsmVorschlagZeile(feld, vorschlag[feld.key], quelle, quelleUrl, vorschlag.bvlPruefUrl || null),
703    );
704  });
705
706  // Rein informative Zusatzangabe aus der BVL-API (keine Zuordnung zu einem
707  // Formularfeld, daher keine Checkbox/Übernahme-Logik) -- siehe
708  // bvlPsmApi.js für die Begründung, warum hier keine automatische
709  // Zuordnung zur eingetragenen Kulturpflanze versucht wird.
710  if (Array.isArray(vorschlag.zugelasseneKulturenBvl) && vorschlag.zugelasseneKulturenBvl.length > 0) {
711    const kulturenZeile = document.createElement("p");
712    kulturenZeile.className = "psm-vorschlag-kulturen-bvl";
713    kulturenZeile.textContent = `Laut BVL-Zulassungsdatenbank für dieses Mittel erfasste Kulturen: ${vorschlag.zugelasseneKulturenBvl.join(", ")}`;
714    psmVorschlagPreviewEl.appendChild(kulturenZeile);
715
716    // Auch die aufgelösten Kulturnamen sind "amtlich, aber verifizierbar":
717    // Ein Fehler bei der eigenen /kode-Auflösung wäre sonst nicht erkennbar.
718    if (vorschlag.bvlPruefUrl) {
719      const kulturenQuelleZeile = document.createElement("div");
720      kulturenQuelleZeile.className = "psm-vorschlag-quelle-zeile";
721      const kulturenLink = document.createElement("a");
722      kulturenLink.href = vorschlag.bvlPruefUrl;
723      kulturenLink.target = "_blank";
724      kulturenLink.rel = "noopener noreferrer";
725      kulturenLink.textContent = "In der BVL-Datenbank prüfen ↗";
726      kulturenQuelleZeile.appendChild(kulturenLink);
727      psmVorschlagPreviewEl.appendChild(kulturenQuelleZeile);
728    }
729  }
730
731  const hatMindestensEinenVorschlag = PSM_VORSCHLAG_FELDER.some((feld) => vorschlag[feld.key]);
732  if (hatMindestensEinenVorschlag) {
733    const uebernehmenButton = document.createElement("button");
734    uebernehmenButton.type = "button";
735    uebernehmenButton.className = "submit-button";
736    uebernehmenButton.textContent = "Ausgewählte Vorschläge übernehmen";
737    uebernehmenButton.addEventListener("click", applyPsmVorschlag);
738    psmVorschlagPreviewEl.appendChild(uebernehmenButton);
739
740    // Die tatsächlich befüllten Felder (registriernummer/eppoCode/bbchStadium/
741    // dosierung) liegen im DOM weit OBERHALB dieses Buttons (siehe
742    // fields-container vs. psm-vorschlag-panel in index.html) -- ohne
743    // sichtbare Rückmeldung wirkt ein Klick auf "Übernehmen" fälschlich so,
744    // als würde nichts passieren, obwohl die Werte korrekt gesetzt wurden
745    // (siehe applyPsmVorschlag: scrollt zum ersten übernommenen Feld und
746    // hebt es kurz hervor).
747    const uebernommenStatus = document.createElement("p");
748    uebernommenStatus.id = "psm-vorschlag-uebernommen-status";
749    uebernommenStatus.className = "save-success-banner hidden";
750    psmVorschlagPreviewEl.appendChild(uebernommenStatus);
751  }
752
753  // Der Dosierungs-Vorschlag wird IMMER im versteckten Feld gespeichert
754  // (siehe Datei-Kommentar oben) — unabhängig davon, ob "Übernehmen"
755  // geklickt wird.
756  const dosierungVorschlagInput = document.getElementById("dosierungVorschlagKi");
757  if (dosierungVorschlagInput) {
758    dosierungVorschlagInput.value = vorschlag.dosierung || "";
759  }
760
761  psmVorschlagPreviewEl.classList.remove("hidden");
762}
763
764// Kurze visuelle Hervorhebung eines gerade übernommenen Feldes -- damit der
765// Sprung nach oben (siehe applyPsmVorschlag) nicht nur technisch, sondern
766// auch optisch als "hier hat sich etwas geändert" erkennbar ist.
767function hebeFeldKurzHervor(input) {
768  input.classList.add("feld-uebernommen-hervorhebung");
769  setTimeout(() => input.classList.remove("feld-uebernommen-hervorhebung"), 2000);
770}
771
772function applyPsmVorschlag() {
773  if (!letzterPsmVorschlag) return;
774  const uebernommeneFelder = [];
775  PSM_VORSCHLAG_FELDER.forEach((feld) => {
776    const wert = letzterPsmVorschlag[feld.key];
777    if (!wert) return;
778    const checkbox = document.getElementById(`psm-vorschlag-uebernehmen-${feld.key}`);
779    if (!checkbox || !checkbox.checked) return;
780    const input = document.getElementById(feld.key);
781    if (input) {
782      input.value = wert;
783      setFieldWarning(feld.key, false);
784      uebernommeneFelder.push({ feld, input });
785    }
786  });
787
788  const statusEl = document.getElementById("psm-vorschlag-uebernommen-status");
789  if (!statusEl) return;
790  if (uebernommeneFelder.length === 0) {
791    statusEl.textContent = "Keine Vorschläge ausgewählt — bitte mindestens eine Checkbox anhaken.";
792    statusEl.classList.remove("hidden");
793    return;
794  }
795
796  const feldnamen = uebernommeneFelder.map(({ feld }) => feld.label).join(", ");
797  statusEl.textContent = `✓ ${uebernommeneFelder.length} Wert(e) übernommen: ${feldnamen} — siehe Formularfelder oben.`;
798  statusEl.classList.remove("hidden");
799
800  // Zum ersten übernommenen Feld scrollen (liegt im DOM oberhalb dieses
801  // Buttons, siehe Kommentar in renderPsmVorschlagPreview) und alle
802  // übernommenen Felder kurz hervorheben, damit die Änderung sichtbar wird,
803  // ohne dass der Nutzer selbst zurückscrollen und suchen muss.
804  uebernommeneFelder[0].input.scrollIntoView({ behavior: "smooth", block: "center" });
805  uebernommeneFelder.forEach(({ input }) => hebeFeldKurzHervor(input));
806}
807
808async function requestPsmVorschlag() {
809  const mittel = document.getElementById("mittel");
810  const zielorganismus = document.getElementById("zielorganismus");
811  const schlag = document.getElementById("schlag");
812  const datum = document.getElementById("datum");
813  const kulturpflanze = document.getElementById("kulturpflanze");
814
815  resetPsmVorschlagPreview();
816  psmVorschlagButton.disabled = true;
817  psmVorschlagStatusEl.textContent = "KI-Vorschlag wird ermittelt … dies kann bis zu 2 Minuten dauern, da eine echte Websuche läuft.";
818  psmVorschlagStatusEl.classList.remove("hidden", "export-error");
819
820  try {
821    const response = await fetchWithTimeout(
822      "/api/psm-vorschlag",
823      {
824        method: "POST",
825        headers: { "Content-Type": "application/json" },
826        body: JSON.stringify({
827          mittel: mittel ? mittel.value.trim() : "",
828          zielorganismus: zielorganismus ? zielorganismus.value.trim() : "",
829          schlag: schlag ? schlag.value.trim() : "",
830          datum: datum ? datum.value.trim() : "",
831          kulturpflanze: kulturpflanze ? kulturpflanze.value.trim() : "",
832        }),
833      },
834      // Eigener, großzügigerer Timeout als die normale Dokument-Auslese --
835      // siehe config.js: psmVorschlagTimeoutMs für die Begründung (echte
836      // Websuche mit bis zu 4 Pause/Resume-Zwischenschritten, kann legitim
837      // deutlich länger als eine einfache Dokument-Auslese dauern).
838      APP_CONFIG.psmVorschlagTimeoutMs,
839    );
840    const data = await parseJsonSafely(response);
841    if (!response.ok) {
842      throw new Error((data && data.error) || "Es konnte kein Vorschlag ermittelt werden.");
843    }
844
845    psmVorschlagStatusEl.classList.add("hidden");
846    renderPsmVorschlagPreview(data.vorschlag || {});
847  } catch (err) {
848    console.error(err);
849    psmVorschlagStatusEl.textContent = err.message || "Es konnte kein Vorschlag ermittelt werden. Bitte versuchen Sie es später erneut.";
850    psmVorschlagStatusEl.classList.remove("hidden");
851    psmVorschlagStatusEl.classList.add("export-error");
852  } finally {
853    updatePsmVorschlagButtonState();
854  }
855}
856
857psmVorschlagButton.addEventListener("click", requestPsmVorschlag);
858
859// --- Schädlingsbekämpfung: manuelle Kontrolleinträge ----------------------
860//
861// Das arrayField "schaedlingsbekaempfungEintraege" (siehe antragstypen.js)
862// wird -- anders als bei den übrigen arrayFeldern dieser App (z. B.
863// Mehrfachantrag flaechennachweis) -- NICHT primär per KI-Auslese eines
864// hochgeladenen Dokuments befüllt, sondern laufend über mehrere Wochen von
865// Hand ergänzt (eine neue Kontrolle = eine neue Zeile). Dafür gibt es dieses
866// eigene Mini-Formular, das denselben versteckten JSON-Wert schreibt wie
867// applyExtractedValues/getFilledValues es ohnehin erwarten -- Speichern,
868// Laden und Export funktionieren dadurch unverändert über die generische
869// arrayField-Logik.
870
871function getSbkEintraege() {
872  const hiddenInput = document.getElementById("schaedlingsbekaempfungEintraege");
873  if (!hiddenInput || !hiddenInput.value) return [];
874  try {
875    const parsed = JSON.parse(hiddenInput.value);
876    return Array.isArray(parsed) ? parsed : [];
877  } catch {
878    return [];
879  }
880}
881
882function setSbkEintraege(eintraege) {
883  const hiddenInput = document.getElementById("schaedlingsbekaempfungEintraege");
884  if (!hiddenInput) return;
885  hiddenInput.value = eintraege.length > 0 ? JSON.stringify(eintraege) : "";
886  const displayEl = document.getElementById("schaedlingsbekaempfungEintraege-display");
887  if (displayEl) {
888    displayEl.textContent = eintraege.length > 0 ? formatFeldWert(eintraege) : "(keine Zeilen erkannt)";
889  }
890  renderSbkEintraegeUi();
891}
892
893function resetSbkForm() {
894  sbkDatumInput.value = "";
895  sbkKoederstelleInput.value = "";
896  sbkArtSelect.value = "";
897  sbkSchaedlingInput.value = "";
898  sbkBeobachtungInput.value = "";
899  sbkKoederMittelInput.value = "";
900  sbkVerbrauchInput.value = "";
901  sbkAnwenderInput.value = "";
902  sbkBestaetigtCheckbox.checked = false;
903  sbkEintragStatusEl.classList.add("hidden");
904}
905
906function addSbkEintrag() {
907  const datum = sbkDatumInput.value.trim();
908  const koederstelle = sbkKoederstelleInput.value.trim();
909  const art = sbkArtSelect.value;
910  const anwender = sbkAnwenderInput.value.trim();
911
912  if (!datum || !koederstelle || !art || !anwender || !sbkBestaetigtCheckbox.checked) {
913    sbkEintragStatusEl.textContent =
914      "Bitte mindestens Datum, Köderstelle, Monitoring/Bekämpfung und Anwender ausfüllen sowie die Bestätigung ankreuzen.";
915    sbkEintragStatusEl.classList.remove("hidden");
916    sbkEintragStatusEl.classList.add("export-error");
917    return;
918  }
919
920  const eintrag = {
921    datum,
922    koederstelle,
923    beobachtung: sbkBeobachtungInput.value.trim(),
924    art,
925    schaedling: sbkSchaedlingInput.value.trim(),
926    koederMittel: sbkKoederMittelInput.value.trim(),
927    verbrauch: sbkVerbrauchInput.value.trim(),
928    anwender,
929    bestaetigt: "Ja",
930  };
931
932  const eintraege = getSbkEintraege();
933  eintraege.push(eintrag);
934  setSbkEintraege(eintraege);
935  resetSbkForm();
936
937  sbkEintragStatusEl.textContent = "Eintrag hinzugefügt.";
938  sbkEintragStatusEl.classList.remove("export-error");
939  sbkEintragStatusEl.classList.remove("hidden");
940}
941
942function removeSbkEintrag(index) {
943  const eintraege = getSbkEintraege();
944  eintraege.splice(index, 1);
945  setSbkEintraege(eintraege);
946}
947
948// "Aktuell auffällig" ist bewusst eine einfache, transparente Definition
949// (keine zusätzliche Datenerfassung nötig): die zuletzt erfasste Zeile einer
950// Köderstelle war eine tatsächliche Bekämpfungsmaßnahme (B), nicht nur ein
951// unauffälliges Monitoring (M). Reihenfolge der Einträge im Array entspricht
952// der Erfassungsreihenfolge (neueste zuletzt), da neue Zeilen stets
953// angehängt werden (siehe addSbkEintrag).
954function berechneSbkStatus(eintraege) {
955  const letzterProKoederstelle = new Map();
956  eintraege.forEach((e) => {
957    const stelle = (e.koederstelle || "").trim();
958    if (!stelle) return;
959    letzterProKoederstelle.set(stelle, e);
960  });
961  const gesamt = letzterProKoederstelle.size;
962  let auffaellig = 0;
963  letzterProKoederstelle.forEach((e) => {
964    if ((e.art || "").startsWith("Bekämpfung")) auffaellig += 1;
965  });
966  return { gesamt, auffaellig };
967}
968
969function renderSbkEintraegeUi() {
970  const eintraege = getSbkEintraege();
971
972  if (eintraege.length === 0) {
973    sbkStatusUebersichtEl.classList.add("hidden");
974    sbkEintraegeListeEl.innerHTML = "";
975    return;
976  }
977
978  const { gesamt, auffaellig } = berechneSbkStatus(eintraege);
979  sbkStatusUebersichtEl.textContent =
980    `Aktueller Status: ${auffaellig} von ${gesamt} Köderstelle${gesamt === 1 ? "" : "n"} aktuell auffällig ` +
981    `(letzter Eintrag = Bekämpfungsmaßnahme statt reines Monitoring).`;
982  sbkStatusUebersichtEl.classList.remove("hidden");
983
984  sbkEintraegeListeEl.innerHTML = "";
985  eintraege.forEach((e, index) => {
986    const zeile = document.createElement("div");
987    zeile.className = "sbk-eintrag-zeile";
988
989    const kopf = document.createElement("div");
990    kopf.className = "sbk-eintrag-zeile-kopf";
991    const titel = document.createElement("strong");
992    titel.textContent = `${e.datum || "(ohne Datum)"} – Köderstelle ${e.koederstelle || "?"} (${e.art || "?"})`;
993    const entfernenButton = document.createElement("button");
994    entfernenButton.type = "button";
995    entfernenButton.className = "link-button";
996    entfernenButton.textContent = "Entfernen";
997    entfernenButton.addEventListener("click", () => removeSbkEintrag(index));
998    kopf.appendChild(titel);
999    kopf.appendChild(entfernenButton);
1000    zeile.appendChild(kopf);
1001
1002    const details = document.createElement("p");
1003    details.className = "hint";
1004    const detailTeile = [
1005      e.schaedling ? `Schädling: ${e.schaedling}` : null,
1006      e.beobachtung ? `Beobachtung: ${e.beobachtung}` : null,
1007      e.koederMittel ? `Köder/Mittel: ${e.koederMittel}` : null,
1008      e.verbrauch ? `Verbrauch: ${e.verbrauch}` : null,
1009      e.anwender ? `Anwender: ${e.anwender}` : null,
1010      e.bestaetigt === "Ja" ? "Bestätigt ✓" : "Nicht bestätigt",
1011    ].filter(Boolean);
1012    details.textContent = detailTeile.join(" · ");
1013    zeile.appendChild(details);
1014
1015    sbkEintraegeListeEl.appendChild(zeile);
1016  });
1017}
1018
1019sbkHinzufuegenButton.addEventListener("click", addSbkEintrag);
1020
1021// --- Antragstyp selection ------------------------------------------------
1022
1023function selectType(typeKey) {
1024  if (!ANTRAGSTYPEN[typeKey]) return;
1025
1026  selectedType = typeKey;
1027  hasUploadedDocument = false;
1028  currentFile = null;
1029  currentFileName = null;
1030  currentApplicationId = null;
1031  // Ein Wechsel des Antragstyps macht eine bereits analysierte Warteschlange
1032  // ungültig (die KI-Schemas sind typspezifisch) — daher wird sie geleert.
1033  documentQueue = [];
1034  currentQueueDocId = null;
1035
1036  document.querySelectorAll(".type-option").forEach((button) => {
1037    button.classList.toggle("selected", button.dataset.type === typeKey);
1038  });
1039
1040  renderFields(typeKey);
1041
1042  if (typeKey === "schaedlingsbekaempfung") {
1043    resetSbkForm();
1044    renderSbkEintraegeUi();
1045  }
1046
1047  uploadSection.classList.remove("hidden");
1048  previewSection.classList.add("hidden");
1049  fileInput.value = "";
1050  fileNameEl.textContent = "";
1051  filePreview.innerHTML = "";
1052  extractionStatus.classList.add("hidden");
1053  extractionStatus.classList.remove("extraction-loading");
1054  uploadProgressEl.classList.add("hidden");
1055  saveSuccessBanner.classList.add("hidden");
1056  hideUploadError();
1057  resetExportUi();
1058  hideDuengungHint();
1059  clearExtractionGating();
1060  renderDocumentQueue();
1061
1062  uploadSection.scrollIntoView({ behavior: "smooth", block: "start" });
1063}
1064
1065typeSelector.addEventListener("click", (e) => {
1066  const button = e.target.closest(".type-option");
1067  if (button) {
1068    selectType(button.dataset.type);
1069  }
1070});
1071
1072// Sprung zur "Was brauche ich?"-Checkliste mit dem gerade auf dieser Seite
1073// gewählten Antragstyp vorausgewählt — nutzt denselben Navigations-
1074// Baustein (showPage + Typ-Vorauswahl) wie der umgekehrte Sprung von der
1075// Checkliste zurück zum Upload (siehe checklist.js: checklistCtaButton)
1076// bzw. von der Kalenderdetailansicht (siehe calendar.js:
1077// goToAntragTypeFromCalendar). Der Button selbst liegt innerhalb von
1078// #upload-section und ist dadurch automatisch nur sichtbar, solange
1079// selectedType gesetzt ist (dieselbe hidden-Klasse, die selectType/
1080// resetAppState bereits pflegen) — kein eigener Sichtbarkeits-Zustand nötig.
1081checklistJumpButton.addEventListener("click", () => {
1082  if (!selectedType) return;
1083  showPage("checklist");
1084  selectChecklistType(selectedType);
1085});
1086
1087// --- Extraction (Mehrfach-Upload: eine Warteschlange, sequentiell analysiert) --
1088
1089// Reine Netzwerk-/Analyse-Funktion ohne DOM-Seiteneffekte — wird für jedes
1090// Dokument der Warteschlange einzeln aufgerufen (siehe processDocumentQueue),
1091// damit ein Fehler bei einem Dokument die anderen nicht blockiert.
1092async function extractFieldsForFile(file) {
1093  const formData = new FormData();
1094  formData.append("file", file);
1095  formData.append("type", selectedType);
1096
1097  const response = await fetchWithTimeout(
1098    "/api/extract",
1099    { method: "POST", body: formData },
1100    APP_CONFIG.extractionTimeoutMs + 10000,
1101  );
1102  const data = await parseJsonSafely(response);
1103
1104  if (!response.ok) {
1105    const err = new Error((data && data.error) || "Unbekannter Fehler bei der Analyse.");
1106    // Markiert speziell die Abo-Sperre (siehe subscription.js:
1107    // requireActiveSubscriptionForBetrieb) — openQueueDocForReview muss
1108    // diesen Fall von einer echten, aber gescheiterten KI-Analyse
1109    // unterscheiden können (siehe dortiger Kommentar).
1110    err.subscriptionRequired = Boolean(data && data.subscriptionRequired);
1111    throw err;
1112  }
1113  if (!data) {
1114    throw new Error("Die Antwort des Servers konnte nicht gelesen werden. Bitte versuchen Sie es erneut.");
1115  }
1116  return data;
1117}
1118
1119const QUEUE_STATUS_LABELS = {
1120  pending: "Wartet …",
1121  analyzing: "Wird analysiert …",
1122  ready: "Bereit zur Prüfung",
1123  error: "Fehler bei der Analyse",
1124};
1125
1126// Zeigt die Warteschlange NUR, wenn mehr als ein Dokument in dieser Sitzung
1127// hochgeladen wurde — bei genau einem Dokument bleibt der Ablauf optisch
1128// identisch zum bisherigen Einzel-Upload (sofortige Vorschau, keine
1129// zusätzliche Liste).
1130function renderDocumentQueue() {
1131  if (documentQueue.length <= 1) {
1132    documentsQueueSection.classList.add("hidden");
1133    return;
1134  }
1135
1136  documentsQueueSection.classList.remove("hidden");
1137  documentsQueueList.innerHTML = "";
1138
1139  documentQueue.forEach((doc) => {
1140    const item = document.createElement("div");
1141    item.className = "application-item";
1142    if (doc.localId === currentQueueDocId) {
1143      item.classList.add("application-item-active");
1144    }
1145
1146    const info = document.createElement("div");
1147    info.className = "application-info";
1148    let statusLabel;
1149    if (doc.applicationId) {
1150      statusLabel = "Gespeichert ✓";
1151    } else if (doc.status === "error") {
1152      statusLabel = `Fehler: ${doc.errorMessage}`;
1153    } else {
1154      statusLabel = QUEUE_STATUS_LABELS[doc.status];
1155    }
1156    info.innerHTML = "";
1157    const fileNameEl = document.createElement("strong");
1158    fileNameEl.textContent = doc.fileName;
1159    const statusEl = document.createElement("span");
1160    statusEl.textContent = statusLabel;
1161    info.appendChild(fileNameEl);
1162    info.appendChild(statusEl);
1163
1164    const actions = document.createElement("div");
1165    actions.className = "application-actions";
1166    const reviewButton = document.createElement("button");
1167    reviewButton.type = "button";
1168    reviewButton.className = "application-action-button";
1169    reviewButton.textContent = doc.applicationId ? "Erneut öffnen" : "Prüfen";
1170    reviewButton.disabled = doc.status === "pending" || doc.status === "analyzing";
1171    reviewButton.addEventListener("click", () => openQueueDocForReview(doc.localId));
1172    actions.appendChild(reviewButton);
1173
1174    item.appendChild(info);
1175    item.appendChild(actions);
1176    documentsQueueList.appendChild(item);
1177  });
1178}
1179
1180// Lädt ein Dokument aus der Warteschlange in die (weiterhin einzelne)
1181// Vorschau von Schritt 3 — genau wie beim bisherigen Einzel-Upload zeigt die
1182// Vorschau immer nur EIN Dokument gleichzeitig, sodass Angaben nie zwischen
1183// Dokumenten vermischt werden können.
1184function openQueueDocForReview(localId) {
1185  const doc = documentQueue.find((d) => d.localId === localId);
1186  if (!doc) return;
1187
1188  currentQueueDocId = localId;
1189  hasUploadedDocument = true;
1190  currentFile = doc.file;
1191  currentFileName = doc.fileName;
1192  currentApplicationId = doc.applicationId;
1193
1194  resetExportUi();
1195  saveSuccessBanner.classList.add("hidden");
1196
1197  fileNameEl.textContent = doc.fileName;
1198  previewSection.classList.remove("hidden");
1199  renderPreviewFromBlob(doc.file, doc.file.type, doc.fileName);
1200
1201  extractionFieldsWrapper.classList.remove("hidden");
1202  renderFields(selectedType);
1203
1204  // Die Analyse ist an der Abo-Sperre gescheitert (402, siehe
1205  // extractFieldsForFile/processDocumentQueue oben) — real hat GAR KEINE
1206  // Erkennung stattgefunden, daher dürfen die Felder nicht wie bei einer
1207  // tatsächlich gescheiterten KI-Analyse als "nicht erkannt" erscheinen.
1208  // Stattdessen zeigt applyDemoValues plausible, klar markierte
1209  // Beispielwerte -- der bereits sichtbare globale Hinweis-Banner (siehe
1210  // net.js: fetchWithTimeout -> showSubscriptionRequiredBanner) bleibt
1211  // zusätzlich sichtbar, damit klar ist: das eigene Dokument wurde NICHT
1212  // ausgelesen.
1213  if (doc.status === "error" && doc.subscriptionRequired) {
1214    applyDemoValues();
1215    extractionStatus.classList.add("hidden");
1216    renderDocumentQueue();
1217    previewSection.scrollIntoView({ behavior: "smooth", block: "start" });
1218    return;
1219  }
1220
1221  if (doc.status === "ready" || doc.applicationId) {
1222    applyExtractedValues(doc.values || {});
1223    extractionStatus.textContent = doc.applicationId
1224      ? "Gespeicherter Antrag geladen. Sie können die Angaben bearbeiten und erneut speichern."
1225      : "Automatische Erkennung abgeschlossen. Bitte Angaben prüfen.";
1226    extractionStatus.classList.remove("extraction-error", "hidden");
1227    // Tatsächliche Auslese liegt vor (auch wenn sie inzwischen gespeichert
1228    // wurde) — Zusammenfassung/Bestätigung zeigen, siehe updateExtractionGating.
1229    updateExtractionGating(doc.values || {});
1230  } else if (doc.status === "error") {
1231    applyExtractedValues({});
1232    extractionStatus.textContent = doc.errorMessage || "Automatische Erkennung fehlgeschlagen. Bitte alle Felder manuell prüfen.";
1233    extractionStatus.classList.remove("hidden");
1234    extractionStatus.classList.add("extraction-error");
1235    // Auch ein fehlgeschlagener Auslese-Versuch ist eine "tatsächlich
1236    // durchgeführte Auslese" (0 von N Feldern erkannt) — Bestätigung nötig.
1237    updateExtractionGating({});
1238  } else {
1239    applyExtractedValues({});
1240    extractionStatus.textContent = "Dokument wird analysiert …";
1241    extractionStatus.classList.remove("extraction-error", "hidden");
1242    // Noch keine Auslese abgeschlossen — keine Zusammenfassung/Bestätigung
1243    // zeigen (siehe clearExtractionGating), sobald die Analyse fertig ist,
1244    // ruft processDocumentQueue diese Funktion erneut auf.
1245    clearExtractionGating();
1246  }
1247
1248  renderDocumentQueue();
1249  previewSection.scrollIntoView({ behavior: "smooth", block: "start" });
1250}
1251
1252// Analysiert die übergebenen Dokumente NACHEINANDER (nicht parallel) — so
1253// lässt sich ein klarer Fortschritt anzeigen ("Dokument 2 von 5 wird
1254// analysiert …") und ein einzelner Analyse-Fehler blockiert nicht die
1255// übrigen Dokumente des Batches.
1256async function processDocumentQueue(docsToProcess) {
1257  for (let i = 0; i < docsToProcess.length; i++) {
1258    const doc = docsToProcess[i];
1259    doc.status = "analyzing";
1260    renderDocumentQueue();
1261
1262    if (docsToProcess.length > 1) {
1263      uploadProgressEl.textContent = `Dokument ${i + 1} von ${docsToProcess.length} wird analysiert …`;
1264      uploadProgressEl.classList.remove("hidden", "extraction-error");
1265      uploadProgressEl.classList.add("extraction-loading");
1266    }
1267
1268    try {
1269      doc.values = await extractFieldsForFile(doc.file);
1270      doc.status = "ready";
1271    } catch (err) {
1272      console.error(err);
1273      doc.status = "error";
1274      doc.errorMessage = err.message || "Automatische Erkennung fehlgeschlagen.";
1275      doc.subscriptionRequired = Boolean(err.subscriptionRequired);
1276    }
1277
1278    // Ist genau dieses Dokument gerade in der Vorschau geöffnet (z. B. das
1279    // einzige Dokument eines Ein-Datei-Uploads), die Vorschau direkt mit dem
1280    // Ergebnis aktualisieren, statt auf ein manuelles erneutes Öffnen zu warten.
1281    if (doc.localId === currentQueueDocId) {
1282      openQueueDocForReview(doc.localId);
1283    } else {
1284      renderDocumentQueue();
1285    }
1286  }
1287
1288  uploadProgressEl.classList.remove("extraction-loading");
1289  uploadProgressEl.classList.add("hidden");
1290
1291  // Ist aktuell nichts geöffnet (z. B. weiterer Batch nach bereits
1292  // abgeschlossener Prüfung), automatisch das erste Dokument dieses Batches
1293  // zur Prüfung öffnen — bei genau einer Datei entspricht das exakt dem
1294  // bisherigen Ablauf (Upload → sofort Vorschau).
1295  if (!currentQueueDocId && docsToProcess.length > 0) {
1296    openQueueDocForReview(docsToProcess[0].localId);
1297  }
1298}
1299
1300function showPreviewError(message) {
1301  filePreview.innerHTML = "";
1302  const notice = document.createElement("div");
1303  notice.className = "preview-error";
1304  const icon = document.createElement("span");
1305  icon.style.fontSize = "1.6rem";
1306  icon.textContent = "⚠️";
1307  const text = document.createElement("span");
1308  text.textContent = message;
1309  notice.appendChild(icon);
1310  notice.appendChild(text);
1311  filePreview.appendChild(notice);
1312}
1313
1314// Renders either an image or a PDF badge for any File or Blob — used both
1315// for a freshly chosen upload and for re-opening a previously saved
1316// application's stored document.
1317function renderPreviewFromBlob(blob, mimetype, filenameForBadge) {
1318  filePreview.innerHTML = "";
1319
1320  if (mimetype.startsWith("image/")) {
1321    const img = document.createElement("img");
1322    img.alt = "Vorschau des Dokuments";
1323    img.addEventListener("error", () => {
1324      showPreviewError("Vorschau konnte nicht geladen werden. Die Datei ist möglicherweise beschädigt.");
1325    });
1326    img.src = URL.createObjectURL(blob);
1327    filePreview.appendChild(img);
1328  } else if (mimetype === "application/pdf") {
1329    const badge = document.createElement("div");
1330    badge.className = "pdf-badge";
1331    const icon = document.createElement("span");
1332    icon.style.fontSize = "2rem";
1333    icon.textContent = "📕";
1334    const name = document.createElement("span");
1335    name.textContent = filenameForBadge || "Dokument.pdf";
1336    badge.appendChild(icon);
1337    badge.appendChild(name);
1338    filePreview.appendChild(badge);
1339  }
1340}
1341
1342// Nimmt eine oder mehrere Dateien entgegen (Dateiauswahl-Dialog per
1343// "multiple" oder Drag & Drop mehrerer Dateien) — jede gültige Datei wird
1344// zu einem eigenen Eintrag der Warteschlange und dort einzeln analysiert.
1345function handleFiles(fileList) {
1346  const files = Array.from(fileList || []);
1347  if (files.length === 0 || !selectedType) return;
1348
1349  hideUploadError();
1350
1351  const validFiles = [];
1352  const errors = [];
1353  files.forEach((file) => {
1354    const validationError = validateFile(file);
1355    if (validationError) {
1356      errors.push(`${file.name}: ${validationError}`);
1357    } else {
1358      validFiles.push(file);
1359    }
1360  });
1361
1362  if (errors.length > 0) {
1363    showUploadError(errors.join(" "));
1364  }
1365  if (validFiles.length === 0) {
1366    return;
1367  }
1368
1369  hasUploadedDocument = true;
1370  const newDocs = validFiles.map((file) => ({
1371    localId: nextDocLocalId++,
1372    file,
1373    fileName: file.name,
1374    status: "pending",
1375    values: null,
1376    errorMessage: null,
1377    applicationId: null,
1378  }));
1379  documentQueue.push(...newDocs);
1380  renderDocumentQueue();
1381  processDocumentQueue(newDocs);
1382}
1383
1384fileInput.addEventListener("change", (e) => {
1385  handleFiles(e.target.files);
1386});
1387
1388["dragenter", "dragover"].forEach((eventName) => {
1389  dropZone.addEventListener(eventName, (e) => {
1390    e.preventDefault();
1391    dropZone.classList.add("drag-over");
1392  });
1393});
1394
1395["dragleave", "drop"].forEach((eventName) => {
1396  dropZone.addEventListener(eventName, (e) => {
1397    e.preventDefault();
1398    dropZone.classList.remove("drag-over");
1399  });
1400});
1401
1402dropZone.addEventListener("drop", (e) => {
1403  if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
1404    fileInput.files = e.dataTransfer.files;
1405    handleFiles(e.dataTransfer.files);
1406  }
1407});
1408
1409previewForm.addEventListener("submit", async (e) => {
1410  e.preventDefault();
1411
1412  saveSuccessBanner.classList.add("hidden");
1413  extractionStatus.textContent = "Angaben werden gespeichert …";
1414  extractionStatus.classList.remove("extraction-error", "hidden");
1415
1416  try {
1417    await saveApplication();
1418    extractionStatus.classList.add("hidden");
1419    // Deutlich sichtbare Bestätigung statt einer stillen Statusänderung —
1420    // bleibt stehen, bis der nächste Speicherversuch sie zurücksetzt.
1421    saveSuccessBanner.textContent = "Antrag wurde gespeichert ✓";
1422    saveSuccessBanner.classList.remove("hidden");
1423  } catch (err) {
1424    console.error(err);
1425    extractionStatus.textContent = `Angaben übernommen, aber nicht gespeichert: ${err.message} Der Export ist trotzdem möglich.`;
1426    extractionStatus.classList.remove("hidden");
1427    extractionStatus.classList.add("extraction-error");
1428  }
1429
1430  exportSection.classList.remove("hidden");
1431  exportSection.scrollIntoView({ behavior: "smooth", block: "end" });
1432});
1433
1434// Persists the current Antragstyp + field values (and the original file, if
1435// any) so the farmer can find it again under "Meine gespeicherten Anträge" —
1436// scoped to their account on the server, so no other user can see it.
1437//
1438// Ist currentApplicationId gesetzt (ein über "Meine gespeicherten Anträge"
1439// geöffneter Antrag), wird dieser Antrag aktualisiert (PUT), statt einen
1440// neuen anzulegen (POST) — so entstehen beim Korrigieren keine Duplikate.
1441// Nach dem ersten erfolgreichen Speichern eines NEUEN Antrags wird
1442// currentApplicationId auf die neue id gesetzt, damit ein erneuter Klick auf
1443// "Angaben übernehmen" im selben Durchlauf ebenfalls aktualisiert statt
1444// dupliziert.
1445async function saveApplication() {
1446  const values = getFilledValues();
1447  if (Object.keys(values).length === 0) {
1448    throw new Error("Es sind keine Felder ausgefüllt.");
1449  }
1450
1451  const formData = new FormData();
1452  formData.append("type", selectedType);
1453  formData.append("values", JSON.stringify(values));
1454  if (currentFile) {
1455    formData.append("file", currentFile, currentFileName || "dokument");
1456  }
1457
1458  const isUpdate = Boolean(currentApplicationId);
1459  const response = await fetchWithTimeout(
1460    isUpdate ? `/api/applications/${currentApplicationId}` : "/api/applications",
1461    { method: isUpdate ? "PUT" : "POST", body: formData },
1462    APP_CONFIG.exportTimeoutMs,
1463  );
1464  const data = await parseJsonSafely(response);
1465
1466  if (!response.ok) {
1467    throw new Error((data && data.error) || "Unbekannter Fehler beim Speichern.");
1468  }
1469
1470  if (!isUpdate && data && data.id) {
1471    currentApplicationId = data.id;
1472  }
1473
1474  // Gehört das gerade gespeicherte Dokument zur Mehrfach-Upload-
1475  // Warteschlange, deren Eintrag mit der (neuen oder aktualisierten) id
1476  // synchronisieren, damit die Liste "Gespeichert ✓" korrekt anzeigt.
1477  if (currentQueueDocId) {
1478    const doc = documentQueue.find((d) => d.localId === currentQueueDocId);
1479    if (doc) doc.applicationId = currentApplicationId;
1480    renderDocumentQueue();
1481  }
1482
1483  await loadApplications();
1484}
1485
1486// --- Export ---------------------------------------------------------------
1487
1488function resetExportUi() {
1489  exportSection.classList.add("hidden");
1490  exportOptions.classList.add("hidden");
1491  exportStatus.classList.add("hidden");
1492}
1493
1494function getFilledValues() {
1495  const config = ANTRAGSTYPEN[selectedType];
1496  const values = {};
1497  config.fields.forEach((field) => {
1498    const raw = document.getElementById(field.key).value.trim();
1499    if (field.arrayField) {
1500      if (!raw) return;
1501      try {
1502        const zeilen = JSON.parse(raw);
1503        if (Array.isArray(zeilen) && zeilen.length > 0) {
1504          values[field.key] = zeilen;
1505        }
1506      } catch {
1507        // Ungültiges JSON im versteckten Feld (sollte nicht vorkommen, da
1508        // ausschließlich applyExtractedValues hineinschreibt) -- Zeile
1509        // sicherheitshalber überspringen statt einen kaputten Wert zu senden.
1510      }
1511      return;
1512    }
1513    if (raw) {
1514      values[field.key] = raw;
1515    }
1516  });
1517  return values;
1518}
1519
1520function showExportStatus(message, isError) {
1521  exportStatus.textContent = message;
1522  exportStatus.classList.toggle("export-error", Boolean(isError));
1523  exportStatus.classList.remove("hidden");
1524}
1525
1526// Returns the values to export, or null (after showing a friendly message)
1527// if exporting isn't possible yet.
1528function prepareExport() {
1529  if (!selectedType || !hasUploadedDocument) {
1530    showExportStatus("Bitte laden Sie zuerst ein Dokument hoch, bevor Sie exportieren.", true);
1531    return null;
1532  }
1533
1534  const values = getFilledValues();
1535  if (Object.keys(values).length === 0) {
1536    showExportStatus(
1537      "Es sind keine Felder ausgefüllt. Bitte füllen Sie mindestens ein Feld aus, bevor Sie exportieren.",
1538      true,
1539    );
1540    return null;
1541  }
1542
1543  return values;
1544}
1545
1546exportToggleButton.addEventListener("click", () => {
1547  exportOptions.classList.toggle("hidden");
1548});
1549
1550exportPdfButton.addEventListener("click", async () => {
1551  const values = prepareExport();
1552  if (!values) return;
1553
1554  showExportStatus("PDF wird erstellt …", false);
1555
1556  try {
1557    const response = await fetchWithTimeout(
1558      "/api/export-pdf",
1559      {
1560        method: "POST",
1561        headers: { "Content-Type": "application/json" },
1562        body: JSON.stringify({ type: selectedType, values }),
1563      },
1564      APP_CONFIG.exportTimeoutMs,
1565    );
1566
1567    if (!response.ok) {
1568      const data = await parseJsonSafely(response);
1569      throw new Error((data && data.error) || "Das PDF konnte nicht erstellt werden.");
1570    }
1571
1572    const disposition = response.headers.get("Content-Disposition") || "";
1573    const match = disposition.match(/filename="?([^"]+)"?/);
1574    const filename = match ? match[1] : `agrado_${selectedType}.pdf`;
1575
1576    const blob = await response.blob();
1577    const url = URL.createObjectURL(blob);
1578    const link = document.createElement("a");
1579    link.href = url;
1580    link.download = filename;
1581    document.body.appendChild(link);
1582    link.click();
1583    link.remove();
1584    URL.revokeObjectURL(url);
1585
1586    showExportStatus("PDF wurde heruntergeladen.", false);
1587  } catch (err) {
1588    console.error(err);
1589    showExportStatus(err.message || "Das PDF konnte nicht erstellt werden. Bitte versuchen Sie es erneut.", true);
1590  }
1591});
1592
1593async function copyToClipboard(text) {
1594  if (navigator.clipboard && window.isSecureContext) {
1595    await navigator.clipboard.writeText(text);
1596    return;
1597  }
1598
1599  const textarea = document.createElement("textarea");
1600  textarea.value = text;
1601  textarea.style.position = "fixed";
1602  textarea.style.opacity = "0";
1603  document.body.appendChild(textarea);
1604  textarea.focus();
1605  textarea.select();
1606  const succeeded = document.execCommand("copy");
1607  textarea.remove();
1608  if (!succeeded) {
1609    throw new Error("Kopieren wird von diesem Browser nicht unterstützt.");
1610  }
1611}
1612
1613exportTextButton.addEventListener("click", async () => {
1614  const values = prepareExport();
1615  if (!values) return;
1616
1617  const config = ANTRAGSTYPEN[selectedType];
1618  const bundesland = window.currentBetrieb ? window.currentBetrieb.bundesland : null;
1619  const text = config.fields
1620    .filter((field) => !field.hidden && values[field.key])
1621    .map((field) => {
1622      const label =
1623        selectedType === "mehrfachantrag" ? getMehrfachantragFieldDisplay(field, bundesland).label : field.label;
1624      const wert = field.arrayField ? formatFeldWert(values[field.key]) : values[field.key];
1625      return `${label}: ${wert}`;
1626    })
1627    .join("\n");
1628
1629  try {
1630    await copyToClipboard(text);
1631    showExportStatus("In die Zwischenablage kopiert.", false);
1632  } catch (err) {
1633    console.error(err);
1634    showExportStatus("Kopieren fehlgeschlagen. Bitte Text manuell markieren und kopieren.", true);
1635  }
1636});
1637
1638// --- Meine gespeicherten Anträge -------------------------------------------
1639// Everything here is scoped server-side to the logged-in user (see
1640// auth.requireAuth + "AND user_id = ?" in db.js) — this list only ever shows
1641// what the current account itself saved.
1642
1643function formatSavedDate(isoLikeString) {
1644  // SQLite gives us "YYYY-MM-DD HH:MM:SS" (UTC, no timezone marker); adding
1645  // "Z" lets the browser parse it correctly and show it in local time.
1646  const date = new Date(isoLikeString.replace(" ", "T") + "Z");
1647  if (Number.isNaN(date.getTime())) return isoLikeString;
1648  return date.toLocaleString("de-DE", { dateStyle: "medium", timeStyle: "short" });
1649}
1650
1651async function loadApplications() {
1652  applicationsFilterBar.classList.add("hidden");
1653  applicationsFilterStatus.classList.add("hidden");
1654  applicationsList.innerHTML = '<p class="hint">Anträge werden geladen …</p>';
1655
1656  try {
1657    const response = await fetchWithTimeout("/api/applications", { method: "GET" }, APP_CONFIG.exportTimeoutMs);
1658    const data = await parseJsonSafely(response);
1659
1660    if (!response.ok) {
1661      throw new Error((data && data.error) || "Anträge konnten nicht geladen werden.");
1662    }
1663
1664    allApplications = data || [];
1665    setupApplicationFilters();
1666    applyApplicationFilters();
1667  } catch (err) {
1668    console.error(err);
1669    allApplications = [];
1670    applicationsList.innerHTML = "";
1671    const notice = document.createElement("p");
1672    notice.className = "upload-error";
1673    notice.textContent = err.message || "Anträge konnten nicht geladen werden.";
1674    applicationsList.appendChild(notice);
1675  }
1676}
1677
1678// Liest den Anzeigenamen eines Antragstyps aus den bereits vorhandenen
1679// Auswahl-Buttons in Schritt 1 aus, statt eine zweite, redundante
1680// Zuordnung zu pflegen.
1681function getApplicationTypeLabel(typeKey) {
1682  const button = document.querySelector(`.type-option[data-type="${typeKey}"]`);
1683  return button ? button.textContent : typeKey;
1684}
1685
1686// SQLite liefert "YYYY-MM-DD HH:MM:SS" (UTC, ohne Zeitzonen-Kennung) —
1687// dieselbe Behandlung wie in formatSavedDate, hier nur für das Jahr.
1688function getApplicationYear(app) {
1689  const date = new Date(app.createdAt.replace(" ", "T") + "Z");
1690  return Number.isNaN(date.getTime()) ? null : date.getFullYear();
1691}
1692
1693// Baut die Filter-Auswahlmöglichkeiten (Antragstyp, Jahr, Betrieb) aus der
1694// aktuell geladenen Liste auf. Wird nach jedem Laden neu aufgerufen, damit
1695// z. B. nach einem Betriebswechsel nur die für DIESEN Betrieb tatsächlich
1696// vorkommenden Typen/Jahre angeboten werden.
1697function setupApplicationFilters() {
1698  const hasApplications = allApplications.length > 0;
1699  applicationsFilterBar.classList.toggle("hidden", !hasApplications);
1700  if (!hasApplications) return;
1701
1702  const previousType = applicationsFilterType.value;
1703  const types = Array.from(new Set(allApplications.map((app) => app.type)));
1704  applicationsFilterType.innerHTML = '<option value="">Alle Antragstypen</option>';
1705  types.forEach((type) => {
1706    const option = document.createElement("option");
1707    option.value = type;
1708    option.textContent = getApplicationTypeLabel(type);
1709    applicationsFilterType.appendChild(option);
1710  });
1711  if (types.includes(previousType)) {
1712    applicationsFilterType.value = previousType;
1713  }
1714
1715  const previousYear = applicationsFilterYear.value;
1716  const years = Array.from(new Set(allApplications.map((app) => getApplicationYear(app)).filter((y) => y !== null)))
1717    .sort((a, b) => b - a);
1718  applicationsFilterYear.innerHTML = '<option value="">Alle Jahre</option>';
1719  years.forEach((year) => {
1720    const option = document.createElement("option");
1721    option.value = String(year);
1722    option.textContent = String(year);
1723    applicationsFilterYear.appendChild(option);
1724  });
1725  if (years.map(String).includes(previousYear)) {
1726    applicationsFilterYear.value = previousYear;
1727  }
1728
1729  // "Meine gespeicherten Anträge" lädt serverseitig immer nur den aktiven
1730  // Betrieb (siehe GET /api/applications) — bei mehreren zugänglichen
1731  // Betrieben wechselt diese Auswahl deshalb tatsächlich den aktiven
1732  // Betrieb (siehe window.switchActiveBetrieb), statt die schon geladene
1733  // Liste zu filtern.
1734  const betriebe = Array.isArray(window.betriebe) ? window.betriebe : [];
1735  if (betriebe.length > 1) {
1736    applicationsFilterBetriebField.classList.remove("hidden");
1737    applicationsFilterBetrieb.innerHTML = "";
1738    betriebe.forEach((betrieb) => {
1739      const option = document.createElement("option");
1740      option.value = String(betrieb.id);
1741      option.textContent = betrieb.name;
1742      applicationsFilterBetrieb.appendChild(option);
1743    });
1744    if (window.currentBetrieb) {
1745      applicationsFilterBetrieb.value = String(window.currentBetrieb.id);
1746    }
1747  } else {
1748    applicationsFilterBetriebField.classList.add("hidden");
1749  }
1750}
1751
1752// Nur sichtbar, wenn explizit nach "Pflanzenschutzmittel-Dokumentation"
1753// gefiltert wird — der generische Suche-/Jahr-Filter oben (applications-
1754// filter-bar) hat weder ein präzises Datumsfeld noch einen Fläche-Filter,
1755// daher ein eigener kleiner Filter-/Export-Block nur für diesen Antragstyp.
1756function updatePsmExportVisibility() {
1757  const isPsm = applicationsFilterType.value === "psm_dokumentation";
1758  psmExportSection.classList.toggle("hidden", !isPsm);
1759}
1760
1761// Analog zu updatePsmExportVisibility, für "Schädlingsbekämpfung/
1762// Rodentizid-Dokumentation" (siehe antragstypen.js: schaedlingsbekaempfung).
1763function updateSbkExportVisibility() {
1764  const isSbk = applicationsFilterType.value === "schaedlingsbekaempfung";
1765  sbkExportSection.classList.toggle("hidden", !isSbk);
1766}
1767
1768// Live-Filterung ohne Neuladen der Seite — filtert rein client-seitig über
1769// die bereits geladene Liste (siehe allApplications).
1770function applyApplicationFilters() {
1771  const searchTerm = applicationsSearchInput.value.trim().toLowerCase();
1772  const typeFilter = applicationsFilterType.value;
1773  const yearFilter = applicationsFilterYear.value;
1774
1775  const filtered = allApplications.filter((app) => {
1776    if (typeFilter && app.type !== typeFilter) return false;
1777    if (yearFilter && String(getApplicationYear(app)) !== yearFilter) return false;
1778    if (searchTerm) {
1779      const haystack = `${app.label} ${app.originalFilename || ""}`.toLowerCase();
1780      if (!haystack.includes(searchTerm)) return false;
1781    }
1782    return true;
1783  });
1784
1785  updatePsmExportVisibility();
1786  updateSbkExportVisibility();
1787  renderApplications(filtered, allApplications.length > 0 && filtered.length === 0);
1788}
1789
1790applicationsSearchInput.addEventListener("input", applyApplicationFilters);
1791applicationsFilterType.addEventListener("change", applyApplicationFilters);
1792applicationsFilterYear.addEventListener("change", applyApplicationFilters);
1793
1794// PSM-Dokumentation exportieren (Excel/CSV) — respektiert den Zeitraum-
1795// und Fläche-Filter dieses Abschnitts (siehe GET /api/psm-export in
1796// server.js für die serverseitige Filterung/Erzeugung). Gleiches Download-
1797// Muster wie der DATEV-Export (rechnungen.js: datevExportForm) — fetch,
1798// als Blob herunterladen, Dateinamen aus Content-Disposition übernehmen.
1799async function triggerPsmExport(format, button, defaultLabel) {
1800  psmExportErrorEl.classList.add("hidden");
1801  const von = psmExportVonInput.value;
1802  const bis = psmExportBisInput.value;
1803  if (von && bis && von > bis) {
1804    psmExportErrorEl.textContent = "Das Von-Datum darf nicht nach dem Bis-Datum liegen.";
1805    psmExportErrorEl.classList.remove("hidden");
1806    return;
1807  }
1808
1809  const params = new URLSearchParams({ format });
1810  if (von) params.set("von", von);
1811  if (bis) params.set("bis", bis);
1812  const flaeche = psmExportFlaecheInput.value.trim();
1813  if (flaeche) params.set("flaeche", flaeche);
1814
1815  button.disabled = true;
1816  button.textContent = "Export wird erstellt …";
1817  try {
1818    const response = await fetchWithTimeout(
1819      `/api/psm-export?${params.toString()}`,
1820      { method: "GET" },
1821      APP_CONFIG.exportTimeoutMs,
1822    );
1823    if (!response.ok) {
1824      const data = await parseJsonSafely(response);
1825      throw new Error((data && data.error) || "Der Export konnte nicht erstellt werden.");
1826    }
1827
1828    const disposition = response.headers.get("Content-Disposition") || "";
1829    const match = disposition.match(/filename="?([^"]+)"?/);
1830    const filename = match ? match[1] : `psm_dokumentation.${format}`;
1831
1832    const blob = await response.blob();
1833    const url = URL.createObjectURL(blob);
1834    const link = document.createElement("a");
1835    link.href = url;
1836    link.download = filename;
1837    document.body.appendChild(link);
1838    link.click();
1839    link.remove();
1840    URL.revokeObjectURL(url);
1841  } catch (err) {
1842    console.error(err);
1843    psmExportErrorEl.textContent = err.message || "Der Export konnte nicht erstellt werden.";
1844    psmExportErrorEl.classList.remove("hidden");
1845  } finally {
1846    button.disabled = false;
1847    button.textContent = defaultLabel;
1848  }
1849}
1850
1851psmExportXlsxButton.addEventListener("click", () =>
1852  triggerPsmExport("xlsx", psmExportXlsxButton, "Als Excel (.xlsx) exportieren"),
1853);
1854psmExportCsvButton.addEventListener("click", () => triggerPsmExport("csv", psmExportCsvButton, "Als CSV exportieren"));
1855
1856// Schädlingsbekämpfung/Rodentizid-Dokumentation exportieren (Excel/CSV) --
1857// analog zu triggerPsmExport, aber ohne Fläche-Filter (siehe GET
1858// /api/schaedlingsbekaempfung-export in server.js für die serverseitige
1859// Erzeugung).
1860async function triggerSbkExport(format, button, defaultLabel) {
1861  sbkExportErrorEl.classList.add("hidden");
1862  const von = sbkExportVonInput.value;
1863  const bis = sbkExportBisInput.value;
1864  if (von && bis && von > bis) {
1865    sbkExportErrorEl.textContent = "Das Von-Datum darf nicht nach dem Bis-Datum liegen.";
1866    sbkExportErrorEl.classList.remove("hidden");
1867    return;
1868  }
1869
1870  const params = new URLSearchParams({ format });
1871  if (von) params.set("von", von);
1872  if (bis) params.set("bis", bis);
1873
1874  button.disabled = true;
1875  button.textContent = "Export wird erstellt …";
1876  try {
1877    const response = await fetchWithTimeout(
1878      `/api/schaedlingsbekaempfung-export?${params.toString()}`,
1879      { method: "GET" },
1880      APP_CONFIG.exportTimeoutMs,
1881    );
1882    if (!response.ok) {
1883      const data = await parseJsonSafely(response);
1884      throw new Error((data && data.error) || "Der Export konnte nicht erstellt werden.");
1885    }
1886
1887    const disposition = response.headers.get("Content-Disposition") || "";
1888    const match = disposition.match(/filename="?([^"]+)"?/);
1889    const filename = match ? match[1] : `schaedlingsbekaempfung.${format}`;
1890
1891    const blob = await response.blob();
1892    const url = URL.createObjectURL(blob);
1893    const link = document.createElement("a");
1894    link.href = url;
1895    link.download = filename;
1896    document.body.appendChild(link);
1897    link.click();
1898    link.remove();
1899    URL.revokeObjectURL(url);
1900  } catch (err) {
1901    console.error(err);
1902    sbkExportErrorEl.textContent = err.message || "Der Export konnte nicht erstellt werden.";
1903    sbkExportErrorEl.classList.remove("hidden");
1904  } finally {
1905    button.disabled = false;
1906    button.textContent = defaultLabel;
1907  }
1908}
1909
1910sbkExportXlsxButton.addEventListener("click", () =>
1911  triggerSbkExport("xlsx", sbkExportXlsxButton, "Als Excel (.xlsx) exportieren"),
1912);
1913sbkExportCsvButton.addEventListener("click", () => triggerSbkExport("csv", sbkExportCsvButton, "Als CSV exportieren"));
1914
1915applicationsFilterBetrieb.addEventListener("change", async () => {
1916  const betriebId = Number(applicationsFilterBetrieb.value);
1917  if (!Number.isInteger(betriebId) || (window.currentBetrieb && betriebId === window.currentBetrieb.id)) {
1918    return;
1919  }
1920
1921  applicationsFilterStatus.classList.add("hidden");
1922
1923  try {
1924    await window.switchActiveBetrieb(betriebId);
1925  } catch (err) {
1926    console.error(err);
1927    applicationsFilterStatus.textContent = err.message || "Betrieb konnte nicht gewechselt werden.";
1928    applicationsFilterStatus.classList.remove("hidden");
1929    if (window.currentBetrieb) {
1930      applicationsFilterBetrieb.value = String(window.currentBetrieb.id);
1931    }
1932  }
1933});
1934
1935// Einladender Leerzustand statt einer nüchternen Textzeile — mit direktem
1936// CTA-Button zum Upload-Einstieg (springt zu #type-section), sofern die
1937// aktuelle Rolle überhaupt hochladen darf.
1938function renderApplicationsEmptyState() {
1939  const empty = document.createElement("div");
1940  empty.className = "empty-state";
1941
1942  const icon = document.createElement("p");
1943  icon.className = "empty-state-icon";
1944  icon.setAttribute("aria-hidden", "true");
1945  icon.textContent = "📄";
1946  empty.appendChild(icon);
1947
1948  const title = document.createElement("p");
1949  title.className = "empty-state-title";
1950  title.textContent = "Noch keine Anträge";
1951  empty.appendChild(title);
1952
1953  const text = document.createElement("p");
1954  text.className = "empty-state-text";
1955  text.textContent = isReadOnlyRole()
1956    ? "Für diesen Betrieb wurden noch keine Anträge gespeichert."
1957    : "Laden Sie Ihr erstes Dokument hoch, um loszulegen.";
1958  empty.appendChild(text);
1959
1960  if (!isReadOnlyRole()) {
1961    const uploadButton = document.createElement("button");
1962    uploadButton.type = "button";
1963    uploadButton.className = "submit-button empty-state-button";
1964    uploadButton.textContent = "Dokument hochladen";
1965    uploadButton.addEventListener("click", () => {
1966      typeSection.scrollIntoView({ behavior: "smooth", block: "start" });
1967    });
1968    empty.appendChild(uploadButton);
1969  }
1970
1971  applicationsList.appendChild(empty);
1972}
1973
1974// Leerzustand, wenn Anträge zwar vorhanden sind, aber kein einziger zu den
1975// aktuell gesetzten Filtern passt — unterscheidet sich bewusst vom "noch
1976// keine Anträge"-Zustand oben (kein Upload-CTA, sondern "Filter
1977// zurücksetzen").
1978function renderApplicationsFilterEmptyState() {
1979  const empty = document.createElement("div");
1980  empty.className = "empty-state";
1981
1982  const icon = document.createElement("p");
1983  icon.className = "empty-state-icon";
1984  icon.setAttribute("aria-hidden", "true");
1985  icon.textContent = "🔍";
1986  empty.appendChild(icon);
1987
1988  const title = document.createElement("p");
1989  title.className = "empty-state-title";
1990  title.textContent = "Keine Treffer";
1991  empty.appendChild(title);
1992
1993  const text = document.createElement("p");
1994  text.className = "empty-state-text";
1995  text.textContent = "Kein gespeicherter Antrag entspricht den aktuellen Filtern.";
1996  empty.appendChild(text);
1997
1998  const resetButton = document.createElement("button");
1999  resetButton.type = "button";
2000  resetButton.className = "submit-button empty-state-button";
2001  resetButton.textContent = "Filter zurücksetzen";
2002  resetButton.addEventListener("click", () => {
2003    applicationsSearchInput.value = "";
2004    applicationsFilterType.value = "";
2005    applicationsFilterYear.value = "";
2006    applyApplicationFilters();
2007  });
2008  empty.appendChild(resetButton);
2009
2010  applicationsList.appendChild(empty);
2011}
2012
2013function renderApplications(applications, isFilteredEmpty = false) {
2014  applicationsList.innerHTML = "";
2015
2016  if (applications.length === 0) {
2017    if (isFilteredEmpty) {
2018      renderApplicationsFilterEmptyState();
2019    } else {
2020      renderApplicationsEmptyState();
2021    }
2022    return;
2023  }
2024
2025  applications.forEach((app) => {
2026    const item = document.createElement("div");
2027    item.className = "application-item";
2028
2029    const info = document.createElement("div");
2030    info.className = "application-info";
2031    info.innerHTML = `<strong>${app.label}</strong><span>${formatSavedDate(app.createdAt)}</span>`;
2032
2033    const actions = document.createElement("div");
2034    actions.className = "application-actions";
2035
2036    const openButton = document.createElement("button");
2037    openButton.type = "button";
2038    openButton.className = "application-action-button";
2039    openButton.textContent = "Öffnen";
2040    openButton.addEventListener("click", () => openApplication(app));
2041    actions.appendChild(openButton);
2042
2043    if (app.hasFile) {
2044      const fileLink = document.createElement("a");
2045      fileLink.href = `/api/applications/${app.id}/file`;
2046      fileLink.target = "_blank";
2047      fileLink.rel = "noopener";
2048      fileLink.className = "application-action-button";
2049      fileLink.textContent = "Dokument ansehen";
2050      actions.appendChild(fileLink);
2051    }
2052
2053    if (!isReadOnlyRole()) {
2054      const deleteButton = document.createElement("button");
2055      deleteButton.type = "button";
2056      deleteButton.className = "application-action-button application-delete-button";
2057      deleteButton.textContent = "Löschen";
2058      deleteButton.addEventListener("click", () => deleteApplication(app.id));
2059      actions.appendChild(deleteButton);
2060    }
2061
2062    item.appendChild(info);
2063    item.appendChild(actions);
2064    applicationsList.appendChild(item);
2065  });
2066}
2067
2068async function openApplication(app) {
2069  // Die eigentliche Vorschau (previewSection/fieldsContainer, weiter unten
2070  // befüllt) lebt auf der Seite "Antrag stellen" (#page-antrag) — nicht auf
2071  // der Listenseite "Meine gespeicherten Anträge" (#page-applications),
2072  // seit deren Trennung in eigene Seiten. Ohne diesen Wechsel läuft diese
2073  // Funktion zwar fehlerfrei durch, aktualisiert dabei aber nur DOM-
2074  // Elemente einer unsichtbaren Seite — ein Klick auf "Öffnen" hätte dann
2075  // für den Nutzer sichtbar GAR KEINE Wirkung. Hier statt nur bei den
2076  // einzelnen Aufrufstellen (siehe script.js: applications-list-Button,
2077  // dashboard.js: navigiereZuAktion), damit kein künftiger Aufrufer das
2078  // vergessen kann.
2079  if (typeof showPage === "function") {
2080    showPage("antrag");
2081  }
2082
2083  selectedType = app.type;
2084  hasUploadedDocument = true;
2085  currentFile = null;
2086  currentFileName = app.originalFilename || null;
2087  currentApplicationId = app.id;
2088  // Kommt NICHT aus der Mehrfach-Upload-Warteschlange (siehe
2089  // processDocumentQueue) — sonst würde ein späteres Speichern versehentlich
2090  // versuchen, einen (evtl. gar nicht mehr existierenden) Warteschlangen-
2091  // Eintrag zu synchronisieren.
2092  currentQueueDocId = null;
2093
2094  document.querySelectorAll(".type-option").forEach((button) => {
2095    button.classList.toggle("selected", button.dataset.type === app.type);
2096  });
2097
2098  // Der Datei-Upload dient zum ERSETZEN des Dokuments — für "nur_ansehen"
2099  // bleibt er versteckt, die Formularfelder werden über
2100  // applyRoleRestrictionsToPreview() (aus renderFields()) deaktiviert.
2101  if (!isReadOnlyRole()) {
2102    uploadSection.classList.remove("hidden");
2103  }
2104  hideUploadError();
2105  extractionFieldsWrapper.classList.remove("hidden");
2106  renderFields(app.type);
2107  applyExtractedValues(app.values);
2108
2109  // A field that was intentionally left empty when saved isn't "not
2110  // recognized" — it just wasn't filled in. Don't show that warning here.
2111  ANTRAGSTYPEN[app.type].fields.forEach((field) => setFieldWarning(field.key, false));
2112
2113  previewSection.classList.remove("hidden");
2114  fileNameEl.textContent = app.originalFilename || "";
2115  filePreview.innerHTML = "";
2116  saveSuccessBanner.classList.add("hidden");
2117  resetExportUi();
2118  // Gilt nicht als "tatsächlich durchgeführte Auslese" in dieser Sitzung —
2119  // keine Bestätigungspflicht beim bloßen Öffnen eines gespeicherten Antrags.
2120  clearExtractionGating();
2121
2122  extractionStatus.textContent = "Gespeicherter Antrag geladen. Sie können die Angaben bearbeiten und erneut exportieren.";
2123  extractionStatus.classList.remove("extraction-error", "hidden");
2124
2125  if (app.hasFile) {
2126    try {
2127      const response = await fetchWithTimeout(
2128        `/api/applications/${app.id}/file`,
2129        { method: "GET" },
2130        APP_CONFIG.exportTimeoutMs,
2131      );
2132      if (response.ok) {
2133        const blob = await response.blob();
2134        currentFile = blob;
2135        renderPreviewFromBlob(blob, blob.type, app.originalFilename);
2136      }
2137    } catch (err) {
2138      console.error("Dokument-Vorschau konnte nicht geladen werden:", err);
2139    }
2140  }
2141
2142  exportSection.classList.remove("hidden");
2143  previewSection.scrollIntoView({ behavior: "smooth", block: "start" });
2144}
2145
2146async function deleteApplication(id) {
2147  try {
2148    const response = await fetchWithTimeout(
2149      `/api/applications/${id}`,
2150      { method: "DELETE" },
2151      APP_CONFIG.exportTimeoutMs,
2152    );
2153    const data = await parseJsonSafely(response);
2154
2155    if (!response.ok) {
2156      throw new Error((data && data.error) || "Antrag konnte nicht gelöscht werden.");
2157    }
2158
2159    // Aus der Filter-Grundlage entfernen und neu rendern (statt nur das
2160    // DOM-Element zu löschen) — sonst würde der gelöschte Antrag nach der
2161    // nächsten Filteränderung aus allApplications wieder auftauchen.
2162    allApplications = allApplications.filter((app) => app.id !== id);
2163    setupApplicationFilters();
2164    applyApplicationFilters();
2165  } catch (err) {
2166    console.error(err);
2167    window.alert(err.message || "Antrag konnte nicht gelöscht werden.");
2168  }
2169}
2170
2171// --- Wichtige Fristen ---------------------------------------------------
2172
2173// Nächstes Vorkommen von Tag/Monat ab heute (rollt ins nächste Jahr, falls
2174// der Termin dieses Jahr schon vorbei ist) — so ist der Countdown nie negativ.
2175function nextOccurrence(month, day, today = new Date()) {
2176  const start = new Date(today.getFullYear(), today.getMonth(), today.getDate());
2177  let candidate = new Date(today.getFullYear(), month - 1, day);
2178  if (candidate < start) {
2179    candidate = new Date(today.getFullYear() + 1, month - 1, day);
2180  }
2181  return candidate;
2182}
2183
2184// Wie nextOccurrence, aber für mehrere wiederkehrende Termine pro Jahr
2185// (z. B. die vier ITW-Quartalsmeldungen) — liefert den jeweils nächsten von
2186// mehreren {month, day}-Terminen. Für jeden Termin greift dieselbe
2187// Rolle-ins-nächste-Jahr-Logik wie bei nextOccurrence, danach wird einfach
2188// der früheste der Kandidaten gewählt.
2189function nextOccurrenceFromSet(dates, today = new Date()) {
2190  return dates
2191    .map((d) => nextOccurrence(d.month, d.day, today))
2192    .reduce((earliest, candidate) => (candidate < earliest ? candidate : earliest));
2193}
2194
2195function daysUntil(date, today = new Date()) {
2196  const start = new Date(today.getFullYear(), today.getMonth(), today.getDate());
2197  const target = new Date(date.getFullYear(), date.getMonth(), date.getDate());
2198  return Math.round((target - start) / (24 * 60 * 60 * 1000));
2199}
2200
2201function formatGermanDate(date) {
2202  return date.toLocaleDateString("de-DE", { day: "numeric", month: "long", year: "numeric" });
2203}
2204
2205// >60 Tage: gruen, 30-60 Tage: gelb, <30 Tage: rot — wie gewünscht "je
2206// dringlicher, desto auffälliger".
2207function urgencyLevel(days) {
2208  if (days > 60) return "gruen";
2209  if (days >= 30) return "gelb";
2210  return "rot";
2211}
2212
2213function daysLabel(days) {
2214  return `${days} ${days === 1 ? "Tag" : "Tage"}`;
2215}
2216
2217function buildFristItem({ title, dateLabel, countdownLabel, level }) {
2218  const item = document.createElement("div");
2219  item.className = level ? `fristen-item fristen-${level}` : "fristen-item";
2220
2221  const titleEl = document.createElement("div");
2222  titleEl.className = "fristen-item-title";
2223  titleEl.textContent = title;
2224  item.appendChild(titleEl);
2225
2226  const dateEl = document.createElement("div");
2227  dateEl.className = "fristen-item-date";
2228  dateEl.textContent = dateLabel;
2229  item.appendChild(dateEl);
2230
2231  if (countdownLabel) {
2232    const countdownEl = document.createElement("div");
2233    countdownEl.className = "fristen-item-countdown";
2234    countdownEl.textContent = countdownLabel;
2235    item.appendChild(countdownEl);
2236  }
2237
2238  return item;
2239}
2240
2241// Liefert eine sortierte Liste aus Versicherungs-/Pachtvertrags-
2242// Ablaufdaten (siehe betrieb-profil.js: Stammdaten, Felder "ablaufdatum")
2243// als einheitliche {title, date}-Paare — gemeinsam genutzt von
2244// renderFristen() unten und calendar.js, beide lesen dieselbe Quelle
2245// (GET /api/betrieb-profil), daher hier nur EIN gemeinsamer
2246// Umformungsschritt statt einer doppelt gepflegten Kopie der Logik. Ob ein
2247// Eintrag manuell eingetragen oder per Dokumenten-Upload erkannt wurde
2248// (siehe server.js: appendStammdatenListItem), macht keinen Unterschied —
2249// beides landet im selben ablaufdatum-Feld.
2250function getVertragsfristenFromStammdaten(stammdaten) {
2251  if (!stammdaten) return [];
2252  const items = [];
2253  (stammdaten.versicherungen || []).forEach((v) => {
2254    if (v && typeof v === "object" && v.ablaufdatum) {
2255      const label = v.art || v.versicherer || "Versicherung";
2256      items.push({ title: `Versicherung läuft ab: ${label}`, date: v.ablaufdatum, vertragsart: "versicherung" });
2257    }
2258  });
2259  (stammdaten.pachtvertraege || []).forEach((p) => {
2260    if (p && typeof p === "object" && p.ablaufdatum) {
2261      const label = p.beschreibung || "Pachtvertrag";
2262      items.push({ title: `Pachtvertrag endet: ${label}`, date: p.ablaufdatum, vertragsart: "pachtvertrag" });
2263    }
2264  });
2265  return items.sort((a, b) => a.date.localeCompare(b.date));
2266}
2267
2268async function renderFristen() {
2269  fristenListEl.innerHTML = "";
2270
2271  // Statt jede Frist sofort in fester Code-Reihenfolge (erst Mehrfachantrag,
2272  // dann die übrigen Antragstypen, dann Bescheide, dann Vertragsfristen)
2273  // anzuhängen, wird hier zunächst JEDE Frist mit einem konkreten
2274  // "sortDate" gesammelt (das tatsächliche NÄCHSTE Datum, unabhängig davon,
2275  // ob dafür gerade ein lebendiger Countdown oder ein ruhiger "Termin
2276  // abgelaufen"-Hinweis angezeigt wird) und erst ganz am Ende gemeinsam
2277  // chronologisch aufsteigend sortiert gerendert — die zeitlich nächste
2278  // Frist erscheint dadurch immer zuerst, unabhängig von ihrer Quelle
2279  // (Antragstyp-Frist, eigener Bescheid oder Vertragsfrist). Fristen ganz
2280  // ohne festes Datum (z. B. "kein bundesweit einheitlicher Termin
2281  // bekannt") lassen sich naturgemäß nicht chronologisch einordnen und
2282  // erscheinen daher gesammelt in einem eigenen Abschnitt am Ende.
2283  const datierteFristen = [];
2284  const fristenOhneTermin = [];
2285
2286  // 1) Mehrfachantrag/GAP-Förderantrag: einzige Frist, die wir bundesweit
2287  // wirklich kennen (15. Mai). Ist der Termin für DIESES Jahr schon
2288  // vorbei, befinden wir uns außerhalb der Antragssaison — ein Countdown
2289  // auf den Termin im Folgejahr (oft 300+ Tage) wäre als auffällig grüne
2290  // "Noch X Tage"-Karte irreführend, weil er suggeriert, es stünde etwas
2291  // Konkretes bevor, obwohl gerade schlicht nichts zu tun ist. In diesem
2292  // Fall wird stattdessen nur ruhig informiert, wann die nächste Saison
2293  // beginnt bzw. endet, ohne Farbe/Countdown — das errechnete "deadline"
2294  // (nächstes Vorkommen) bleibt trotzdem der korrekte Sortier-Zeitpunkt.
2295  const today = new Date();
2296  const todayMidnight = new Date(today.getFullYear(), today.getMonth(), today.getDate());
2297  const currentYearDeadline = new Date(
2298    today.getFullYear(),
2299    MEHRFACHANTRAG_DEADLINE.month - 1,
2300    MEHRFACHANTRAG_DEADLINE.day,
2301  );
2302  const seasonEnded = todayMidnight > currentYearDeadline;
2303  const deadline = nextOccurrence(MEHRFACHANTRAG_DEADLINE.month, MEHRFACHANTRAG_DEADLINE.day, today);
2304
2305  if (seasonEnded) {
2306    datierteFristen.push({
2307      sortDate: deadline,
2308      el: buildFristItem({
2309        title: "Mehrfachantrag/GAP-Förderantrag",
2310        dateLabel: `Antragssaison ${currentYearDeadline.getFullYear()} ist beendet. Nächster Termin: ${formatGermanDate(deadline)}.`,
2311      }),
2312    });
2313  } else {
2314    const deadlineDays = daysUntil(deadline, today);
2315    datierteFristen.push({
2316      sortDate: deadline,
2317      el: buildFristItem({
2318        title: "Mehrfachantrag/GAP-Förderantrag – Einreichungsfrist",
2319        dateLabel: formatGermanDate(deadline),
2320        countdownLabel: `Noch ${daysLabel(deadlineDays)} bis zur Mehrfachantrag-Frist`,
2321        level: urgencyLevel(deadlineDays),
2322      }),
2323    });
2324
2325    // 2) Voraussichtlicher Portal-Start, falls für das Bundesland des
2326    // aktiven Betriebs bekannt (aktuell: Baden-Württemberg, Bayern, ...) —
2327    // nur innerhalb der laufenden Saison relevant (siehe seasonEnded oben),
2328    // sonst würde er den "Saison beendet"-Hinweis nur verdoppeln.
2329    const bundesland = window.currentBetrieb ? window.currentBetrieb.bundesland : null;
2330    const portalInfo = getMehrfachantragPortalInfo(bundesland);
2331    const startDate = portalInfo.portalStart
2332      ? nextOccurrence(portalInfo.portalStart.month, portalInfo.portalStart.day, today)
2333      : null;
2334    // Liegt der nächste Portal-Start rechnerisch NACH der nächsten
2335    // Mehrfachantrag-Frist, ist das Portal für die laufende Antragssaison
2336    // bereits geöffnet (wir sind also mitten in der Saison) — dann bringt
2337    // ein Countdown auf den nächstjährigen Start nichts, also wird der
2338    // Hinweis in diesem Fall ausgeblendet.
2339    if (startDate && startDate <= deadline) {
2340      const startDays = daysUntil(startDate, today);
2341      datierteFristen.push({
2342        sortDate: startDate,
2343        el: buildFristItem({
2344          title: `Voraussichtlicher Portal-Start (${portalInfo.portal}) in ${bundesland}`,
2345          dateLabel: `ca. ${portalInfo.portalStart.label} ${startDate.getFullYear()}`,
2346          countdownLabel: `Noch ca. ${daysLabel(startDays)} bis zum voraussichtlichen Portal-Start`,
2347          level: urgencyLevel(startDays),
2348        }),
2349      });
2350    }
2351  }
2352
2353  // 3) Weitere Antragstypen: jeweils eine einzelne, jährlich wiederkehrende
2354  // Meldefrist (kein Portal-Öffnung/-Schließung-Zyklus wie beim
2355  // Mehrfachantrag). Ist der Termin für DIESES Jahr schon vorbei, gilt
2356  // dieselbe Überlegung wie beim Mehrfachantrag oben: ein auffällig grüner
2357  // Countdown auf den Termin im Folgejahr (oft 300+ Tage) wäre irreführend,
2358  // weil er ein bevorstehendes Ereignis suggeriert, obwohl gerade nichts zu
2359  // tun ist — stattdessen nur ruhiger Hinweis auf den nächsten Termin, ohne
2360  // Farbe/Countdown (auch hier bleibt "nextDate" trotzdem der korrekte
2361  // Sortier-Zeitpunkt).
2362  [
2363    { config: ANTRAGSTYPEN.bg_jahresmeldung, deadline: BG_JAHRESMELDUNG_DEADLINE },
2364    { config: ANTRAGSTYPEN.duengeverordnung, deadline: DUENGEVERORDNUNG_DEADLINE },
2365    // Die Ausgleichszulage wird in den meisten Bundesländern zusammen mit
2366    // dem Mehrfachantrag im selben Portal beantragt (siehe checklistNote
2367    // in antragstypen.js) — deshalb dieselbe Frist wie Teil 1 oben, aber
2368    // mit eigenem Kartentitel, damit klar bleibt, warum das Datum identisch
2369    // ist.
2370    {
2371      config: ANTRAGSTYPEN.ausgleichszulage,
2372      deadline: MEHRFACHANTRAG_DEADLINE,
2373      titleOverride: `${ANTRAGSTYPEN.ausgleichszulage.label} – Frist (i. d. R. gemeinsam mit dem Mehrfachantrag)`,
2374    },
2375    // Nur die jährliche Stichtagsmeldung für Schafe/Ziegen (Bestand zum
2376    // 1. Januar) hat ein festes Kalenderdatum — die laufenden Zugangs-/
2377    // Abgangsmeldungen (auch bei Rindern) sind ereignisbezogen (7-Tage-
2378    // Frist ab dem jeweiligen Ereignis) und lassen sich nicht als
2379    // wiederkehrender Kalendertermin abbilden, daher hier absichtlich nicht
2380    // enthalten.
2381    {
2382      config: ANTRAGSTYPEN.hit_meldung,
2383      deadline: HIT_STICHTAGSMELDUNG_DEADLINE,
2384      titleOverride: "HIT-Stichtagsmeldung Schafe/Ziegen – Frist",
2385    },
2386    // EEG-Jahresmeldung an den Netzbetreiber (Erzeugungs-/Einspeisemengen,
2387    // Substrat-Mix) — bundesweit einheitlich, siehe Kommentar bei
2388    // BIOGAS_EEG_JAHRESMELDUNG_DEADLINE in antragstypen.js. Die
2389    // ereignisbezogene MaStR-Registrierung selbst hat keinen festen
2390    // Jahrestermin und erscheint daher hier bewusst nicht.
2391    {
2392      config: ANTRAGSTYPEN.biogas_meldung,
2393      deadline: BIOGAS_EEG_JAHRESMELDUNG_DEADLINE,
2394      titleOverride: "Biogasanlagen-EEG-Jahresmeldung – Frist",
2395    },
2396  ].forEach(({ config, deadline: fristDeadline, titleOverride }) => {
2397    const currentYearFristDeadline = new Date(today.getFullYear(), fristDeadline.month - 1, fristDeadline.day);
2398    const fristDeadlinePassed = todayMidnight > currentYearFristDeadline;
2399    const nextDate = nextOccurrence(fristDeadline.month, fristDeadline.day, today);
2400    const title = titleOverride || `${config.label} – Frist`;
2401
2402    if (fristDeadlinePassed) {
2403      datierteFristen.push({
2404        sortDate: nextDate,
2405        el: buildFristItem({
2406          title,
2407          dateLabel: `Termin ${currentYearFristDeadline.getFullYear()} ist bereits abgelaufen. Nächster Termin: ${formatGermanDate(nextDate)}.`,
2408        }),
2409      });
2410    } else {
2411      const days = daysUntil(nextDate, today);
2412      datierteFristen.push({
2413        sortDate: nextDate,
2414        el: buildFristItem({
2415          title,
2416          dateLabel: formatGermanDate(nextDate),
2417          countdownLabel: `Noch ${daysLabel(days)} bis zur Frist`,
2418          level: urgencyLevel(days),
2419        }),
2420      });
2421    }
2422  });
2423
2424  // Initiative Tierwohl (ITW): anders als die übrigen Fristen oben kein
2425  // einzelner Jahrestermin, sondern vier wiederkehrende Quartalstermine
2426  // (siehe ITW_QUARTALSMELDUNG_DEADLINES in antragstypen.js) — daher ein
2427  // eigener Block statt Teil der forEach-Schleife oben. Der Abstand
2428  // zwischen zwei Quartalsterminen ist immer nur rund drei Monate, ein
2429  // "abgelaufen dieses Jahr"-Zustand wie beim Mehrfachantrag (mit
2430  // monatelanger Pause bis zum nächsten Termin) kommt hier nicht vor —
2431  // deshalb immer ein lebendiger Countdown, ohne die neutrale
2432  // "Saison beendet"-Darstellung.
2433  {
2434    const itwNextDate = nextOccurrenceFromSet(ITW_QUARTALSMELDUNG_DEADLINES, today);
2435    const itwDays = daysUntil(itwNextDate, today);
2436    datierteFristen.push({
2437      sortDate: itwNextDate,
2438      el: buildFristItem({
2439        title: `${ANTRAGSTYPEN.itw_quartalsmeldung.label} – Frist`,
2440        dateLabel: formatGermanDate(itwNextDate),
2441        countdownLabel: `Noch ${daysLabel(itwDays)} bis zur nächsten Quartalsmeldung`,
2442        level: urgencyLevel(itwDays),
2443      }),
2444    });
2445  }
2446
2447  // Antibiotika-Maßnahmenplan (TAM-Meldung): wie ITW zwei statt eines
2448  // Jahrestermins (14. Juli/14. Januar, siehe TAM_MELDUNG_DEADLINES in
2449  // antragstypen.js) — auch hier maximal ~6 Monate zwischen zwei Terminen,
2450  // daher ebenfalls immer ein lebendiger Countdown ohne "Saison beendet"-
2451  // Zustand. Die zusätzliche, nur bedingt geltende Maßnahmenplan-Frist zum
2452  // 1. April (nur bei Überschreitung der Kennzahl 2) erscheint bewusst
2453  // nicht hier, sondern nur in der Checkliste.
2454  {
2455    const tamNextDate = nextOccurrenceFromSet(TAM_MELDUNG_DEADLINES, today);
2456    const tamDays = daysUntil(tamNextDate, today);
2457    datierteFristen.push({
2458      sortDate: tamNextDate,
2459      el: buildFristItem({
2460        title: `${ANTRAGSTYPEN.tam_meldung.label} – Frist`,
2461        dateLabel: formatGermanDate(tamNextDate),
2462        countdownLabel: `Noch ${daysLabel(tamDays)} bis zur nächsten Halbjahresmeldung`,
2463        level: urgencyLevel(tamDays),
2464      }),
2465    });
2466  }
2467
2468  // Kein bundesweit einheitlicher/fester Termin bekannt: Krankenkasse/
2469  // Alterskasse hängt vom individuellen Versicherungsverhältnis ab,
2470  // Tierhaltungskennzeichnung ist eine einmalige/ereignisbezogene Meldung
2471  // (Aufnahme der Haltung bzw. Änderung der Haltungsform) statt einer
2472  // jährlich wiederkehrenden Frist, die Meldepflicht Nebentätigkeiten hat
2473  // keine einzelne zuständige Stelle mit fester bundesweiter Frist,
2474  // Weinbaukartei/Bienenseuchenkasse/Junglandwirte-Förderung/
2475  // Hagelversicherungs-Zuschuss/EU-Schulprogramm werden länderweise mit
2476  // unterschiedlichen Stichtagen geführt, die Agrarstrukturerhebung findet
2477  // nur in ausgewählten Erhebungsjahren statt (kein jährlicher Rhythmus),
2478  // GLÖZ 2 und Wasserentnahme sind an individuelle Verfahren/Erlaubnisse
2479  // gekoppelt, und der Wolfsriss-Entschädigungsantrag ist rein
2480  // ereignisbezogen (24-Stunden-Meldefrist ab dem Rissereignis, kein
2481  // Kalendertermin) — siehe checklistNote je Typ in antragstypen.js für
2482  // Details. Daher bei allen zwölf nur ein reiner Hinweistext ohne
2483  // Countdown/Farbe UND ohne sortierbares Datum — sie landen gesammelt im
2484  // Abschnitt "Ohne festen Termin" am Ende, statt (mangels Datum) beliebig
2485  // zwischen den chronologisch sortierten Fristen zu erscheinen.
2486  [
2487    {
2488      config: ANTRAGSTYPEN.krankenkasse_fragebogen,
2489      hint: "Kein bundesweit einheitlicher Termin bekannt — bitte in Ihren Unterlagen oder beim zuständigen Träger nachfragen.",
2490    },
2491    {
2492      config: ANTRAGSTYPEN.tierhaltungskennzeichnung,
2493      hint: "Kein fester jährlicher Termin — Meldung bei Aufnahme der Haltung bzw. bei Änderung der Haltungsform, je nach zuständiger Landesbehörde.",
2494    },
2495    {
2496      config: ANTRAGSTYPEN.nebentaetigkeiten,
2497      hint: "Kein bundesweit einheitlicher Termin bekannt — je nach Betrieb sind Finanzamt, SVLFG oder Gewerbeamt zuständig.",
2498    },
2499    {
2500      config: ANTRAGSTYPEN.weinbaukartei,
2501      hint: "Termin variiert je Bundesland (z. B. Bayern/Baden-Württemberg Stichtag 31. Mai, Rheinland-Pfalz bis 7. August) — Details in der Checkliste.",
2502    },
2503    {
2504      config: ANTRAGSTYPEN.bienenseuchenkasse,
2505      hint: "Termin variiert je Bundesland (z. B. Bayern bis 20. Januar, Nordrhein-Westfalen bis 31. Januar) — Details in der Checkliste.",
2506    },
2507    {
2508      config: ANTRAGSTYPEN.junglandwirte,
2509      hint: "Termin variiert je Bundesland (z. B. Brandenburg: 36-Monats-Fenster nach der Niederlassung; Thüringen: nächste Runde bis 31. Januar 2026) — Details in der Checkliste.",
2510    },
2511    {
2512      config: ANTRAGSTYPEN.agrarstrukturerhebung,
2513      hint: "Kein jährlicher Termin — findet nur in ausgewählten Erhebungsjahren statt (zuletzt 2023, 2026). Ob Ihr Betrieb betroffen ist, steht im amtlichen Anschreiben.",
2514    },
2515    {
2516      config: ANTRAGSTYPEN.gloez2_ueberpruefung,
2517      hint: "Termin variiert je Bundesland und Verfahrensstand — bitte bei Ihrer Landwirtschaftskammer den aktuellen Stand der Kulisse und die Überprüfungsfrist erfragen.",
2518    },
2519    {
2520      config: ANTRAGSTYPEN.wolfsriss_entschaedigung,
2521      hint: "Ereignisbezogen: Meldung innerhalb von 24 Stunden nach dem Rissereignis an die zuständige Stelle — kein fester Kalendertermin.",
2522    },
2523    {
2524      config: ANTRAGSTYPEN.hagelversicherung_zuschuss,
2525      hint: "Termin variiert je Bundesland (nicht überall verfügbar; wo gefördert meist 15. Mai, Sachsen-Anhalt abweichend) — Details in der Checkliste.",
2526    },
2527    {
2528      config: ANTRAGSTYPEN.schulprogramm_eu,
2529      hint: "Termin variiert je Bundesland (z. B. Schleswig-Holstein 8. Mai, Niedersachsen/Bremen/Hamburg 21. April, NRW 7. August) — Details in der Checkliste.",
2530    },
2531    {
2532      config: ANTRAGSTYPEN.wasserentnahme,
2533      hint: "Ereignisbezogen/variiert je Erlaubnis — wasserrechtliche Erlaubnisse laufen oft 15–20 Jahre ohne jährlich wiederkehrenden Termin.",
2534    },
2535    {
2536      config: ANTRAGSTYPEN.psm_dokumentation,
2537      hint: "Ereignisbezogen: bei jeder Pflanzenschutzmittel-Anwendung neu zu dokumentieren — kein fester Kalendertermin.",
2538    },
2539  ].forEach(({ config, hint }) => {
2540    fristenOhneTermin.push(
2541      buildFristItem({
2542        title: config.label,
2543        dateLabel: hint,
2544      }),
2545    );
2546  });
2547
2548  // 4) Eigene Bescheide: vom Nutzer hochgeladene Behörden-Bescheide mit
2549  // einer von der KI erkannten Frist (siehe "Bescheide & Schreiben" im
2550  // Betriebsprofil). Anders als alle Fristen oben ist frist_datum ein
2551  // EINMALIGES konkretes Datum (z. B. "2026-08-15"), kein wiederkehrendes
2552  // Jahresmuster — es gibt daher keinen "nächstes Jahr"-Termin: liegt die
2553  // Frist in der Vergangenheit, wird das als dringlicher Hinweis gezeigt
2554  // (nicht als ruhige "Saison beendet"-Meldung wie bei den obigen
2555  // Antragsfristen) UND sortiert sich durch ihr (vergangenes) Datum
2556  // automatisch ganz nach vorn — genau richtig, da eine bereits
2557  // abgelaufene eigene Frist die dringlichste ist. Nur offene (noch nicht
2558  // als "erledigt" markierte) Bescheide erscheinen hier — ein erledigter
2559  // Bescheid bleibt zwar in der Liste im Betriebsprofil sichtbar, aber
2560  // nicht mehr als offene Frist.
2561  try {
2562    const bescheideResponse = await fetchWithTimeout("/api/bescheide", { method: "GET" }, APP_CONFIG.exportTimeoutMs);
2563    if (bescheideResponse.ok) {
2564      const bescheide = await bescheideResponse.json();
2565      const offeneFristen = (Array.isArray(bescheide) ? bescheide : []).filter((b) => b.fristDatum && b.status === "offen");
2566
2567      offeneFristen.forEach((bescheid) => {
2568        const fristDate = new Date(`${bescheid.fristDatum}T00:00:00`);
2569        const title = bescheid.originalFilename ? `Eigener Bescheid: ${bescheid.originalFilename}` : "Eigener Bescheid";
2570
2571        if (fristDate < todayMidnight) {
2572          datierteFristen.push({
2573            sortDate: fristDate,
2574            el: buildFristItem({
2575              title,
2576              dateLabel: `Frist bereits abgelaufen am ${formatGermanDate(fristDate)}. Bitte prüfen Sie umgehend im Betriebsprofil, ob noch reagiert werden kann.`,
2577              level: "rot",
2578            }),
2579          });
2580        } else {
2581          const days = daysUntil(fristDate, today);
2582          datierteFristen.push({
2583            sortDate: fristDate,
2584            el: buildFristItem({
2585              title,
2586              dateLabel: formatGermanDate(fristDate),
2587              countdownLabel: `Noch ${daysLabel(days)} – automatisch aus einem hochgeladenen Bescheid erkannt, bitte den Originalbescheid selbst prüfen`,
2588              level: urgencyLevel(days),
2589            }),
2590          });
2591        }
2592      });
2593    }
2594  } catch (err) {
2595    console.error("Bescheid-Fristen konnten nicht geladen werden:", err);
2596  }
2597
2598  // 5) Vertragsfristen: Ablauf-/Kündigungstermine aus den manuellen
2599  // Stammdaten (Versicherungen/Pachtverträge, siehe betrieb-profil.js) —
2600  // gleiche Einmaldatum-Logik wie bei "Eigene Bescheide" oben (kein
2601  // wiederkehrendes Jahresmuster).
2602  try {
2603    const profilResponse = await fetchWithTimeout("/api/betrieb-profil", { method: "GET" }, APP_CONFIG.exportTimeoutMs);
2604    if (profilResponse.ok) {
2605      const profilData = await profilResponse.json();
2606      const vertragsfristen = getVertragsfristenFromStammdaten(profilData.stammdaten);
2607
2608      vertragsfristen.forEach(({ title, date }) => {
2609        const fristDate = new Date(`${date}T00:00:00`);
2610        if (fristDate < todayMidnight) {
2611          datierteFristen.push({
2612            sortDate: fristDate,
2613            el: buildFristItem({
2614              title,
2615              dateLabel: `Bereits abgelaufen am ${formatGermanDate(fristDate)}. Bitte im Betriebsprofil prüfen, ob eine Verlängerung/Kündigung nötig ist.`,
2616              level: "rot",
2617            }),
2618          });
2619        } else {
2620          const days = daysUntil(fristDate, today);
2621          datierteFristen.push({
2622            sortDate: fristDate,
2623            el: buildFristItem({
2624              title,
2625              dateLabel: formatGermanDate(fristDate),
2626              countdownLabel: `Noch ${daysLabel(days)} – aus den Stammdaten im Betriebsprofil`,
2627              level: urgencyLevel(days),
2628            }),
2629          });
2630        }
2631      });
2632    }
2633  } catch (err) {
2634    console.error("Vertragsfristen konnten nicht geladen werden:", err);
2635  }
2636
2637  // Alle terminierten Fristen jetzt gemeinsam nach Datum aufsteigend
2638  // sortiert rendern — die zeitlich nächste (bzw. am weitesten überfällige)
2639  // Frist erscheint dadurch immer zuerst, unabhängig davon, aus welchem
2640  // der obigen Blöcke sie stammt.
2641  datierteFristen.sort((a, b) => a.sortDate - b.sortDate);
2642  datierteFristen.forEach(({ el }) => fristenListEl.appendChild(el));
2643
2644  if (fristenOhneTermin.length > 0) {
2645    const subheading = document.createElement("h3");
2646    subheading.className = "fristen-subheading";
2647    subheading.textContent = "Ohne festen Termin";
2648    fristenListEl.appendChild(subheading);
2649    fristenOhneTermin.forEach((el) => fristenListEl.appendChild(el));
2650  }
2651}
2652
2653// --- Fehlermeldung verstehen ------------------------------------------------
2654
2655function resetErrorHelper() {
2656  errorHelperSection.classList.add("hidden");
2657  errorHelperInput.value = "";
2658  errorHelperStatus.classList.add("hidden");
2659  errorHelperResult.classList.add("hidden");
2660  errorHelperExplanation.textContent = "";
2661}
2662
2663errorHelperSubmitButton.addEventListener("click", async () => {
2664  const message = errorHelperInput.value.trim();
2665
2666  errorHelperResult.classList.add("hidden");
2667  errorHelperStatus.classList.remove("hidden", "extraction-error");
2668
2669  if (!message) {
2670    errorHelperStatus.textContent = "Bitte geben Sie eine Fehlermeldung ein.";
2671    errorHelperStatus.classList.add("extraction-error");
2672    return;
2673  }
2674
2675  errorHelperStatus.textContent = "Fehlermeldung wird analysiert …";
2676  errorHelperSubmitButton.disabled = true;
2677
2678  try {
2679    const response = await fetchWithTimeout(
2680      "/api/explain-error",
2681      {
2682        method: "POST",
2683        headers: { "Content-Type": "application/json" },
2684        body: JSON.stringify({ message }),
2685      },
2686      APP_CONFIG.extractionTimeoutMs + 10000,
2687    );
2688    const data = await parseJsonSafely(response);
2689
2690    if (!response.ok) {
2691      throw new Error((data && data.error) || "Die Erklärung konnte nicht erstellt werden.");
2692    }
2693    if (!data || !data.explanation) {
2694      throw new Error("Die Antwort des Servers konnte nicht gelesen werden. Bitte versuchen Sie es erneut.");
2695    }
2696
2697    errorHelperExplanation.textContent = data.explanation;
2698    errorHelperResult.classList.remove("hidden");
2699    errorHelperStatus.classList.add("hidden");
2700  } catch (err) {
2701    console.error(err);
2702    errorHelperStatus.textContent =
2703      err.message || "Die Erklärung konnte nicht erstellt werden. Bitte versuchen Sie es erneut.";
2704    errorHelperStatus.classList.add("extraction-error");
2705  } finally {
2706    errorHelperSubmitButton.disabled = false;
2707  }
2708});
2709
2710// --- Verbindung zum Login/Logout (siehe auth-ui.js) ------------------------
2711
2712function resetAppState() {
2713  selectedType = null;
2714  hasUploadedDocument = false;
2715  currentFile = null;
2716  currentFileName = null;
2717  currentApplicationId = null;
2718  documentQueue = [];
2719  currentQueueDocId = null;
2720
2721  document.querySelectorAll(".type-option").forEach((button) => button.classList.remove("selected"));
2722  fieldsContainer.innerHTML = "";
2723  uploadSection.classList.add("hidden");
2724  previewSection.classList.add("hidden");
2725  applicationsList.innerHTML = "";
2726  fileInput.value = "";
2727  fileNameEl.textContent = "";
2728  filePreview.innerHTML = "";
2729  hideUploadError();
2730  extractionStatus.classList.add("hidden");
2731  extractionStatus.classList.remove("extraction-loading");
2732  uploadProgressEl.classList.add("hidden");
2733  saveSuccessBanner.classList.add("hidden");
2734  resetExportUi();
2735  resetErrorHelper();
2736  hideDuengungHint();
2737  fristenListEl.innerHTML = "";
2738  renderDocumentQueue();
2739}
2740
2741// Läuft sowohl nach dem Login (neuer Nutzer, erster aktiver Betrieb steht
2742// fest) als auch nach jedem Betriebswechsel (siehe auth-ui.js:
2743// window.onActiveBetriebChanged) — ein begonnener Upload/eine offene
2744// Vorschau gehört zum vorherigen Betrieb und wird daher verworfen.
2745// "Meine gespeicherten Anträge"/die Fristen-Karte selbst werden nicht mehr
2746// hier geladen, sondern jedes Mal frisch beim Öffnen der jeweiligen
2747// Sidebar-Seite (siehe sidebar.js) — so zeigen sie auch dann den aktuellen
2748// Betrieb korrekt an, wenn der Wechsel auf der "Meine Betriebe"-Seite
2749// passiert ist, ohne dass "Antrag stellen" gerade sichtbar war.
2750function onBetriebReady() {
2751  resetAppState();
2752  applyRoleRestrictionsGlobal();
2753}
2754
2755window.onUserAuthenticated = onBetriebReady;
2756window.onActiveBetriebChanged = onBetriebReady;
2757
2758window.onUserLoggedOut = function onUserLoggedOut() {
2759  resetAppState();
2760  window.currentBetrieb = null;
2761  window.betriebe = [];
2762};

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.