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.