"use strict"; const TABS = [ { id: "stammdaten", label: "Stammdaten", icon: "i-id", color: "var(--tab-1)" }, { id: "anamnese", label: "Anamnese", icon: "i-clip", color: "var(--tab-2)" }, { id: "notizen", label: "Notizen", icon: "i-pencil", color: "var(--tab-3)" }, { id: "behandlung", label: "Behandlung", icon: "i-paw", color: "var(--tab-4)" }, { id: "verlauf", label: "Verlauf", icon: "i-clock", color: "var(--tab-5)" }, { id: "labor", label: "Labor", icon: "i-tube", color: "var(--tab-6)" }, { id: "bilder", label: "Bilder", icon: "i-image", color: "var(--tab-7)" }, { id: "dokumente", label: "Dokumente", icon: "i-file", color: "var(--tab-8)" }, { id: "datenschutz", label: "Datenschutz", icon: "i-shield", color: "var(--tab-9)" }, { id: "vertrag", label: "Behandlungsvertrag", icon: "i-contract", color: "var(--tab-10)" }, ]; const SPECIES_ICON = { dog: "i-dog", cat: "i-cat", rabbit: "i-rabbit", tortoise: "i-turtle", other: "i-paw" }; const SPECIES_COLOR = { dog: "var(--tab-1)", cat: "var(--tab-6)", rabbit: "var(--tab-3)", tortoise: "var(--tab-4)", other: "var(--tab-9)" }; const SPECIES_LABEL_DEFAULT = { dog: "Hund", cat: "Katze", rabbit: "Kaninchen", tortoise: "Griechische Landschildkröte", other: "Tier" }; // Real skeleton illustrations from the practice's own Patientenakte template (used with // permission — see conversation). Natural pixel size, used to keep the body-map stage's // aspect ratio matched so object-fit:contain never letterboxes (which would throw off // the percentage-based pin coordinates). const SILHOUETTE_IMAGE = { dog: "skeleton-dog.png", cat: "skeleton-cat.png", rabbit: "skeleton-rabbit.png" }; const SILHOUETTE_ASPECT = { dog: 691 / 508, cat: 731 / 443, rabbit: 592 / 525 }; const SEV = { ok: { label: "unauffällig", color: "var(--ok)" }, warn: { label: "beobachten", color: "var(--warn)" }, critical: { label: "auffällig", color: "var(--critical)" }, }; let directory = []; let filterText = ""; let noteMatchIds = new Set(); // animal ids whose Notizen match the current search let searchToken = 0; let currentAnimalId = null; let bundle = null; let activeTab = "stammdaten"; let bmPopup = null; let showSigPad = false; let showContractSigPad = false; let anamneseExpanded = false; let emailStatus = {}; // { einwilligung: {kind,msg}, vertrag: {kind,msg} } let viewMode = "patients"; // "patients" | "calendar" const today = new Date(); let calYear = today.getFullYear(); let calMonth = today.getMonth(); // 0-indexed let calAppointments = []; // appointments for the currently visible grid range const APPOINTMENT_TYPES = [ { label: "Erstanamnese", duration: 120 }, { label: "Folgeuntersuchung", duration: 45 }, { label: "Telefonische Beratung", duration: 15 }, { label: "Kurzcheck", duration: 15 }, { label: "Ernährungsberatung", duration: 45 }, ]; const WEEKDAY_LABELS = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"]; const MONTH_LABELS = ["Januar", "Februar", "März", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober", "November", "Dezember"]; // Mirrors the practice's real "Fragebogen Erstanamnese" paper form — everything that // isn't already captured in Stammdaten/Besitzer. Stored as one JSON object per animal. const ANAMNESE_SCHEMA = [ { title: "Herkunft des Tieres", fields: [ { key: "h_farbe", label: "Farbe / Merkmale", type: "text" }, { key: "h_erkennung", label: "Erkennung", type: "check", options: ["gechipt", "tätowiert", "Brandzeichen"] }, { key: "h_gewichtstrend", label: "Gewichtstrend", type: "check", options: ["Zunahme", "Abnahme"] }, { key: "h_zuchttier", label: "Zuchttier", type: "radio", options: ["Nein", "Ja", "trächtig", "säugend"] }, { key: "h_letzte_geburt", label: "Letzte Geburt", type: "text" }, { key: "h_anzahl_geburten", label: "Anzahl Geburten bisher", type: "text" }, { key: "h_lebensmittel_tier", label: "Sonstiges", type: "check", options: ["Lebensmittel lieferndes Tier"] }, { key: "h_herkunft", label: "Herkunft", type: "check", options: ["Züchter", "Tierheim Inland", "Zoohandlung", "Tierschutz Ausland", "von Privat / Vorbesitzer", "Plattform im Internet"], }, { key: "h_herkunft_sonstiges", label: "Herkunft, Sonstiges", type: "text" }, { key: "h_besonderheiten", label: "Besonderheiten", type: "textarea", wide: true }, ], }, { title: "Lebenssituation des Tieres", fields: [ { key: "l_haltung", label: "Haltungsart", type: "check", wide: true, options: [ "Haustier", "Familientier", "Hoftier", "Arbeitstier", "Sporttier", "Außenhaltung", "Zucht/Ausstellungstier", "Rudel/Gruppe", "Innen/Außen gemischt", "Balkon", "Innenhaltung", ], }, { key: "l_haltungssituation", label: "Genaue Beschreibung der Haltungssituation", type: "textarea", wide: true }, { key: "l_bei_ihnen_seit", label: "Bei Ihnen seit", type: "text" }, { key: "l_alter_bei_uebernahme", label: "Alter bei Übernahme", type: "text" }, { key: "l_vorgeschichte", label: "Was wissen Sie über die Zeit davor?", type: "textarea", wide: true }, { key: "l_eingewoehnung", label: "Wie hat sich das Tier eingewöhnt?", type: "textarea", wide: true }, { key: "l_familie_mensch", label: "Menschliche Familienmitglieder", type: "text" }, { key: "l_familie_tier", label: "Tierische Familienmitglieder", type: "text" }, { key: "l_bezugsperson", label: "Bezugsperson", type: "text" }, { key: "l_beschaeftigung", label: "Tägliche Beschäftigung", type: "textarea", wide: true }, { key: "l_training", label: "Training / Sport / „Beruf“", type: "text" }, { key: "l_alltag", label: "Alltag, Tagesablauf", type: "textarea", wide: true }, { key: "l_besonderheiten", label: "Besonderheiten", type: "text" }, { key: "l_alleine", label: "Wie oft alleine zuhause?", type: "radio", options: ["Nie", "Selten", "Regelmäßig"] }, { key: "l_alleine_stunden", label: "Stunden pro Tag", type: "text" }, ], }, { title: "Impfstatus & Parasitenkontrolle", fields: [ { key: "i_letzte_impfung", label: "Letzte Impfung am", type: "text" }, { key: "i_regelmaessig", label: "Regelmäßig geimpft?", type: "radio", options: ["Ja", "Nein"] }, { key: "i_impfungen", label: "Impfungen", type: "textarea", wide: true }, { key: "i_kotuntersuchung", label: "Letzte Kotuntersuchung", type: "text" }, { key: "i_wurmkur", label: "Letzte Wurmkur", type: "text" }, { key: "i_floh_zecken", label: "Letzte Floh-/Zeckenprophylaxe, Mittel", type: "text" }, { key: "i_ausland", label: "Kürzlich im Ausland / Urlaub?", type: "radio", options: ["Nein", "Ja"] }, { key: "i_ausland_wo", label: "Wo genau", type: "text" }, ], }, { title: "Vorstellungsgrund", fields: [ { key: "v_kategorie", label: "Kategorie", type: "check", wide: true, options: [ "Gesundheitsberatung", "Ernährungsumstellung", "Allergien/Unverträglichkeiten", "Verhaltensauffälligkeiten", "chronische Erkrankungen", "Gewichtsprobleme", "Haut-/Fellprobleme", "Magen-Darm-Beschwerden", ], }, { key: "v_sonstiges", label: "Sonstiges", type: "text" }, { key: "v_grund", label: "Grund der Vorstellung", type: "textarea", wide: true }, { key: "v_akut", label: "Akute Auffälligkeiten", type: "text" }, { key: "v_seit_wann", label: "Wie lange besteht das Problem schon?", type: "text" }, { key: "v_haeufigkeit", label: "Wie häufig tritt es auf?", type: "radio", options: ["täglich", "wöchentlich", "sporadisch"] }, { key: "v_ausloeser", label: "Auslöser oder Veränderung im Umfeld?", type: "textarea", wide: true }, ], }, { title: "Medizinische Vorgeschichte", fields: [ { key: "m_chronisch", label: "Chronische/wiederkehrende Krankheiten?", type: "radio", options: ["nein", "ja"] }, { key: "m_chronisch_welche", label: "Welche?", type: "text" }, { key: "m_operationen", label: "Frühere Operationen/Eingriffe?", type: "radio", options: ["nein", "ja"] }, { key: "m_operationen_welche", label: "Welche?", type: "text" }, { key: "m_vererbt", label: "Erkrankungen bei Vorfahren bekannt?", type: "radio", options: ["nein", "ja"] }, { key: "m_vererbt_welche", label: "Welche?", type: "text" }, { key: "m_diagnosen", label: "Aktuelle Diagnosen vom Tierarzt", type: "text", wide: true }, { key: "m_behandlungen", label: "Bisherige Behandlungen/Therapien", type: "text", wide: true }, { key: "m_medikamente", label: "Regelmäßige Medikamente/Präparate/NEM?", type: "radio", options: ["nein", "ja"] }, { key: "m_medikamente_welche", label: "Welche?", type: "text" }, ], }, { title: "Allergien und Unverträglichkeiten", fields: [ { key: "a_bekannt", label: "Bekannte Allergien/Unverträglichkeiten?", type: "radio", options: ["nein", "ja"] }, { key: "a_welche", label: "Welche?", type: "text" }, { key: "a_symptome", label: "Beschwerden äußern sich als", type: "check", options: ["Juckreiz", "Durchfall", "Erbrechen", "Rötungen/Ausschläge"] }, { key: "a_symptome_andere", label: "Andere Beschwerden", type: "text" }, ], }, { title: "Ernährung", fields: [ { key: "e_art", label: "Fütterungsart aktuell", type: "check", options: ["Trockenfutter", "Nassfutter", "BARF", "Selbstgekocht", "Mischfütterung"] }, { key: "e_sonstiges", label: "Sonstiges", type: "text" }, { key: "e_marken", label: "Marken/Produkte", type: "text", wide: true }, { key: "e_nem", label: "Nahrungsergänzungsfuttermittel", type: "text", wide: true }, { key: "e_mahlzeiten", label: "Mahlzeiten pro Tag", type: "radio", options: ["1", "2", "3", "mehr"] }, { key: "e_menge", label: "Futtermenge pro Tag (g)", type: "text" }, { key: "e_leckerlis", label: "Leckerlis/Kauartikel", type: "radio", options: ["regelmäßig", "selten", "gar nicht"] }, { key: "e_appetit", label: "Fressverhalten", type: "radio", options: ["sehr guter Appetit", "normal", "wählerisch", "frisst schlecht"] }, { key: "e_reaktionen", label: "Reaktionen auf bestimmtes Futter?", type: "textarea", wide: true }, { key: "e_trinken", label: "Trinkverhalten", type: "text" }, { key: "e_verhalten", label: "Verhaltensweisen", type: "check", options: ["Kotfressen", "Grasfressen"] }, ], }, { title: "Verdauung", fields: [ { key: "d_kotabsatz", label: "Kotabsatz", type: "radio", options: ["regelmäßig", "unregelmäßig", "häufig", "selten"] }, { key: "d_konsistenz", label: "Konsistenz", type: "radio", options: ["normal", "weich", "Durchfall", "wechselnd"] }, { key: "d_geruch", label: "Geruch", type: "radio", options: ["unauffällig", "sehr stark", "faulig", "blähend"] }, { key: "d_blaehungen", label: "Blähungen?", type: "radio", options: ["Nein", "Ja"] }, { key: "d_blaehungen_wie_oft", label: "Wie oft?", type: "text" }, { key: "d_erbrechen", label: "Erbricht gelegentlich?", type: "radio", options: ["Nein", "Ja"] }, { key: "d_erbrechen_wann", label: "Wann/wie oft?", type: "text" }, ], }, { title: "Charakter & Verhalten", fields: [ { key: "c_charakter", label: "Charakterbeschreibung (neugierig, zahm, aggressiv, ängstlich, dominant …)", type: "textarea", wide: true }, { key: "c_anfassen", label: "Lässt sich anfassen? Überall? Von wem?", type: "textarea", wide: true }, { key: "c_vorlieben", label: "Vorlieben", type: "textarea", wide: true }, { key: "c_kaelte_waerme", label: "Kälte oder Wärme?", type: "radio", options: ["Kälte", "Wärme"] }, { key: "c_naehe_abstand", label: "Nähe oder Abstand?", type: "radio", options: ["Nähe", "Abstand"] }, { key: "c_ruhe_action", label: "Ruhe oder Action?", type: "radio", options: ["Ruhe", "Action"] }, { key: "c_liegeplatz", label: "Fester oder weicher Liegeplatz?", type: "radio", options: ["Fester", "Weicher"] }, { key: "c_vertraeglich", label: "Verträglich mit", type: "check", options: ["Artgenossen", "anderen Tieren", "Menschen", "Kindern"] }, { key: "c_kontaktverhalten", label: "Verhalten bei verträglichen/unverträglichen Kontakten", type: "textarea", wide: true }, { key: "c_aengste", label: "Hat Ängste?", type: "radio", options: ["nein", "ja"] }, { key: "c_aengste_vor", label: "Vor was? Seit wann? Wie äußert es sich?", type: "text", wide: true }, { key: "c_schmerzen", label: "Äußert Schmerzen?", type: "radio", options: ["nein", "ja"] }, { key: "c_schmerzen_wie", label: "Wann, wo und wie?", type: "text", wide: true }, { key: "c_trauma", label: "Trauma-/Verlust-/Misshandlungssituationen erlebt?", type: "textarea", wide: true }, { key: "c_spiel", label: "Spielverhalten, Lieblingsspielzeug", type: "textarea", wide: true }, { key: "c_veraendert", label: "Verhalten verändert? Wie, wann?", type: "textarea", wide: true }, ], }, { title: "Schlaf", fields: [ { key: "s_verhalten", label: "Schlafverhalten", type: "radio", options: ["ungestört", "häufig unterbrochen", "ruhelos"] }, { key: "s_platz", label: "Liebster Liege-/Schlafplatz", type: "text", wide: true }, { key: "s_stunden", label: "Stunden Schlaf pro Tag", type: "text" }, { key: "s_anmerkungen", label: "Weitere Anmerkungen", type: "textarea", wide: true }, ], }, ]; // The vitals/exam-findings block from the real paper Patientenakte (dog, cat, rabbit, // guinea pig cards all share this exact layout) — captured as one dated entry per visit. const EXAM_SCHEMA = [ { key: "puls", label: "Puls" }, { key: "atmung", label: "Atmung" }, { key: "temperatur", label: "Temperatur" }, { key: "herztoene", label: "Herztöne" }, { key: "gewicht", label: "Gewicht" }, { key: "schleimhaeute", label: "Schleimhäute" }, { key: "maul_zaehne", label: "Maul / Zähne" }, { key: "augen_ohren", label: "Augen / Ohren" }, { key: "hydration", label: "Hydration" }, { key: "lymphknoten", label: "Lymphknoten" }, { key: "haut_fell", label: "Haut / Fell" }, { key: "gangbild", label: "Gangbild" }, { key: "verhalten", label: "Verhalten / Stimmung" }, ]; // The practice's real DSGVO-Einwilligung, condensed to checkable items. const CONSENT_ITEMS = [ { key: "verarbeitung_erforderlich", label: "Datenverarbeitung zur Behandlung (Anamnese, Befunderhebung, Diagnose, Therapie, Nachsorge) ist erforderlich und wird zur Kenntnis genommen." }, { key: "dokumentation_lexware", label: "Speicherung zur Dokumentation; Buchhaltungsdaten dürfen über Lexware verarbeitet und an Steuerberater/in weitergegeben werden." }, { key: "vorab_informiert", label: "Vor der Datenerhebung wurden die erforderlichen Informationen mitgeteilt." }, { key: "freiwillig", label: "Die Einwilligung erfolgt freiwillig." }, { key: "kuenftige_vertraege", label: "Daten dürfen auch für künftige Behandlungsverträge genutzt werden." }, { key: "labore", label: "Daten dürfen bei Bedarf an Untersuchungslabore übermittelt werden." }, { key: "wissenschaftlich", label: "Anonymisierte Nutzung zu wissenschaftlicher Dokumentation und fachlicher Supervision." }, { key: "rechnung_email", label: "Rechnungen dürfen per E-Mail zugestellt werden." }, { key: "praxisgemeinschaft", label: "Tierdaten dürfen mit anderen Tierheilpraktiker*innen in einer Praxisgemeinschaft besprochen werden (keine Personendaten)." }, { key: "praxisnachfolge", label: "Daten dürfen bei einer Praxisübergabe an eine Nachfolge weiter genutzt werden." }, { key: "widerruf_kenntnis", label: "Die Widerrufsbelehrung wurde zur Kenntnis genommen." }, { key: "datenschutz_aufklaerung", label: "Aufklärung über die geltenden Datenschutzrichtlinien und Rechtsgrundlagen erhalten." }, ]; // The practice's real Behandlungsvertrag (AGB), §1–§15 — read/confirm, then signed at §16. const CONTRACT_TEXT = [ { title: "§ 1 Vertragsgegenstand", html: `

Der/die Tierhalter/in nimmt eine tierheilpraktikertypische naturheilkundliche Behandlung für sein/ihr Tier in Anspruch. Der Behandlungsvertrag ist ein Dienstvertrag gemäß § 611 Abs. 1 und § 612 Abs. 1 BGB und regelt die Geschäftsbeziehungen zwischen der Mobilen Tierheilpraxis Iris Hack als Tierheilpraktikerin und dem/der Tierhalter/in, soweit zwischen den Vertragsparteien Abweichendes nicht schriftlich vereinbart wurde.

Dieser Behandlungsvertrag kommt zustande, wenn der/die Tierhalter/in das Angebot der Tierheilpraktikerin in Form von Beratung, Diagnosestellung, Untersuchung und Therapie annimmt. Eine über die Behandlung des Tieres hinausgehende Heilung wird nicht geschuldet.

Der Behandlungsvertrag gilt als rechtsverbindlich geschlossen, wenn Tierhalter/in und Tierheilpraktikerin einen ersten Termin vereinbaren – auch bei einer mündlichen Vereinbarung bzw. Zustimmung. Die Tierheilpraktikerin ist berechtigt, einen Behandlungsvertrag ohne Angabe von Gründen abzulehnen; der Honoraranspruch für bis dahin entstandene Leistungen bleibt erhalten.

`, }, { title: "§ 2 Versprechen auf Heilung", html: `

Es wird ausdrücklich darauf hingewiesen, dass keine Garantie auf Heilung oder Linderung gegeben wird. Ein Versprechen auf Heilung gemäß Heilmittelwerbegesetz (HWG) wird nicht gegeben – dies ist weder möglich noch gesetzlich zulässig.

`, }, { title: "§ 3 Aufklärung / Hinweise", html: `

Der/die Tierhalter/in wird darauf hingewiesen, dass es sich bei den angewandten Verfahren um schulmedizinisch nicht anerkannte Verfahren handelt und die Behandlung eine ärztliche Therapie nicht ersetzt. Sofern ärztlicher Rat erforderlich ist, veranlasst die Tierheilpraktikerin unverzüglich eine Weiterleitung an einen Tierarzt oder spricht eine entsprechende Empfehlung aus. Für die Erteilung einer Auskunft an Dritte ist die schriftliche Einwilligung des/der Tierhalters/in erforderlich.

`, }, { title: "§ 4 Aufklärungspflicht / Aufklärungsumfang", html: `

Die Tierheilpraktikerin ist verpflichtet, dem/der Tierhalter/in in verständlicher Weise zu Beginn der Behandlung und, soweit erforderlich, in deren Verlauf sämtliche wesentlichen Umstände zu erläutern. Mit der Unterschrift bestätigt der/die Tierhalter/in, dass folgende Punkte umfassend besprochen wurden: aktueller Gesundheitszustand des Tieres, Art der Erkrankung, Behandlungsmethode und deren voraussichtliche Dauer, zur Verfügung stehende Behandlungsalternativen, Belastungen und Risiken der Therapie, Honorar.

`, }, { title: "§ 5 Behandlung", html: `

Alle Behandlungen erfolgen auf Wunsch des/der Tierhalters/in unter der Maßgabe, das Tier ganzheitlich zu behandeln. Die Behandlungszeiten richten sich nach Vorgaben der Tierheilpraktikerin, können im Einzelfall aber länger dauern als zunächst angenommen.

Der/die Tierhalter/in verpflichtet sich, alle Fragen zum Tier und dessen Gesundheit wahrheitsgetreu zu beantworten. Die Tierheilpraktikerin ist berechtigt, die Behandlung abzubrechen, wenn das erforderliche Vertrauensverhältnis nicht mehr gegeben scheint.

`, }, { title: "§ 6 Haftung des Behandlers", html: `

Eine Haftung für leicht fahrlässige Pflichtverletzungen wird ausgeschlossen, ebenso jegliche Haftung für Schäden am Tier durch Behandlungsempfehlungen oder Therapien, soweit nach BGB zulässig. Dies gilt nicht für vorsätzliche und grob fahrlässige Pflichtverletzungen sowie für Verletzungen von Leben, Körper oder Gesundheit oder von Kardinalpflichten; in diesem Fall ist der Schadensersatz auf die bei Vertragsschluss vorhersehbaren, vertragstypischen Schäden begrenzt.

`, }, { title: "§ 7 Honorar, Kostenerstattung", html: `

Nach Dienstvertragsrecht (§ 612 BGB) gilt eine Abrechnung im üblichen Rahmen als vereinbart. Geschuldet wird die Leistung, nicht der Erfolg – kein Erfolgshonorar. Das Honorar berechnet sich nach Leistungs- und/oder Zeitaufwand und ist unmittelbar zur Zahlung fällig (bar, EC-Cash oder PayPal); eine Quittung wird ausgestellt, eine detaillierte Rechnung folgt innerhalb einer Woche.

Die Gebühren orientieren sich an den Richtlinien des Verbands Deutscher Tierheilpraktiker e.V.; die aktuelle Preisliste liegt vor und wurde zur Kenntnis genommen. Bei Zahlungsverzug wird eine Mahngebühr von 5,00 € erhoben; danach kann der Vorgang einem Inkassobüro übergeben werden. Die Abrechnung erfolgt ohne Mehrwertsteuer gemäß Kleinunternehmerregelung § 19 UStG.

`, }, { title: "§ 8 Terminvereinbarung", html: `

Termine gelten als vertraglich vereinbart, wenn sie schriftlich, mündlich oder persönlich bestätigt wurden. Bei Hausbesuchen kann es zu unvorhersehbaren Verzögerungen kommen. Termine müssen mindestens 24 Stunden im Voraus telefonisch abgesagt werden.

`, }, { title: "§ 9 Ausfallhonorar", html: `

Bei kurzfristigem Rücktritt oder Versäumen eines Termins wird ein Ausfallhonorar in Höhe von 100 % des reservierten Zeitfensters fällig, sofern nicht mindestens 24 Stunden vorher abgesagt wurde oder ein unverschuldetes Hindernis (höhere Gewalt) nachgewiesen wird. Bei kürzerfristiger Absage wird pauschal ein Ausfallhonorar von 50,00 € berechnet.

`, }, { title: "§ 10 Beratung / Nachbetreuung über Telefon oder Videocall", html: `

Telefon- oder Videoberatung ersetzt die reguläre Behandlung nicht und wird zusätzlich zu persönlichen Terminen angeboten. Über Telefon oder Videocall können keine Diagnosen gestellt werden.

`, }, { title: "§ 11 Laborkosten / Kosten für Medikamente", html: `

Tierheilpraktiker*innen dürfen keine verschreibungs- oder apothekenpflichtigen Arzneimittel verschreiben oder abgeben; die Direktverabreichung durch die Tierheilpraktikerin ist zulässig. Empfehlungen von Produkten können gegen Provision erfolgen. Vom/von der Tierhalter/in mitgebrachte Arzneimittel dürfen nicht angewendet werden. Kosten für Laboruntersuchungen durch ein Fremdlabor werden zusätzlich in Rechnung gestellt.

`, }, { title: "§ 12 Datenschutz / Einverständniserklärung Datenerhebung", html: `

Die Tierheilpraktikerin erhebt und verarbeitet personenbezogene Daten ausschließlich im Rahmen der vertraglichen Vereinbarungen und behandelt Patientendaten streng vertraulich. Die separate datenschutzrechtliche Einwilligung gemäß Art. 7 DSGVO wurde erhalten, verstanden und unterzeichnet. Handakten werden 10 Jahre nach der letzten Behandlung bzw. 5 Jahre nach dem Tod des Patienten vernichtet.

`, }, { title: "§ 13 Salvatorische Klausel", html: `

Sollten einzelne Bestimmungen unwirksam sein oder werden, bleibt die Wirksamkeit des Vertrages im Übrigen unberührt; die Bestimmung wird durch eine dem Vertragszweck möglichst nahekommende Regelung ersetzt.

`, }, { title: "§ 14 Erfüllungsort und Gerichtsstand", html: `

Erfüllungsort: Leonberg, Baden-Württemberg. Gerichtsstand für beide Parteien: Amtsgericht Stuttgart.

`, }, { title: "§ 15 Widerrufsrecht / Widerrufsbelehrung", html: `

Der Vertrag kann binnen vierzehn Tagen ohne Angabe von Gründen widerrufen werden. Zur Fristwahrung genügt die rechtzeitige Absendung der Widerrufserklärung per Post oder E-Mail an die Tierheilpraktikerin.

`, }, ]; function esc(s) { return String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c])); } function fmtDate(d) { return d ? new Date(d).toLocaleDateString("de-DE") : "—"; } // ---------------- boot ---------------- async function boot() { document.body.dataset.view = viewMode; directory = await window.api.listDirectory(); renderDirectory(); document.getElementById("new-owner-btn").addEventListener("click", openNewOwnerModal); document.getElementById("settings-btn").addEventListener("click", openSmtpSettingsModal); document.getElementById("lexware-btn").addEventListener("click", openLexware); document.querySelectorAll(".view-switch-btn").forEach((btn) => btn.addEventListener("click", () => switchView(btn.dataset.view))); // Narrow-screen master/detail: goes back from an open Mappe to the Besitzer/Tier-Liste. document.getElementById("mobile-back-btn").addEventListener("click", () => { currentAnimalId = null; document.body.classList.remove("has-animal"); document.getElementById("folder-view").hidden = true; document.getElementById("empty-state").hidden = false; renderDirectory(); }); document.getElementById("search-input").addEventListener("input", (e) => { filterText = e.target.value.trim().toLowerCase(); renderDirectory(); const token = ++searchToken; if (filterText.length < 2) { noteMatchIds = new Set(); return; } window.api.searchNotes(filterText).then((ids) => { if (token !== searchToken) return; // a newer keystroke already superseded this search noteMatchIds = new Set(ids); renderDirectory(); }); }); document.getElementById("modal-overlay").addEventListener("click", (e) => { if (e.target.id === "modal-overlay") closeModal(); }); } // ---------------- calendar ---------------- function switchView(mode) { viewMode = mode; document.body.dataset.view = mode; document.querySelectorAll(".view-switch-btn").forEach((b) => b.classList.toggle("active", b.dataset.view === mode)); document.getElementById("calendar-view").hidden = mode !== "calendar"; document.getElementById("remedies-view").hidden = mode !== "remedies"; document.getElementById("empty-state").hidden = mode !== "patients" || !!currentAnimalId; document.getElementById("folder-view").hidden = mode !== "patients" || !currentAnimalId; if (mode === "calendar") renderCalendar(); if (mode === "remedies") renderRemedies(); } function pad2(n) { return String(n).padStart(2, "0"); } function isoDate(y, m, d) { return `${y}-${pad2(m + 1)}-${pad2(d)}`; } function isoToday() { const t = new Date(); return isoDate(t.getFullYear(), t.getMonth(), t.getDate()); } function formatIsoDateLong(iso) { const [y, m, d] = iso.split("-").map(Number); return `${d}. ${MONTH_LABELS[m - 1]} ${y}`; } function backupInfoText(info) { if (!info.count) return "Noch keine Sicherung vorhanden."; // backup folder names are "YYYY-MM-DD" or "YYYY-MM-DD_HHMMSS" for same-day manual backups return `${info.count} Sicherung${info.count === 1 ? "" : "en"} vorhanden — letzte: ${formatIsoDateLong(info.last.slice(0, 10))}`; } function formatIsoDateShort(iso) { const [y, m, d] = iso.split("-").map(Number); return `${pad2(d)}.${pad2(m)}.`; } async function renderCalendar() { const container = document.getElementById("calendar-view"); // Monday-start 6-week (42 day) grid covering the visible month plus padding days. const firstOfMonth = new Date(calYear, calMonth, 1); const firstWeekday = (firstOfMonth.getDay() + 6) % 7; // 0=Mon const gridStart = new Date(calYear, calMonth, 1 - firstWeekday); const days = Array.from({ length: 42 }, (_, i) => { const d = new Date(gridStart); d.setDate(gridStart.getDate() + i); return d; }); const fromIso = isoDate(days[0].getFullYear(), days[0].getMonth(), days[0].getDate()); const toIso = isoDate(days[41].getFullYear(), days[41].getMonth(), days[41].getDate()); calAppointments = await window.api.listAppointments(fromIso, toIso); const byDate = {}; calAppointments.forEach((a) => (byDate[a.date] ??= []).push(a)); const todayIso = isoToday(); const dayCells = days .map((d) => { const iso = isoDate(d.getFullYear(), d.getMonth(), d.getDate()); const inMonth = d.getMonth() === calMonth; const items = (byDate[iso] || []).sort((a, b) => (a.start_time || "").localeCompare(b.start_time || "")); const shown = items.slice(0, 3); const moreCount = items.length - shown.length; return `
${d.getDate()}
${shown.map((a) => `
${a.start_time ? esc(a.start_time) + " " : ""}${esc(a.title)}
`).join("")} ${moreCount > 0 ? `
+${moreCount} mehr
` : ""}
`; }) .join(""); const upcoming = calAppointments .filter((a) => a.date >= todayIso) .sort((a, b) => (a.date + (a.start_time || "")).localeCompare(b.date + (b.start_time || ""))) .slice(0, 8); container.innerHTML = `

${MONTH_LABELS[calMonth]} ${calYear}

${WEEKDAY_LABELS.map((w) => `
${w}
`).join("")} ${dayCells}

Nächste Termine

${ upcoming.length ? upcoming .map( (a) => `
${formatIsoDateShort(a.date)}${a.start_time ? " · " + esc(a.start_time) : ""}
${esc(a.title)}
${a.animalName ? `
${esc(a.animalName)}${a.ownerName ? " · " + esc(a.ownerName) : ""}
` : ""}${a.location ? `
${esc(a.location)}
` : ""}
` ) .join("") : '

Keine anstehenden Termine.

' }
`; document.getElementById("cal-prev").addEventListener("click", () => { calMonth--; if (calMonth < 0) { calMonth = 11; calYear--; } renderCalendar(); }); document.getElementById("cal-next").addEventListener("click", () => { calMonth++; if (calMonth > 11) { calMonth = 0; calYear++; } renderCalendar(); }); document.getElementById("cal-today-btn").addEventListener("click", () => { calYear = today.getFullYear(); calMonth = today.getMonth(); renderCalendar(); }); document.getElementById("cal-new-appt").addEventListener("click", () => openAppointmentModal({ defaultDate: todayIso })); container.querySelectorAll(".cal-day").forEach((cell) => cell.addEventListener("click", (e) => { if (e.target.closest(".cal-appt")) return; openAppointmentModal({ defaultDate: cell.dataset.date }); }) ); container.querySelectorAll("[data-appt-id]").forEach((el) => el.addEventListener("click", (e) => { e.stopPropagation(); const appt = calAppointments.find((a) => a.id === el.dataset.apptId); if (appt) openAppointmentModal({ existing: appt }); }) ); } function openAppointmentModal({ existing, defaultDate }) { const overlay = document.getElementById("modal-overlay"); const card = document.getElementById("modal-card"); card.className = "modal-card wide"; const animalOptions = directory .flatMap((o) => o.animals.map((a) => ({ id: a.id, label: `${a.name} (${o.name})` }))) .sort((a, b) => a.label.localeCompare(b.label, "de")); card.innerHTML = `

${existing ? "Termin bearbeiten" : "Neuer Termin"}

${APPOINTMENT_TYPES.map((t) => ``).join("")}
`; overlay.hidden = false; document.getElementById("appt-title").focus(); card.querySelectorAll("#appt-quicktypes button").forEach((b) => b.addEventListener("click", () => { document.getElementById("appt-title").value = b.dataset.title; document.getElementById("appt-duration").value = b.dataset.duration; card.querySelectorAll("#appt-quicktypes button").forEach((x) => x.classList.remove("active")); b.classList.add("active"); }) ); document.getElementById("modal-cancel").addEventListener("click", closeModal); const openAnimalLink = document.getElementById("appt-open-animal"); if (openAnimalLink) openAnimalLink.addEventListener("click", async (e) => { e.preventDefault(); closeModal(); switchView("patients"); await selectAnimal(existing.animal_id); }); const deleteBtn = document.getElementById("appt-delete"); if (deleteBtn) deleteBtn.addEventListener("click", () => { openConfirmModal({ title: "Termin löschen?", message: `"${existing.title}" am ${formatIsoDateLong(existing.date)} wirklich löschen?`, confirmLabel: "Löschen", onConfirm: async () => { await window.api.deleteAppointment(existing.id); renderCalendar(); }, }); }); document.getElementById("appt-form").addEventListener("submit", async (e) => { e.preventDefault(); const data = { animalId: document.getElementById("appt-animal").value || null, title: document.getElementById("appt-title").value.trim(), date: document.getElementById("appt-date").value, startTime: document.getElementById("appt-time").value || null, durationMinutes: Number(document.getElementById("appt-duration").value) || null, location: document.getElementById("appt-location").value.trim(), notes: document.getElementById("appt-notes").value.trim(), }; if (!data.title || !data.date) return; if (existing) await window.api.updateAppointment(existing.id, data); else await window.api.addAppointment(data); closeModal(); renderCalendar(); }); } // ---------------- Verzeichnis (Medikamente/Präparate) ---------------- let remedyFilter = ""; async function renderRemedies() { const container = document.getElementById("remedies-view"); const items = await window.api.listRemedies(remedyFilter); const byCategory = {}; items.forEach((r) => (byCategory[r.category || "Sonstiges"] ??= []).push(r)); const categories = Object.keys(byCategory).sort((a, b) => a.localeCompare(b, "de")); container.innerHTML = `

Verzeichnis

${ categories.length ? categories .map( (cat) => `

${esc(cat)}

` ) .join("") : `

${remedyFilter ? "Keine Treffer." : "Noch keine Einträge — leg gleich den ersten an."}

` }`; document.getElementById("remedy-search").addEventListener("input", (e) => { remedyFilter = e.target.value.trim(); renderRemedies(); }); document.getElementById("remedy-new").addEventListener("click", () => openRemedyModal({})); container.querySelectorAll("[data-remedy-id]").forEach((el) => el.addEventListener("click", async () => { const existing = items.find((r) => r.id === el.dataset.remedyId); if (existing) openRemedyModal({ existing }); }) ); } function openRemedyModal({ existing }) { const overlay = document.getElementById("modal-overlay"); const card = document.getElementById("modal-card"); card.className = "modal-card wide"; card.innerHTML = `

${existing ? "Eintrag bearbeiten" : "Neuer Eintrag"}

`; overlay.hidden = false; document.getElementById("rm-name").focus(); document.getElementById("modal-cancel").addEventListener("click", closeModal); const deleteBtn = document.getElementById("rm-delete"); if (deleteBtn) deleteBtn.addEventListener("click", () => { openConfirmModal({ title: "Eintrag löschen?", message: `"${existing.name}" wirklich löschen?`, confirmLabel: "Löschen", onConfirm: async () => { await window.api.deleteRemedy(existing.id); renderRemedies(); }, }); }); document.getElementById("remedy-form").addEventListener("submit", async (e) => { e.preventDefault(); const data = { name: document.getElementById("rm-name").value.trim(), category: document.getElementById("rm-category").value.trim(), keywords: document.getElementById("rm-keywords").value.trim(), notes: document.getElementById("rm-notes").value.trim(), }; if (!data.name) return; if (existing) await window.api.updateRemedy(existing.id, data); else await window.api.addRemedy(data); closeModal(); renderRemedies(); }); } // ---------------- modal ---------------- function fieldInputHtml(f) { if (f.type === "select") { return ` `; } return ` `; } function fieldHtml(f) { return ``; } // Renders a flat field list, grouping consecutive fields that share the same // `group` value into one row (e.g. Straße + Hausnummer side by side). function fieldsHtml(fields) { const out = []; let i = 0; while (i < fields.length) { const f = fields[i]; if (f.group) { const groupFields = []; while (i < fields.length && fields[i].group === f.group) { groupFields.push(fields[i]); i++; } out.push(``); } else { out.push(fieldHtml(f)); i++; } } return out.join(""); } function openModal(title, fields, submitLabel, onSubmit) { const overlay = document.getElementById("modal-overlay"); const card = document.getElementById("modal-card"); card.innerHTML = `

${esc(title)}

`; overlay.hidden = false; document.getElementById("modal-cancel").addEventListener("click", closeModal); document.getElementById("modal-form").addEventListener("submit", async (e) => { e.preventDefault(); const data = {}; fields.forEach((f) => (data[f.key] = document.getElementById("mf-" + f.key).value.trim())); if (fields.some((f) => f.required && !data[f.key])) return; await onSubmit(data); closeModal(); }); const first = card.querySelector("input,select"); if (first) first.focus(); } function closeModal() { document.getElementById("modal-overlay").hidden = true; document.getElementById("modal-card").className = "modal-card"; } function openConfirmModal({ title, message, confirmLabel, danger = true, onConfirm }) { const overlay = document.getElementById("modal-overlay"); const card = document.getElementById("modal-card"); card.innerHTML = `

${esc(title)}

${esc(message)}

`; overlay.hidden = false; document.getElementById("modal-cancel").addEventListener("click", closeModal); document.getElementById("modal-confirm").addEventListener("click", async () => { closeModal(); await onConfirm(); }); } function backupRowLabel(name) { // "YYYY-MM-DD" (daily) or "YYYY-MM-DD_HHMMSS" (manual/safety-net same-day backup) const datePart = name.slice(0, 10); const timePart = name.slice(11); const label = formatIsoDateLong(datePart); if (!timePart) return label; const t = `${timePart.slice(0, 2)}:${timePart.slice(2, 4)}:${timePart.slice(4, 6)}`; return `${label}, ${t} Uhr`; } async function openSmtpSettingsModal() { const current = (await window.api.getSmtpSettings()) || {}; const lexwareCurrent = (await window.api.getLexwarePath()) || ""; const reminderLead = await window.api.getReminderLead(); const backupInfo = await window.api.getBackupInfo(); const backups = await window.api.listBackups(); const webDevices = await window.api.listWebDevices(); const overlay = document.getElementById("modal-overlay"); const card = document.getElementById("modal-card"); card.className = "modal-card xwide"; card.innerHTML = `

Einstellungen

E-Mail-Versand

Für den automatischen Versand der unterschriebenen PDFs. Diese Zugangsdaten liegen unverschlüsselt im gemeinsamen Datenordner.

Lexware & Erinnerungen

Sicherung

Täglich beim ersten Start wird automatisch eine Sicherung des Datenordners (Datenbank + Fotos/Dokumente) in backups/ angelegt, die letzten 30 werden aufgehoben.

${backupInfoText(backupInfo)}

Wiederherstellung

Stellt den Datenordner auf den Stand einer früheren Sicherung zurück. Der aktuelle Stand wird vorher automatisch selbst gesichert, die App startet danach neu.

${renderRestoreList(backups)}

Tablet / Mac im WLAN verbinden

Läuft komplett lokal über euer eigenes WLAN — keine Cloud, keine Registrierung, keine laufenden Kosten. Phase 1: die Kartei lässt sich am gekoppelten Gerät ansehen, Bearbeiten folgt später. Dieser PC muss dafür an und die App geöffnet sein.

Gekoppelte Geräte

${renderWebDevicesList(webDevices)}
`; overlay.hidden = false; document.querySelectorAll("#settings-tabstrip .tab").forEach((tabBtn) => tabBtn.addEventListener("click", () => { document.querySelectorAll("#settings-tabstrip .tab").forEach((b) => b.classList.toggle("active", b === tabBtn)); document.querySelectorAll(".settings-panel").forEach((p) => p.classList.toggle("active", p.dataset.spanel === tabBtn.dataset.stab)); }) ); document.getElementById("backup-now").addEventListener("click", async (e) => { const btn = e.currentTarget; btn.disabled = true; btn.textContent = "Sichere …"; const result = await window.api.backupNow(); const info = await window.api.getBackupInfo(); document.getElementById("backup-info").textContent = result.ok ? backupInfoText(info) : `Sicherung fehlgeschlagen: ${result.error}`; if (result.ok) { const list = await window.api.listBackups(); document.getElementById("restore-list").innerHTML = renderRestoreList(list); wireRestoreButtons(); } btn.disabled = false; btn.textContent = "Jetzt sichern"; }); document.getElementById("backup-open").addEventListener("click", () => window.api.openBackupFolder()); wireRestoreButtons(); document.getElementById("pair-start").addEventListener("click", async (e) => { const btn = e.currentTarget; btn.disabled = true; const info = await window.api.startWebPairing(); btn.disabled = false; renderPairingPanel(info); }); wireWebDeviceButtons(); const readForm = () => ({ host: document.getElementById("smtp-host").value.trim(), port: document.getElementById("smtp-port").value.trim() || "587", secure: document.getElementById("smtp-secure").checked, user: document.getElementById("smtp-user").value.trim(), pass: document.getElementById("smtp-pass").value, fromName: document.getElementById("smtp-fromname").value.trim(), fromEmail: document.getElementById("smtp-fromemail").value.trim(), }); const showStatus = (kind, msg) => { document.getElementById("smtp-status").innerHTML = ``; }; document.getElementById("modal-cancel").addEventListener("click", closeModal); document.getElementById("smtp-test").addEventListener("click", async () => { showStatus("ok", "Verbindung wird geprüft …"); const result = await window.api.testSmtpSettings(readForm()); if (result.ok) showStatus("ok", "Verbindung erfolgreich – die Zugangsdaten funktionieren."); else showStatus("error", "Verbindung fehlgeschlagen: " + (result.error || "unbekannter Fehler")); }); document.getElementById("smtp-form").addEventListener("submit", async (e) => { e.preventDefault(); await window.api.setSmtpSettings(readForm()); await window.api.setLexwarePath(document.getElementById("lexware-path").value.trim()); const lead = Number(document.getElementById("reminder-lead").value.trim()); await window.api.setReminderLead(Number.isFinite(lead) && lead > 0 ? lead : 30); closeModal(); }); } function renderRestoreList(backups) { if (!backups.length) return '

Noch keine Sicherungen vorhanden.

'; return `
${backups .map( (name) => `
${backupRowLabel(name)}
` ) .join("")}
`; } function wireRestoreButtons() { document.querySelectorAll("[data-restore]").forEach((btn) => btn.addEventListener("click", () => { const name = btn.dataset.restore; openConfirmModal({ title: "Sicherung wiederherstellen?", message: `Der Datenordner wird auf den Stand vom ${backupRowLabel(name)} zurückgesetzt. Alles, was seitdem eingetragen wurde, geht dabei verloren (der aktuelle Stand wird vorher aber noch selbst gesichert). Die App startet danach automatisch neu.`, confirmLabel: "Zurücksetzen & neu starten", onConfirm: async () => { const result = await window.api.restoreBackup(name); if (!result.ok) { openConfirmModal({ title: "Wiederherstellung fehlgeschlagen", message: String(result.error || "Unbekannter Fehler."), confirmLabel: "OK", danger: false, onConfirm: () => {}, }); } // on success the app quits and relaunches itself — nothing more to do here }, }); }) ); } function renderPairingPanel(info) { const panel = document.getElementById("pair-panel"); if (!panel) return; const expiresIn = Math.max(0, Math.round((info.expiresAt - Date.now()) / 60000)); panel.innerHTML = `
QR-Code
Mit Kamera/QR-Scanner am Tablet scannen, oder von Hand eingeben:
${esc(info.code)}
Adresse: ${esc(info.url)}
Gültig noch ca. ${expiresIn} Min. — beide Geräte müssen im selben WLAN sein.
`; } function renderWebDevicesList(devices) { if (!devices.length) return '

Noch kein Gerät gekoppelt.

'; return `
${devices .map( (d) => `
${esc(d.name)}gekoppelt ${fmtDate(d.pairedAt)} · zuletzt ${fmtDate(d.lastSeen)}
` ) .join("")}
`; } function wireWebDeviceButtons() { document.querySelectorAll("[data-revoke-device]").forEach((btn) => btn.addEventListener("click", () => { const id = btn.dataset.revokeDevice; openConfirmModal({ title: "Zugriff entziehen?", message: "Das Gerät kann sich danach nicht mehr mit dieser Kartei verbinden, bis es neu gekoppelt wird.", confirmLabel: "Entziehen", onConfirm: async () => { await window.api.revokeWebDevice(id); const list = await window.api.listWebDevices(); document.getElementById("web-devices-list").innerHTML = renderWebDevicesList(list); wireWebDeviceButtons(); }, }); }) ); } async function openLexware() { const path = await window.api.getLexwarePath(); if (!path) { await openSmtpSettingsModal(); return; } const result = await window.api.openLexware(); if (!result.ok) { openConfirmModal({ title: "Lexware konnte nicht geöffnet werden", message: `Fehler: ${result.error}. Pfad/Adresse in den Einstellungen prüfen?`, confirmLabel: "Einstellungen öffnen", danger: false, onConfirm: openSmtpSettingsModal, }); } } function openNewOwnerModal() { openModal( "Neuer Besitzer", [ { key: "name", label: "Name", required: true }, { key: "phone", label: "Telefon" }, { key: "email", label: "E-Mail" }, { key: "street", label: "Straße", group: "addr-street" }, { key: "house_no", label: "Hausnummer", group: "addr-street" }, { key: "zip", label: "PLZ", group: "addr-city" }, { key: "city", label: "Ort", group: "addr-city" }, ], "Anlegen", async (data) => { data.address = [[data.street, data.house_no].filter(Boolean).join(" "), [data.zip, data.city].filter(Boolean).join(" ")] .filter(Boolean) .join(", "); await window.api.createOwner(data); directory = await window.api.listDirectory(); renderDirectory(); } ); } function openNewAnimalModal(ownerId) { openModal( "Neues Tier", [ { key: "name", label: "Name", required: true }, { key: "species", label: "Tierart", type: "select", options: [ { value: "dog", label: "Hund" }, { value: "cat", label: "Katze" }, { value: "rabbit", label: "Kaninchen" }, { value: "tortoise", label: "Landschildkröte" }, { value: "other", label: "Andere" }, ], }, { key: "breed", label: "Rasse (z. B. Australian Shepherd)" }, { key: "sex", label: "Geschlecht (z. B. Rüde, kastriert)" }, { key: "birth_date", label: "Geburtsdatum (TT.MM.JJJJ)" }, { key: "weight", label: "Gewicht (z. B. 12,5 kg)" }, { key: "chip_number", label: "Chip-Nummer" }, ], "Mappe anlegen", async (data) => { data.species_label = SPECIES_LABEL_DEFAULT[data.species] || "Tier"; const id = await window.api.createAnimal(ownerId, data); directory = await window.api.listDirectory(); renderDirectory(); await selectAnimal(id); } ); } // ---------------- directory ---------------- function renderDirectory() { const list = document.getElementById("directory-list"); const q = filterText; const groups = directory .map((owner) => { const animals = owner.animals.filter( (a) => !q || owner.name.toLowerCase().includes(q) || a.name.toLowerCase().includes(q) || noteMatchIds.has(a.id) ); if (q && !owner.name.toLowerCase().includes(q) && animals.length === 0) return ""; const rows = animals .map((a) => { const noteHit = q && noteMatchIds.has(a.id) && !a.name.toLowerCase().includes(q) && !owner.name.toLowerCase().includes(q); return ` `; }) .join(""); return `
${esc(owner.name)}
${rows || '
Noch kein Tier
'}
`; }) .join(""); list.innerHTML = groups || '
Noch keine Besitzer angelegt.
'; list.querySelectorAll(".animal-row").forEach((btn) => btn.addEventListener("click", () => selectAnimal(btn.dataset.id))); list.querySelectorAll(".add-animal-btn:not(.danger)").forEach((btn) => btn.addEventListener("click", () => openNewAnimalModal(btn.dataset.owner))); list.querySelectorAll("[data-delete-owner]").forEach((btn) => btn.addEventListener("click", () => { const name = btn.dataset.ownerName; const count = Number(btn.dataset.animalCount); const ownerId = btn.dataset.deleteOwner; const animalNote = count > 0 ? ` und ${count === 1 ? "sein/ihr Tier" : "alle " + count + " Tiere"} (inkl. Notizen, Dokumente, Fotos, Unterschriften)` : ""; openConfirmModal({ title: "Besitzer löschen?", message: `"${name}"${animalNote} unwiderruflich löschen? Das kann nicht rückgängig gemacht werden.`, confirmLabel: "Endgültig löschen", onConfirm: async () => { const owner = directory.find((o) => o.id === ownerId); if (currentAnimalId && owner && owner.animals.some((a) => a.id === currentAnimalId)) { currentAnimalId = null; document.body.classList.remove("has-animal"); document.getElementById("folder-view").hidden = true; document.getElementById("empty-state").hidden = false; } await window.api.deleteOwner(ownerId); directory = await window.api.listDirectory(); renderDirectory(); }, }); }) ); } // ---------------- folder ---------------- async function refreshBundle() { bundle = await window.api.getAnimalBundle(currentAnimalId); } async function selectAnimal(id) { if (viewMode !== "patients") switchView("patients"); currentAnimalId = id; activeTab = "stammdaten"; bmPopup = null; showSigPad = false; showContractSigPad = false; anamneseExpanded = false; emailStatus = {}; await refreshBundle(); renderDirectory(); renderFolder(); } function renderFolder() { document.body.classList.add("has-animal"); document.getElementById("empty-state").hidden = true; const view = document.getElementById("folder-view"); view.hidden = false; const a = bundle.animal, owner = bundle.owner; document.getElementById("crumb").innerHTML = `${esc(owner.name)}${esc(a.name)}`; document.getElementById("avatar-lg").style.background = SPECIES_COLOR[a.species] || SPECIES_COLOR.other; document.getElementById("avatar-lg").innerHTML = ``; document.getElementById("animal-name").textContent = a.name; const deceasedChip = a.deceased_at ? `Verstorben am ${esc(a.deceased_at)}` : ""; document.getElementById("animal-chips").innerHTML = deceasedChip + [a.species_label, a.breed, a.sex, a.birth_date ? "geb. " + a.birth_date : "", a.weight] .filter(Boolean) .map((c) => `${esc(c)}`) .join(""); document.getElementById("owner-card").innerHTML = `
Besitzer
${esc(owner.name)}
${owner.phone ? `
${esc(owner.phone)}
` : ""} ${owner.email ? `
${esc(owner.email)}
` : ""}`; renderTabstrip(); renderPanels(); } function renderTabstrip() { document.getElementById("tabstrip").innerHTML = TABS.map( (t) => `` ).join(""); document.querySelectorAll(".tab").forEach((btn) => btn.addEventListener("click", () => { activeTab = btn.dataset.tab; bmPopup = null; renderPanels(); document.querySelectorAll(".tab").forEach((b) => b.classList.toggle("active", b.dataset.tab === activeTab)); }) ); } // ---------------- panels ---------------- function renderPanels() { const a = bundle.animal; const map = { stammdaten: panelStammdaten, anamnese: panelAnamnese, notizen: panelNotizen, behandlung: panelBehandlung, verlauf: panelVerlauf, labor: panelLabor, bilder: panelBilder, dokumente: panelDokumente, datenschutz: panelDatenschutz, vertrag: panelVertrag, }; document.getElementById("panels").innerHTML = TABS.map( (t) => `
${map[t.id](a)}
` ).join(""); document.querySelectorAll(".tab").forEach((b) => b.classList.toggle("active", b.dataset.tab === activeTab)); wireStammdaten(); wireAnamnese(); wireNotizen(); wireVerlauf(); wireLabor(); wireBilder(); wireDokumente(); wireDatenschutz(); wireVertrag(); wireBehandlung(); } function editableField(key, label, value, entity = "animal") { return `
${esc(label)}
${esc(value) || "—"}
`; } function panelStammdaten(a) { return `

Grunddaten

${editableField("name", "Name", a.name)} ${editableField("species_label", "Tierart", a.species_label)} ${editableField("breed", "Rasse", a.breed)} ${editableField("sex", "Geschlecht", a.sex)} ${editableField("birth_date", "Geburtsdatum", a.birth_date)} ${editableField("weight", "Gewicht", a.weight)} ${editableField("chip_number", "Chip-Nummer", a.chip_number)}

Auf einen Wert klicken, um ihn zu ändern — speichert automatisch.

Besitzer

${editableField("name", "Name", bundle.owner.name, "owner")} ${editableField("phone", "Telefon", bundle.owner.phone, "owner")} ${editableField("email", "E-Mail", bundle.owner.email, "owner")} ${editableField("address", "Adresse", bundle.owner.address, "owner")}

Auf einen Wert klicken, um ihn zu ändern — speichert automatisch. Gilt für alle Tiere dieses Besitzers.

Status

${a.deceased_at ? `
${editableField("deceased_at", "Verstorben am", a.deceased_at)}
` : ""}
`; } function wireStammdaten() { document.querySelectorAll("#panel-stammdaten .field[data-field] .val").forEach((valEl) => { valEl.addEventListener("click", () => { const fieldEl = valEl.closest(".field"); const key = fieldEl.dataset.field; const entity = fieldEl.dataset.entity || "animal"; const current = valEl.textContent === "—" ? "" : valEl.textContent; const input = document.createElement("input"); input.className = "edit-val"; input.value = current; valEl.replaceWith(input); input.focus(); input.select(); let committed = false; const commit = async () => { if (committed) return; committed = true; if (entity === "owner") { await window.api.updateOwnerField(bundle.owner.id, key, input.value.trim()); directory = await window.api.listDirectory(); renderDirectory(); } else { await window.api.updateAnimalField(currentAnimalId, key, input.value.trim()); } await refreshBundle(); renderPanels(); }; input.addEventListener("blur", commit); input.addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); input.blur(); } if (e.key === "Escape") { committed = true; renderPanels(); } }); }); }); const deceasedToggle = document.getElementById("deceased-toggle"); if (deceasedToggle) deceasedToggle.addEventListener("click", async () => { const isDeceased = !!bundle.animal.deceased_at; const value = isDeceased ? "" : new Date().toLocaleDateString("de-DE"); await window.api.updateAnimalField(currentAnimalId, "deceased_at", value); await refreshBundle(); directory = await window.api.listDirectory(); renderDirectory(); renderFolder(); }); } function getAnamneseData() { try { return JSON.parse(bundle.animal.anamnese_data || "{}"); } catch { return {}; } } function afFieldHtml(f, data) { const val = data[f.key]; const wideCls = f.wide ? " af-wide" : ""; if (f.type === "text") return `
`; if (f.type === "textarea") return `
`; if (f.type === "radio") return `
${f.options .map((o) => ``) .join("")}
`; // check const arr = Array.isArray(val) ? val : []; return `
${f.options .map((o) => ``) .join("")}
`; } function panelAnamnese(a) { const data = getAnamneseData(); const sections = ANAMNESE_SCHEMA.map( (s) => `

${esc(s.title)}

${s.fields.map((f) => afFieldHtml(f, data)).join("")}
` ).join(""); return `

Kurzfassung

Speichert automatisch, sobald du das Feld verlässt.

Vollständiger Erstanamnese-Fragebogen (Herkunft, Haltung, Impfstatus, Ernährung, Verhalten …)

${anamneseExpanded ? `
${sections}
` : ""}`; } function wireAnamnese() { const box = document.getElementById("anamnese-box"); if (box) box.addEventListener("blur", async () => { await window.api.updateAnimalField(currentAnimalId, "anamnese", box.value); await refreshBundle(); }); const expandBtn = document.getElementById("anamnese-expand-btn"); if (expandBtn) expandBtn.addEventListener("click", () => { anamneseExpanded = !anamneseExpanded; renderPanels(); }); const grid = document.querySelector("#panel-anamnese .af-section-grid"); if (!grid) return; const saveField = async (key, value) => { const data = getAnamneseData(); data[key] = value; bundle.animal.anamnese_data = JSON.stringify(data); await window.api.updateAnimalField(currentAnimalId, "anamnese_data", JSON.stringify(data)); }; grid.querySelectorAll('input[data-type="text"], textarea[data-type="textarea"]').forEach((el) => { el.addEventListener("blur", () => saveField(el.dataset.key, el.value.trim())); }); grid.querySelectorAll('input[data-type="radio"]').forEach((el) => { el.addEventListener("change", () => saveField(el.dataset.key, el.value)); }); grid.querySelectorAll('input[data-type="check"]').forEach((el) => { el.addEventListener("change", () => { const data = getAnamneseData(); const arr = new Set(Array.isArray(data[el.dataset.key]) ? data[el.dataset.key] : []); if (el.checked) arr.add(el.value); else arr.delete(el.value); saveField(el.dataset.key, Array.from(arr)); }); }); } function panelNotizen(a) { return `

Notizen

${bundle.notes.map((n) => `
${n.date}
${esc(n.text)}
`).join("") || '

Noch keine Notizen.

'}
`; } function wireNotizen() { const btn = document.getElementById("note-add"); if (!btn) return; const input = document.getElementById("note-input"); const add = async () => { const val = input.value.trim(); if (!val) return; await window.api.addNote(currentAnimalId, val); await refreshBundle(); renderPanels(); }; btn.addEventListener("click", add); input.addEventListener("keydown", (e) => { if (e.key === "Enter") add(); }); } function panelVerlauf(a) { return `

Verlauf

${bundle.history.map((v) => `
${v.date}

${esc(v.title)}

${esc(v.text)}

`).join("") || '

Noch keine Einträge.

'}
`; } function wireVerlauf() { const btn = document.getElementById("hist-add"); if (!btn) return; btn.addEventListener("click", async () => { const title = document.getElementById("hist-title").value.trim(); const text = document.getElementById("hist-text").value.trim(); if (!title) return; await window.api.addHistoryEntry(currentAnimalId, title, text); await refreshBundle(); renderPanels(); }); } function panelLabor(a) { const cards = bundle.labResults .map( (l) => `

${esc(l.name)} · ${l.date}

${l.values.map((r) => ``).join("")}
ParameterWertReferenz
${esc(r.parameter)}${esc(r.value)}${esc(r.reference)}
` ) .join(""); return `${cards}
`; } function wireLabor() { const newBtn = document.getElementById("lab-new"); if (!newBtn) return; newBtn.addEventListener("click", () => { openModal("Neuer Laborbefund", [{ key: "name", label: "Bezeichnung (z. B. Kleines Blutbild)", required: true }], "Anlegen", async (data) => { await window.api.addLabResult(currentAnimalId, data.name); await refreshBundle(); renderPanels(); }); }); document.querySelectorAll(".lab-del").forEach((btn) => btn.addEventListener("click", async () => { await window.api.deleteLabResult(btn.dataset.id); await refreshBundle(); renderPanels(); }) ); document.querySelectorAll(".lv-add").forEach((btn) => btn.addEventListener("click", async () => { const labId = btn.dataset.lab; const param = document.querySelector(`.lv-param[data-lab="${labId}"]`).value.trim(); const value = document.querySelector(`.lv-value[data-lab="${labId}"]`).value.trim(); const ref = document.querySelector(`.lv-ref[data-lab="${labId}"]`).value.trim(); if (!param) return; await window.api.addLabValue(labId, param, value, ref); await refreshBundle(); renderPanels(); }) ); } function panelBilder(a) { return `

Bilder

${bundle.photos.map((p) => `
${esc(p.filename)}
${esc(p.filename)}
`).join("")}
Foto hinzufügen
`; } function wireBilder() { const btn = document.getElementById("add-photo"); if (!btn) return; btn.addEventListener("click", async () => { const added = await window.api.addDocument(currentAnimalId, "photo"); if (added.length) { await refreshBundle(); renderPanels(); } }); } function panelDokumente(a) { return `

Vorlagen erstellen

Erzeugt ein PDF mit eurem Briefkopf, direkt für ${esc(a.name)} — landet automatisch unten in den Dokumenten.

Dokumente

PDF, Scan oder Foto ablegen — oder Datei auswählen
${bundle.documents.map(docRow).join("") || '

Noch keine Dokumente.

'}
`; } function docRow(d) { const kb = d.size_bytes ? Math.max(1, Math.round(d.size_bytes / 1024)) + " KB" : ""; return `
${esc(d.filename)}
${fmtDate(d.uploaded_at)} · ${kb}
`; } function wireDokumente() { const btn = document.getElementById("doc-upload"); if (!btn) return; btn.addEventListener("click", async () => { const added = await window.api.addDocument(currentAnimalId, "document"); if (added.length) { await refreshBundle(); renderPanels(); } }); document.querySelectorAll(".doc-open").forEach((b) => b.addEventListener("click", () => window.api.openDocument(b.dataset.id))); const ernaehrungsBtn = document.getElementById("tpl-ernaehrungsplan"); if (ernaehrungsBtn) ernaehrungsBtn.addEventListener("click", openErnaehrungsplanModal); const rezeptBtn = document.getElementById("tpl-rezept"); if (rezeptBtn) rezeptBtn.addEventListener("click", openRezeptModal); } const WOCHENTAGE = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"]; const TAGESZEITEN = ["morgens", "mittags", "abends"]; function ernaehrungsRowHtml(i) { return `
${WOCHENTAGE.map((t) => ``).join("")}
${TAGESZEITEN.map((t) => ``).join("")}
`; } function wireErnaehrungsRow(row) { const removeBtn = row.querySelector(".fp-row-remove"); removeBtn.addEventListener("click", () => { const list = document.getElementById("fp-rows"); if (list.children.length <= 1) return; // always keep at least one row row.remove(); }); } function openErnaehrungsplanModal() { const overlay = document.getElementById("modal-overlay"); const card = document.getElementById("modal-card"); card.className = "modal-card xwide"; card.innerHTML = `

Ernährungsplan für ${esc(bundle.animal.name)}

${ernaehrungsRowHtml(0)}
`; overlay.hidden = false; wireErnaehrungsRow(document.querySelector("#fp-rows .fp-row")); document.getElementById("modal-cancel").addEventListener("click", closeModal); let rowCounter = 1; document.getElementById("fp-add-row").addEventListener("click", () => { const list = document.getElementById("fp-rows"); list.insertAdjacentHTML("beforeend", ernaehrungsRowHtml(rowCounter++)); wireErnaehrungsRow(list.lastElementChild); list.lastElementChild.scrollIntoView({ block: "nearest" }); }); document.getElementById("fp-create").addEventListener("click", async () => { const rows = Array.from(document.querySelectorAll("#fp-rows .fp-row")) .map((row) => ({ futtermittel: row.querySelector(".fp-futtermittel").value.trim(), tage: Array.from(row.querySelectorAll(".fp-tag:checked")).map((c) => c.value), zeiten: Array.from(row.querySelectorAll(".fp-zeit:checked")).map((c) => c.value), menge: row.querySelector(".fp-menge").value.trim(), })) .filter((r) => r.futtermittel); if (!rows.length) { document.getElementById("fp-status").innerHTML = ''; return; } const notes = document.getElementById("fp-notes").value.trim(); const bodyHtml = `${rows .map( (r) => `` ) .join("")}
FuttermittelTagWannMenge
${esc(r.futtermittel)}${esc(r.tage.join(", ") || "—")}${esc(r.zeiten.join(", ") || "—")}${esc(r.menge || "—")}
${notes ? `

Anmerkungen

${esc(notes)}

` : ""}`; document.getElementById("fp-status").innerHTML = ''; await window.api.generateTemplatePdf(currentAnimalId, "Ernaehrungsplan", { title: "Ernährungsplan", ownerBlockHtml: buildOwnerBlockHtml(bundle.owner, bundle.animal), bodyHtml, }); closeModal(); await refreshBundle(); renderPanels(); }); } function openRezeptModal() { const overlay = document.getElementById("modal-overlay"); const card = document.getElementById("modal-card"); card.className = "modal-card wide"; const todayStr = new Date().toLocaleDateString("de-DE"); card.innerHTML = `

Rezept für ${esc(bundle.animal.name)}

`; overlay.hidden = false; document.getElementById("rz-text").focus(); document.getElementById("modal-cancel").addEventListener("click", closeModal); document.getElementById("rz-create").addEventListener("click", async () => { const text = document.getElementById("rz-text").value.trim(); if (!text) { document.getElementById("rz-status").innerHTML = ''; return; } const dateStr = document.getElementById("rz-date").value.trim(); document.getElementById("rz-status").innerHTML = ''; const bodyHtml = `

Datum: ${esc(dateStr)}

${esc(text)}

Empfehlung im Rahmen der Tierheilpraxis – keine tierärztliche Verschreibung.

`; await window.api.generateTemplatePdf(currentAnimalId, "Rezept", { title: "Rezept", ownerBlockHtml: buildOwnerBlockHtml(bundle.owner, bundle.animal), bodyHtml, }); closeModal(); await refreshBundle(); renderPanels(); }); } function getConsentItems() { try { return JSON.parse((bundle.consent && bundle.consent.items_json) || "{}"); } catch { return {}; } } function panelDatenschutz(a) { const c = bundle.consent || {}; const items = getConsentItems(); const checklist = `

Datenschutzrechtliche Einwilligung

`; const granted = !!c.granted; // showSigPad must win over an existing signature — otherwise "Neu unterschreiben" // re-renders straight back into the "already signed" branch and looks like a no-op. const sigSection = showSigPad ? `
` : c.signature_url ? `
Unterschrift
` : `
`; return `${checklist}

Datenschutz & Einverständnis

${sigSection} ${emailSection("einwilligung", c)}
`; } // Shared by Datenschutz + Behandlungsvertrag: shows send status under the signature, // plus a resend button once something has been signed. function emailSection(kind, record) { const status = emailStatus[kind]; const statusHtml = status ? `
${esc(status.msg)}
` : ""; if (!record || !record.signature_path) return statusHtml; const lastSent = record.email_sent_at ? `

Zuletzt per E-Mail gesendet am ${fmtDate(record.email_sent_at)}.

` : ""; return `${lastSent}
${statusHtml}`; } function buildOwnerBlockHtml(owner, animal) { const esc2 = esc; return `
Tierhalter/in${esc2(owner.name)}
Adresse${esc2(owner.address) || "—"}
E-Mail${esc2(owner.email) || "—"}
Tier${esc2(animal.name)} (${esc2(animal.species_label)})
`; } function summarizeEmailResult(result) { if (result.ok) return { kind: "ok", msg: `PDF erstellt und per E-Mail an ${bundle.owner.email} gesendet.` }; if (result.reason === "not-configured") return { kind: "error", msg: "PDF wurde erstellt. E-Mail-Versand ist noch nicht eingerichtet (Zahnrad oben rechts)." }; if (result.reason === "no-owner-email") return { kind: "error", msg: "PDF wurde erstellt. Für diesen Besitzer ist keine E-Mail-Adresse hinterlegt." }; return { kind: "error", msg: "PDF wurde erstellt, E-Mail-Versand ist fehlgeschlagen: " + (result.reason || "unbekannter Fehler") }; } async function generateAndMaybeSendPdf(kind) { const a = bundle.animal, owner = bundle.owner; const record = kind === "vertrag" ? bundle.contract : bundle.consent; const title = kind === "vertrag" ? "Behandlungsvertrag" : "Datenschutzrechtliche Einwilligung"; const ownerBlockHtml = buildOwnerBlockHtml(owner, a); const bodyHtml = kind === "vertrag" ? CONTRACT_TEXT.map((s) => `

${esc(s.title)}

${s.html}`).join("") : ``; const signedInfoHtml = `

Unterschrieben von: ${esc(owner.name)}
Datum: ${fmtDate(record.signed_at)}
Methode: ${esc(record.method || "")}

`; const result = await window.api.generatePdf(currentAnimalId, kind, { title, ownerBlockHtml, bodyHtml, signedInfoHtml }); await refreshBundle(); const smtp = await window.api.getSmtpSettings(); if (!smtp) return { ok: false, reason: "not-configured" }; if (!bundle.owner.email) return { ok: false, reason: "no-owner-email" }; const sendResult = await window.api.sendDocumentEmail(currentAnimalId, kind, { absPath: result.absPath, filename: result.filename }); await refreshBundle(); return sendResult.ok ? { ok: true } : { ok: false, reason: sendResult.error }; } function wireDatenschutz() { document.querySelectorAll("#panel-datenschutz .consent-item input").forEach((cb) => { cb.addEventListener("change", async () => { const items = getConsentItems(); items[cb.dataset.key] = cb.checked; const c = bundle.consent || {}; await window.api.setConsent(currentAnimalId, { granted: !!c.granted, method: c.method || null, signaturePath: c.signature_path || null, marketingOk: !!c.marketing_ok, itemsJson: JSON.stringify(items), }); await refreshBundle(); renderPanels(); }); }); const toggle = document.getElementById("consent-toggle"); if (toggle) toggle.addEventListener("click", async () => { const c = bundle.consent || {}; await window.api.setConsent(currentAnimalId, { granted: !c.granted, method: !c.granted ? "Manuell im Programm markiert" : null, signaturePath: c.signature_path || null, marketingOk: !!c.marketing_ok, }); await refreshBundle(); renderPanels(); }); const marketing = document.getElementById("marketing-toggle"); if (marketing) marketing.addEventListener("click", async () => { const c = bundle.consent || {}; await window.api.setConsent(currentAnimalId, { granted: !!c.granted, method: c.method || null, signaturePath: c.signature_path || null, marketingOk: !c.marketing_ok, }); await refreshBundle(); renderPanels(); }); const sigStart = document.getElementById("sig-start"); if (sigStart) sigStart.addEventListener("click", () => { showSigPad = true; renderPanels(); }); const sigRedo = document.getElementById("sig-redo"); if (sigRedo) sigRedo.addEventListener("click", () => { showSigPad = true; renderPanels(); }); const emailBtn = document.querySelector('#panel-datenschutz [data-email-kind="einwilligung"]'); if (emailBtn) emailBtn.addEventListener("click", async () => { emailStatus.einwilligung = { kind: "pending", msg: "PDF wird erstellt und gesendet …" }; renderPanels(); const result = await generateAndMaybeSendPdf("einwilligung"); emailStatus.einwilligung = summarizeEmailResult(result); renderPanels(); }); const pad = document.getElementById("sig-pad"); if (pad) { const ctx = pad.getContext("2d"); ctx.lineWidth = 2.4; ctx.lineCap = "round"; ctx.strokeStyle = "#3a332c"; let drawing = false, last = null; const posFor = (e) => { const r = pad.getBoundingClientRect(); return { x: ((e.clientX - r.left) / r.width) * pad.width, y: ((e.clientY - r.top) / r.height) * pad.height }; }; pad.addEventListener("pointerdown", (e) => { pad.setPointerCapture(e.pointerId); drawing = true; last = posFor(e); }); pad.addEventListener("pointermove", (e) => { if (!drawing) return; const p = posFor(e); ctx.beginPath(); ctx.moveTo(last.x, last.y); ctx.lineTo(p.x, p.y); ctx.stroke(); last = p; }); pad.addEventListener("pointerup", () => (drawing = false)); pad.addEventListener("pointercancel", () => (drawing = false)); document.getElementById("sig-clear").addEventListener("click", () => ctx.clearRect(0, 0, pad.width, pad.height)); document.getElementById("sig-save").addEventListener("click", async () => { const dataUrl = pad.toDataURL("image/png"); bundle.consent = await window.api.saveSignature(currentAnimalId, dataUrl); showSigPad = false; emailStatus.einwilligung = { kind: "pending", msg: "PDF wird erstellt und gesendet …" }; renderPanels(); const result = await generateAndMaybeSendPdf("einwilligung"); emailStatus.einwilligung = summarizeEmailResult(result); renderPanels(); }); } } function panelVertrag(a) { const c = bundle.contract || {}; const granted = !!c.granted; const sections = CONTRACT_TEXT.map((s) => `

${esc(s.title)}

${s.html}`).join(""); const sigSection = showContractSigPad ? `
` : c.signature_url ? `
Unterschrift
` : `
${c.briefing_confirmed ? "" : '

Erst die Bestätigung oben ankreuzen, dann kann unterschrieben werden.

'}`; return `

Behandlungsvertrag mit Allgemeinen Geschäftsbedingungen

${sections}

Unterschrift

${sigSection} ${emailSection("vertrag", c)}
`; } function wireVertrag() { const confirmBox = document.getElementById("vertrag-confirm"); if (confirmBox) confirmBox.addEventListener("change", async () => { const c = bundle.contract || {}; await window.api.setContract(currentAnimalId, { granted: !!c.granted, method: c.method || null, signaturePath: c.signature_path || null, briefingConfirmed: confirmBox.checked, }); await refreshBundle(); renderPanels(); }); const sigStart = document.getElementById("vertrag-sig-start"); if (sigStart) sigStart.addEventListener("click", () => { showContractSigPad = true; renderPanels(); }); const sigRedo = document.getElementById("vertrag-sig-redo"); if (sigRedo) sigRedo.addEventListener("click", () => { showContractSigPad = true; renderPanels(); }); const emailBtn = document.querySelector('#panel-vertrag [data-email-kind="vertrag"]'); if (emailBtn) emailBtn.addEventListener("click", async () => { emailStatus.vertrag = { kind: "pending", msg: "PDF wird erstellt und gesendet …" }; renderPanels(); const result = await generateAndMaybeSendPdf("vertrag"); emailStatus.vertrag = summarizeEmailResult(result); renderPanels(); }); const pad = document.getElementById("vertrag-sig-pad"); if (pad) { const ctx = pad.getContext("2d"); ctx.lineWidth = 2.4; ctx.lineCap = "round"; ctx.strokeStyle = "#3a332c"; let drawing = false, last = null; const posFor = (e) => { const r = pad.getBoundingClientRect(); return { x: ((e.clientX - r.left) / r.width) * pad.width, y: ((e.clientY - r.top) / r.height) * pad.height }; }; pad.addEventListener("pointerdown", (e) => { pad.setPointerCapture(e.pointerId); drawing = true; last = posFor(e); }); pad.addEventListener("pointermove", (e) => { if (!drawing) return; const p = posFor(e); ctx.beginPath(); ctx.moveTo(last.x, last.y); ctx.lineTo(p.x, p.y); ctx.stroke(); last = p; }); pad.addEventListener("pointerup", () => (drawing = false)); pad.addEventListener("pointercancel", () => (drawing = false)); document.getElementById("vertrag-sig-clear").addEventListener("click", () => ctx.clearRect(0, 0, pad.width, pad.height)); document.getElementById("vertrag-sig-save").addEventListener("click", async () => { const dataUrl = pad.toDataURL("image/png"); bundle.contract = await window.api.saveContractSignature(currentAnimalId, dataUrl); showContractSigPad = false; emailStatus.vertrag = { kind: "pending", msg: "PDF wird erstellt und gesendet …" }; renderPanels(); const result = await generateAndMaybeSendPdf("vertrag"); emailStatus.vertrag = summarizeEmailResult(result); renderPanels(); }); } } function wireDatenschutz() { document.querySelectorAll("#panel-datenschutz .consent-item input").forEach((cb) => { cb.addEventListener("change", async () => { const items = getConsentItems(); items[cb.dataset.key] = cb.checked; const c = bundle.consent || {}; await window.api.setConsent(currentAnimalId, { granted: !!c.granted, method: c.method || null, signaturePath: c.signature_path || null, marketingOk: !!c.marketing_ok, itemsJson: JSON.stringify(items), }); await refreshBundle(); renderPanels(); }); }); const toggle = document.getElementById("consent-toggle"); if (toggle) toggle.addEventListener("click", async () => { const c = bundle.consent || {}; await window.api.setConsent(currentAnimalId, { granted: !c.granted, method: !c.granted ? "Manuell im Programm markiert" : null, signaturePath: c.signature_path || null, marketingOk: !!c.marketing_ok, }); await refreshBundle(); renderPanels(); }); const marketing = document.getElementById("marketing-toggle"); if (marketing) marketing.addEventListener("click", async () => { const c = bundle.consent || {}; await window.api.setConsent(currentAnimalId, { granted: !!c.granted, method: c.method || null, signaturePath: c.signature_path || null, marketingOk: !c.marketing_ok, }); await refreshBundle(); renderPanels(); }); const sigStart = document.getElementById("sig-start"); if (sigStart) sigStart.addEventListener("click", () => { showSigPad = true; renderPanels(); }); const sigRedo = document.getElementById("sig-redo"); if (sigRedo) sigRedo.addEventListener("click", () => { showSigPad = true; renderPanels(); }); const pad = document.getElementById("sig-pad"); if (pad) { const ctx = pad.getContext("2d"); ctx.lineWidth = 2.4; ctx.lineCap = "round"; ctx.strokeStyle = "#3a332c"; let drawing = false, last = null; const posFor = (e) => { const r = pad.getBoundingClientRect(); return { x: ((e.clientX - r.left) / r.width) * pad.width, y: ((e.clientY - r.top) / r.height) * pad.height }; }; pad.addEventListener("pointerdown", (e) => { pad.setPointerCapture(e.pointerId); drawing = true; last = posFor(e); }); pad.addEventListener("pointermove", (e) => { if (!drawing) return; const p = posFor(e); ctx.beginPath(); ctx.moveTo(last.x, last.y); ctx.lineTo(p.x, p.y); ctx.stroke(); last = p; }); pad.addEventListener("pointerup", () => (drawing = false)); pad.addEventListener("pointercancel", () => (drawing = false)); document.getElementById("sig-clear").addEventListener("click", () => ctx.clearRect(0, 0, pad.width, pad.height)); document.getElementById("sig-save").addEventListener("click", async () => { const dataUrl = pad.toDataURL("image/png"); bundle.consent = await window.api.saveSignature(currentAnimalId, dataUrl); showSigPad = false; await refreshBundle(); renderPanels(); }); } } function examFieldValue(key) { const el = document.querySelector(`#exam-form [data-exam-key="${key}"]`); return el ? el.value.trim() : ""; } function panelExamForm() { return `

Untersuchung dokumentieren

${EXAM_SCHEMA.map((f) => `
`).join("")}
`; } function panelExamHistory() { if (!bundle.exams.length) return ""; return `

Untersuchungsverlauf

${bundle.exams .map((ex) => { const rows = EXAM_SCHEMA.filter((f) => ex[f.key]).map((f) => `${esc(f.label)}: ${esc(ex[f.key])}`).join(""); return `
${ex.date}
${rows ? `
${rows}
` : ""} ${ex.auffaelligkeiten ? `

Auffälligkeiten: ${esc(ex.auffaelligkeiten)}

` : ""} ${ex.empfehlung ? `

Empfehlung: ${esc(ex.empfehlung)}

` : ""}
`; }) .join("")}
`; } function panelBehandlung(a) { const findings = bundle.findings .map( (p) => `
${SEV[p.severity].label} — ${esc(p.text)}
` ) .join("") || `

Noch keine Markierungen. Auf die Silhouette klicken, um einen Befund einzutragen.

`; return `${panelExamForm()}${panelExamHistory()}

Körper-Silhouette

Auf die Silhouette klicken oder tippen, um an der berührten Stelle einen Befund zu markieren.

${silhouette(a.species)} ${bundle.findings.map((p, i) => `
${i + 1}
`).join("")} ${bmPopup ? bmPopupHtml(bmPopup) : ""}

Markierte Befunde

${findings}
`; } function bmPopupHtml(p) { const left = Math.min(Math.max(p.x, 10), 60); const top = Math.min(Math.max(p.y, 8), 58); return `
${p.editId ? '' : ""}
`; } function silhouette(species) { if (SILHOUETTE_IMAGE[species]) { return ``; } if (species === "tortoise") return ` `; return ` `; } function wireBehandlung() { const examSave = document.getElementById("exam-save"); if (examSave) examSave.addEventListener("click", async () => { const fields = {}; EXAM_SCHEMA.forEach((f) => (fields[f.key] = examFieldValue(f.key))); fields.auffaelligkeiten = examFieldValue("auffaelligkeiten"); fields.empfehlung = examFieldValue("empfehlung"); const hasAny = Object.values(fields).some((v) => v); if (!hasAny) return; await window.api.addExam(currentAnimalId, fields); await refreshBundle(); renderPanels(); }); const stage = document.getElementById("bm-stage"); if (!stage) return; stage.addEventListener("click", (e) => { if (e.target.closest(".bm-pin") || e.target.closest(".bm-pop")) return; const r = stage.getBoundingClientRect(); const x = ((e.clientX - r.left) / r.width) * 100; const y = ((e.clientY - r.top) / r.height) * 100; bmPopup = { x, y, sev: "warn", text: "" }; renderPanels(); }); stage.querySelectorAll(".bm-pin").forEach((pin) => pin.addEventListener("click", (e) => { e.stopPropagation(); const finding = bundle.findings.find((f) => f.id === pin.dataset.id); bmPopup = { x: finding.x, y: finding.y, sev: finding.severity, text: finding.text, editId: finding.id }; renderPanels(); }) ); const popText = document.getElementById("pop-text"); if (popText) { document.querySelectorAll(".sev-btn").forEach((b) => b.addEventListener("click", () => { bmPopup.sev = b.dataset.sev; renderPanels(); }) ); document.getElementById("pop-cancel").addEventListener("click", () => { bmPopup = null; renderPanels(); }); const popDelete = document.getElementById("pop-delete"); if (popDelete) popDelete.addEventListener("click", async () => { await window.api.deleteFinding(bmPopup.editId); bmPopup = null; await refreshBundle(); renderPanels(); }); document.getElementById("pop-save").addEventListener("click", async () => { const text = popText.value.trim() || "Ohne Beschreibung"; if (bmPopup.editId) { await window.api.updateFinding(bmPopup.editId, { severity: bmPopup.sev, text }); } else { await window.api.addFinding(currentAnimalId, { x: bmPopup.x, y: bmPopup.y, severity: bmPopup.sev, text }); } bmPopup = null; await refreshBundle(); renderPanels(); }); } document.querySelectorAll("#panel-behandlung .finding .rm").forEach((btn) => btn.addEventListener("click", async () => { await window.api.deleteFinding(btn.dataset.id); await refreshBundle(); renderPanels(); }) ); } boot();