Files

2229 lines
112 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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: `<p>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.</p>
<p>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.</p>
<p>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.</p>`,
},
{
title: "§ 2 Versprechen auf Heilung",
html: `<p>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.</p>`,
},
{
title: "§ 3 Aufklärung / Hinweise",
html: `<p>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.</p>`,
},
{
title: "§ 4 Aufklärungspflicht / Aufklärungsumfang",
html: `<p>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.</p>`,
},
{
title: "§ 5 Behandlung",
html: `<p>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.</p>
<p>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.</p>`,
},
{
title: "§ 6 Haftung des Behandlers",
html: `<p>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.</p>`,
},
{
title: "§ 7 Honorar, Kostenerstattung",
html: `<p>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.</p>
<p>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.</p>`,
},
{
title: "§ 8 Terminvereinbarung",
html: `<p>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.</p>`,
},
{
title: "§ 9 Ausfallhonorar",
html: `<p>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.</p>`,
},
{
title: "§ 10 Beratung / Nachbetreuung über Telefon oder Videocall",
html: `<p>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.</p>`,
},
{
title: "§ 11 Laborkosten / Kosten für Medikamente",
html: `<p>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.</p>`,
},
{
title: "§ 12 Datenschutz / Einverständniserklärung Datenerhebung",
html: `<p>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.</p>`,
},
{
title: "§ 13 Salvatorische Klausel",
html: `<p>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.</p>`,
},
{
title: "§ 14 Erfüllungsort und Gerichtsstand",
html: `<p>Erfüllungsort: Leonberg, Baden-Württemberg. Gerichtsstand für beide Parteien: Amtsgericht Stuttgart.</p>`,
},
{
title: "§ 15 Widerrufsrecht / Widerrufsbelehrung",
html: `<p>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.</p>`,
},
];
function esc(s) {
return String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[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 `<div class="cal-day ${inMonth ? "" : "other-month"} ${iso === todayIso ? "today" : ""}" data-date="${iso}">
<div class="cal-day-num">${d.getDate()}</div>
${shown.map((a) => `<div class="cal-appt" data-appt-id="${a.id}">${a.start_time ? esc(a.start_time) + " " : ""}${esc(a.title)}</div>`).join("")}
${moreCount > 0 ? `<div class="cal-appt-more">+${moreCount} mehr</div>` : ""}
</div>`;
})
.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 = `
<div class="cal-head">
<h1>${MONTH_LABELS[calMonth]} ${calYear}</h1>
<div class="cal-nav">
<button class="cal-nav-btn" id="cal-prev"><svg><use href="#i-chevron-left"/></svg></button>
<button class="btn" id="cal-today-btn">Heute</button>
<button class="cal-nav-btn" id="cal-next"><svg><use href="#i-chevron-right"/></svg></button>
</div>
<div class="cal-head-spacer"></div>
<button class="btn primary" id="cal-new-appt"><svg><use href="#i-plus"/></svg>Neuer Termin</button>
</div>
<div class="cal-grid">
${WEEKDAY_LABELS.map((w) => `<div class="cal-dow">${w}</div>`).join("")}
${dayCells}
</div>
<div class="card agenda-card">
<h3>Nächste Termine</h3>
${
upcoming.length
? upcoming
.map(
(a) => `<div class="agenda-item" data-appt-id="${a.id}">
<div class="agenda-date">${formatIsoDateShort(a.date)}${a.start_time ? " · " + esc(a.start_time) : ""}</div>
<div><div class="agenda-title">${esc(a.title)}</div>${a.animalName ? `<div class="agenda-sub">${esc(a.animalName)}${a.ownerName ? " · " + esc(a.ownerName) : ""}</div>` : ""}${a.location ? `<div class="agenda-sub">${esc(a.location)}</div>` : ""}</div>
</div>`
)
.join("")
: '<p class="prose" style="color:var(--ink-faint)">Keine anstehenden Termine.</p>'
}
</div>`;
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 = `<h2>${existing ? "Termin bearbeiten" : "Neuer Termin"}</h2>
<form id="appt-form">
<div class="quicktype-row" id="appt-quicktypes" style="margin-bottom:16px">
${APPOINTMENT_TYPES.map((t) => `<button type="button" class="quicktype-chip ${existing && existing.title === t.label ? "active" : ""}" data-title="${esc(t.label)}" data-duration="${t.duration}">${esc(t.label)}</button>`).join("")}
</div>
<div class="grid-2">
<div class="modal-field"><label class="lbl">Titel</label><input id="appt-title" type="text" value="${esc(existing ? existing.title : "")}" required></div>
<div class="modal-field"><label class="lbl">Tier (optional)</label><select id="appt-animal">
<option value="">— kein Tier —</option>
${animalOptions.map((o) => `<option value="${o.id}" ${existing && existing.animal_id === o.id ? "selected" : ""}>${esc(o.label)}</option>`).join("")}
</select>${existing && existing.animal_id ? '<div class="modal-hint"><a href="#" id="appt-open-animal">Patientenmappe öffnen →</a></div>' : ""}</div>
<div class="modal-field"><label class="lbl">Datum</label><input id="appt-date" type="date" value="${existing ? existing.date : defaultDate}" required></div>
<div class="modal-field"><label class="lbl">Uhrzeit</label><input id="appt-time" type="time" value="${existing && existing.start_time ? existing.start_time : ""}"></div>
<div class="modal-field"><label class="lbl">Dauer (Minuten)</label><input id="appt-duration" type="text" value="${existing && existing.duration_minutes ? existing.duration_minutes : ""}"></div>
<div class="modal-field"><label class="lbl">Ort</label><input id="appt-location" type="text" value="${esc(existing ? existing.location || "" : "")}" placeholder="z. B. beim Besitzer zuhause"></div>
</div>
<div class="modal-field"><label class="lbl">Notizen</label><textarea id="appt-notes" style="width:100%;min-height:60px;border:1px solid var(--border);border-radius:var(--radius-s);padding:9px 11px;font-size:13.5px;outline:0;background:var(--surface);font-family:inherit">${esc(existing ? existing.notes || "" : "")}</textarea></div>
<div class="modal-actions">
${existing ? '<button type="button" class="btn danger" id="appt-delete" style="margin-right:auto">Löschen</button>' : ""}
<button type="button" class="btn" id="modal-cancel">Abbrechen</button>
<button type="submit" class="btn primary">${existing ? "Speichern" : "Anlegen"}</button>
</div>
</form>`;
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 = `
<div class="cal-head">
<h1>Verzeichnis</h1>
<div class="cal-head-spacer"></div>
<div class="search" style="width:260px; margin:0"><svg width="15" height="15"><use href="#i-search"/></svg>
<input id="remedy-search" placeholder="z. B. Juckreiz, Darm, Gelenke …" value="${esc(remedyFilter)}"></div>
<button class="btn primary" id="remedy-new"><svg><use href="#i-plus"/></svg>Neuer Eintrag</button>
</div>
${
categories.length
? categories
.map(
(cat) => `<div class="card" style="margin-bottom:14px"><h3>${esc(cat)}</h3><div class="consent-list">
${byCategory[cat]
.map(
(r) => `<div class="consent-item" style="cursor:pointer" data-remedy-id="${r.id}">
<div style="flex:1">
<div style="font-weight:600; font-size:13.5px">${esc(r.name)}</div>
${r.keywords ? `<div class="hint" style="margin-top:2px">${esc(r.keywords)}</div>` : ""}
</div>
</div>`
)
.join("")}
</div></div>`
)
.join("")
: `<div class="card"><p class="prose" style="color:var(--ink-faint)">${remedyFilter ? "Keine Treffer." : "Noch keine Einträge — leg gleich den ersten an."}</p></div>`
}`;
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 = `<h2>${existing ? "Eintrag bearbeiten" : "Neuer Eintrag"}</h2>
<form id="remedy-form">
<div class="grid-2">
<div class="modal-field"><label class="lbl">Name</label><input id="rm-name" type="text" value="${esc(existing ? existing.name : "")}" required></div>
<div class="modal-field"><label class="lbl">Kategorie</label><input id="rm-category" type="text" value="${esc(existing ? existing.category || "" : "")}" placeholder="z. B. Bachblüte, Phytotherapie, Schüßlersalz, NEM"></div>
</div>
<div class="modal-field"><label class="lbl">Anwendungsgebiete / Symptome (für die Suche)</label><input id="rm-keywords" type="text" value="${esc(existing ? existing.keywords || "" : "")}" placeholder="z. B. Juckreiz, Darm, Gelenke"></div>
<div class="modal-field"><label class="lbl">Dosierung / Notizen</label><textarea id="rm-notes" style="width:100%;min-height:100px;border:1px solid var(--border);border-radius:var(--radius-s);padding:9px 11px;font-size:13.5px;outline:0;background:var(--surface);font-family:inherit">${esc(existing ? existing.notes || "" : "")}</textarea></div>
<div class="modal-actions">
${existing ? '<button type="button" class="btn danger" id="rm-delete" style="margin-right:auto">Löschen</button>' : ""}
<button type="button" class="btn" id="modal-cancel">Abbrechen</button>
<button type="submit" class="btn primary">${existing ? "Speichern" : "Anlegen"}</button>
</div>
</form>`;
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 `<label class="lbl">${esc(f.label)}</label>
<select id="mf-${f.key}">${f.options.map((o) => `<option value="${esc(o.value)}">${esc(o.label)}</option>`).join("")}</select>`;
}
return `<label class="lbl">${esc(f.label)}</label>
<input id="mf-${f.key}" type="text" value="${esc(f.default || "")}" placeholder="${esc(f.placeholder || "")}">`;
}
function fieldHtml(f) {
return `<div class="modal-field">${fieldInputHtml(f)}</div>`;
}
// 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(`<div class="modal-field"><div class="grid-2">${groupFields.map((gf) => `<div>${fieldInputHtml(gf)}</div>`).join("")}</div></div>`);
} 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 = `<h2>${esc(title)}</h2><form id="modal-form">
${fieldsHtml(fields)}
<div class="modal-actions"><button type="button" class="btn" id="modal-cancel">Abbrechen</button><button type="submit" class="btn primary">${esc(submitLabel)}</button></div>
</form>`;
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 = `<h2>${esc(title)}</h2><p class="prose">${esc(message)}</p>
<div class="modal-actions">
<button type="button" class="btn" id="modal-cancel">Abbrechen</button>
<button type="button" class="btn ${danger ? "danger" : "primary"}" id="modal-confirm">${esc(confirmLabel)}</button>
</div>`;
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 = `<h2>Einstellungen</h2>
<div class="settings-tabstrip" id="settings-tabstrip">
<button type="button" class="tab active" style="--tab-color:var(--tab-1)" data-stab="allgemein"><svg><use href="#i-gear"/></svg>Allgemein</button>
<button type="button" class="tab" style="--tab-color:var(--tab-4)" data-stab="backup"><svg><use href="#i-folder"/></svg>Datensicherung</button>
<button type="button" class="tab" style="--tab-color:var(--tab-5)" data-stab="tablet"><svg><use href="#i-external"/></svg>Tablet-Zugang</button>
</div>
<form id="smtp-form">
<div class="settings-panel active" data-spanel="allgemein">
<h3 style="margin-bottom:10px">E-Mail-Versand</h3>
<p class="hint" style="margin:-4px 0 14px">Für den automatischen Versand der unterschriebenen PDFs. Diese Zugangsdaten liegen unverschlüsselt im gemeinsamen Datenordner.</p>
<div class="grid-2">
<div class="modal-field"><label class="lbl">SMTP-Server (Host)</label><input id="smtp-host" type="text" value="${esc(current.host || "")}" placeholder="z. B. smtp.gmail.com"></div>
<div class="modal-field"><label class="lbl">Port</label><input id="smtp-port" type="text" value="${esc(current.port || "587")}"></div>
<div class="modal-field"><label class="lbl">Benutzername (meist deine E-Mail-Adresse)</label><input id="smtp-user" type="text" value="${esc(current.user || "")}"></div>
<div class="modal-field"><label class="lbl">Passwort (bzw. App-Passwort)</label><input id="smtp-pass" type="password" value="${esc(current.pass || "")}"></div>
<div class="modal-field"><label class="lbl">Absender-Name</label><input id="smtp-fromname" type="text" value="${esc(current.fromName || "Iris Hack Tierheilpraxis")}"></div>
<div class="modal-field"><label class="lbl">Absender-E-Mail (falls abweichend)</label><input id="smtp-fromemail" type="text" value="${esc(current.fromEmail || "")}" placeholder="${esc(current.user || "")}"></div>
</div>
<div class="modal-field"><label class="check-row"><input id="smtp-secure" type="checkbox" ${current.secure ? "checked" : ""}> Verschlüsselte Verbindung (SSL, meist Port 465)</label></div>
<div id="smtp-status"></div>
<h3 style="margin:20px 0 10px">Lexware & Erinnerungen</h3>
<div class="grid-2">
<div class="modal-field"><label class="lbl">Lexware-Pfad oder Web-Adresse</label><input id="lexware-path" type="text" value="${esc(lexwareCurrent)}" placeholder="C:\\Program Files\\Lexware\\... oder https://…"></div>
<div class="modal-field"><label class="lbl">Termin-Erinnerung (Minuten vorher)</label><input id="reminder-lead" type="text" value="${esc(reminderLead)}"></div>
</div>
</div>
<div class="settings-panel" data-spanel="backup">
<h3 style="margin-bottom:10px">Sicherung</h3>
<p class="hint" style="margin:-4px 0 10px">Täglich beim ersten Start wird automatisch eine Sicherung des Datenordners (Datenbank + Fotos/Dokumente) in <code>backups/</code> angelegt, die letzten 30 werden aufgehoben.</p>
<p class="hint" id="backup-info" style="margin:0 0 12px">${backupInfoText(backupInfo)}</p>
<div class="modal-field" style="display:flex; gap:8px">
<button type="button" class="btn" id="backup-now">Jetzt sichern</button>
<button type="button" class="btn" id="backup-open">Backup-Ordner öffnen</button>
</div>
<h3 style="margin:20px 0 6px">Wiederherstellung</h3>
<p class="hint" style="margin:-2px 0 10px">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.</p>
<div id="restore-list">${renderRestoreList(backups)}</div>
</div>
<div class="settings-panel" data-spanel="tablet">
<h3 style="margin-bottom:6px">Tablet / Mac im WLAN verbinden</h3>
<p class="hint" style="margin:-2px 0 14px">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.</p>
<div class="modal-field"><button type="button" class="btn primary" id="pair-start">Neues Gerät koppeln</button></div>
<div id="pair-panel"></div>
<h3 style="margin:22px 0 6px">Gekoppelte Geräte</h3>
<div id="web-devices-list">${renderWebDevicesList(webDevices)}</div>
</div>
<div class="modal-actions">
<button type="button" class="btn" id="modal-cancel">Abbrechen</button>
<button type="button" class="btn" id="smtp-test">E-Mail-Verbindung testen</button>
<button type="submit" class="btn primary">Speichern</button>
</div>
</form>`;
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 = `<div class="modal-status ${kind}">${esc(msg)}</div>`;
};
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 '<p class="hint" style="color:var(--ink-faint)">Noch keine Sicherungen vorhanden.</p>';
return `<div class="card" style="padding:4px 14px">${backups
.map(
(name) => `<div class="backup-row">
<div class="d">${backupRowLabel(name)}</div>
<button type="button" class="btn" data-restore="${esc(name)}">Wiederherstellen</button>
</div>`
)
.join("")}</div>`;
}
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 = `<div class="card" style="margin-top:12px; display:flex; gap:18px; align-items:center; flex-wrap:wrap">
<img src="${info.qr}" alt="QR-Code" style="width:150px; height:150px; border-radius:var(--radius-s); border:1px solid var(--border)">
<div>
<div class="hint" style="margin-bottom:4px">Mit Kamera/QR-Scanner am Tablet scannen, oder von Hand eingeben:</div>
<div style="font-family:var(--font-head); font-size:26px; font-weight:700; letter-spacing:.15em; color:var(--accent-ink)">${esc(info.code)}</div>
<div class="hint" style="margin-top:6px">Adresse: ${esc(info.url)}</div>
<div class="hint" style="margin-top:2px">Gültig noch ca. ${expiresIn} Min. — beide Geräte müssen im selben WLAN sein.</div>
</div>
</div>`;
}
function renderWebDevicesList(devices) {
if (!devices.length) return '<p class="hint" style="color:var(--ink-faint)">Noch kein Gerät gekoppelt.</p>';
return `<div class="card" style="padding:4px 14px">${devices
.map(
(d) => `<div class="backup-row">
<div class="d">${esc(d.name)}<small>gekoppelt ${fmtDate(d.pairedAt)} · zuletzt ${fmtDate(d.lastSeen)}</small></div>
<button type="button" class="btn danger" data-revoke-device="${esc(d.id)}">Zugriff entziehen</button>
</div>`
)
.join("")}</div>`;
}
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 `
<button class="animal-row ${a.id === currentAnimalId ? "active" : ""} ${a.deceased_at ? "deceased" : ""}" data-id="${a.id}">
<div class="animal-avatar" style="background:${SPECIES_COLOR[a.species] || SPECIES_COLOR.other}"><svg><use href="#${SPECIES_ICON[a.species] || "i-paw"}"/></svg></div>
<div class="animal-meta"><div class="nm">${esc(a.name)}${a.deceased_at ? ' <svg class="rip-mark"><use href="#i-rip"/></svg>' : ""}</div><div class="sp">${noteHit ? "Treffer in Notizen" : esc(a.species_label || a.species)}</div></div>
</button>`;
})
.join("");
return `<div class="owner-group">
<div class="owner-name-row"><span class="owner-name">${esc(owner.name)}</span>
<button class="add-animal-btn" data-owner="${owner.id}" title="Tier hinzufügen"><svg><use href="#i-plus"/></svg></button>
<button class="add-animal-btn danger" data-delete-owner="${owner.id}" data-owner-name="${esc(owner.name)}" data-animal-count="${owner.animals.length}" title="Besitzer löschen"><svg><use href="#i-trash"/></svg></button>
</div>
${rows || '<div class="empty-owners">Noch kein Tier</div>'}
</div>`;
})
.join("");
list.innerHTML = groups || '<div class="empty-owners">Noch keine Besitzer angelegt.</div>';
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 = `<span>${esc(owner.name)}</span><span style="opacity:.5"></span><b>${esc(a.name)}</b>`;
document.getElementById("avatar-lg").style.background = SPECIES_COLOR[a.species] || SPECIES_COLOR.other;
document.getElementById("avatar-lg").innerHTML = `<svg><use href="#${SPECIES_ICON[a.species] || "i-paw"}"/></svg>`;
document.getElementById("animal-name").textContent = a.name;
const deceasedChip = a.deceased_at ? `<span class="chip deceased-chip"><svg><use href="#i-rip"/></svg>Verstorben am ${esc(a.deceased_at)}</span>` : "";
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) => `<span class="chip">${esc(c)}</span>`)
.join("");
document.getElementById("owner-card").innerHTML = `
<div class="lbl">Besitzer</div>
<div class="nm">${esc(owner.name)}</div>
${owner.phone ? `<div class="line"><svg><use href="#i-phone"/></svg>${esc(owner.phone)}</div>` : ""}
${owner.email ? `<div class="line"><svg><use href="#i-mail"/></svg>${esc(owner.email)}</div>` : ""}`;
renderTabstrip();
renderPanels();
}
function renderTabstrip() {
document.getElementById("tabstrip").innerHTML = TABS.map(
(t) => `<button class="tab ${t.id === activeTab ? "active" : ""}" style="--tab-color:${t.color}" data-tab="${t.id}">
<svg style="color:${t.color}"><use href="#${t.icon}"/></svg>${t.label}</button>`
).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) => `<div class="panel ${t.id === activeTab ? "active" : ""}" id="panel-${t.id}">${map[t.id](a)}</div>`
).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 `<div class="field" data-field="${key}" data-entity="${entity}"><div class="lbl">${esc(label)}</div><div class="val" tabindex="0">${esc(value) || "—"}</div></div>`;
}
function panelStammdaten(a) {
return `<div class="card"><h3>Grunddaten</h3><div class="grid-2">
${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)}
</div><p class="hint">Auf einen Wert klicken, um ihn zu ändern — speichert automatisch.</p></div>
<div class="card"><h3>Besitzer</h3><div class="grid-2">
${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")}
</div><p class="hint">Auf einen Wert klicken, um ihn zu ändern — speichert automatisch. Gilt für alle Tiere dieses Besitzers.</p></div>
<div class="card"><h3>Status</h3>
<div class="consent-row" style="border-top:0; padding-top:0">
<div><div class="t">${a.deceased_at ? "Verstorben" : "Aktiv"}</div><div class="d">${a.deceased_at ? "Datum siehe unten" : "Kein Sterbedatum hinterlegt"}</div></div>
<button class="btn" id="deceased-toggle">${a.deceased_at ? "Als aktiv markieren" : '<svg><use href="#i-rip"/></svg>Als verstorben markieren'}</button>
</div>
${a.deceased_at ? `<div style="margin-top:12px">${editableField("deceased_at", "Verstorben am", a.deceased_at)}</div>` : ""}
</div>`;
}
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 `<div class="af-field${wideCls}"><label>${esc(f.label)}</label><input type="text" data-key="${f.key}" data-type="text" value="${esc(val || "")}"></div>`;
if (f.type === "textarea")
return `<div class="af-field${wideCls}"><label>${esc(f.label)}</label><textarea data-key="${f.key}" data-type="textarea">${esc(val || "")}</textarea></div>`;
if (f.type === "radio")
return `<div class="af-field${wideCls}"><label>${esc(f.label)}</label><div class="af-options">${f.options
.map((o) => `<label class="af-opt"><input type="radio" name="af-${f.key}" data-key="${f.key}" data-type="radio" value="${esc(o)}" ${val === o ? "checked" : ""}><span>${esc(o)}</span></label>`)
.join("")}</div></div>`;
// check
const arr = Array.isArray(val) ? val : [];
return `<div class="af-field${wideCls}"><label>${esc(f.label)}</label><div class="af-options">${f.options
.map((o) => `<label class="af-opt"><input type="checkbox" data-key="${f.key}" data-type="check" value="${esc(o)}" ${arr.includes(o) ? "checked" : ""}><span>${esc(o)}</span></label>`)
.join("")}</div></div>`;
}
function panelAnamnese(a) {
const data = getAnamneseData();
const sections = ANAMNESE_SCHEMA.map(
(s) => `<div class="card af-section"><h4>${esc(s.title)}</h4><div class="af-grid">${s.fields.map((f) => afFieldHtml(f, data)).join("")}</div></div>`
).join("");
return `<div class="card">
<h3>Kurzfassung</h3>
<textarea class="anamnese-box" id="anamnese-box" placeholder="Vorstellungsgrund, Vorgeschichte, Fütterung, Wesen …">${esc(a.anamnese)}</textarea>
<p class="hint">Speichert automatisch, sobald du das Feld verlässt.</p>
</div>
<div class="card anamnese-toggle">
<p>Vollständiger Erstanamnese-Fragebogen (Herkunft, Haltung, Impfstatus, Ernährung, Verhalten …)</p>
<button class="btn" id="anamnese-expand-btn">${anamneseExpanded ? "Einklappen" : "Fragebogen öffnen"}</button>
</div>
${anamneseExpanded ? `<div class="af-section-grid">${sections}</div>` : ""}`;
}
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 `<div class="card"><h3>Notizen</h3>
<div class="note-composer"><input id="note-input" placeholder="Neue Notiz hinzufügen und Enter drücken…">
<button class="btn primary" id="note-add"><svg><use href="#i-plus"/></svg>Hinzufügen</button></div>
${bundle.notes.map((n) => `<div class="note"><div class="date">${n.date}</div><div class="prose">${esc(n.text)}</div></div>`).join("") || '<p class="prose" style="color:var(--ink-faint)">Noch keine Notizen.</p>'}
</div>`;
}
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 `<div class="card"><h3>Verlauf</h3>
<div class="composer-2">
<input id="hist-title" placeholder="Titel (z. B. Kontrolltermin)">
<input id="hist-text" placeholder="Kurzbeschreibung">
<button class="btn primary" id="hist-add"><svg><use href="#i-plus"/></svg></button>
</div>
<div class="timeline">${bundle.history.map((v) => `<div class="tl-item"><div class="date">${v.date}</div><h4>${esc(v.title)}</h4><p>${esc(v.text)}</p></div>`).join("") || '<p class="prose" style="color:var(--ink-faint)">Noch keine Einträge.</p>'}</div>
</div>`;
}
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) => `<div class="card">
<div class="card-head-row"><h3 style="margin-bottom:0">${esc(l.name)} · <span style="font-weight:400;text-transform:none;letter-spacing:0">${l.date}</span></h3>
<button class="btn ghost lab-del" data-id="${l.id}">Löschen</button></div>
<table class="lab-table"><thead><tr><th>Parameter</th><th>Wert</th><th>Referenz</th></tr></thead><tbody>
${l.values.map((r) => `<tr><td>${esc(r.parameter)}</td><td>${esc(r.value)}</td><td>${esc(r.reference)}</td></tr>`).join("")}
</tbody></table>
<div class="lab-add-row">
<input class="lv-param" placeholder="Parameter" data-lab="${l.id}">
<input class="lv-value" placeholder="Wert" data-lab="${l.id}">
<input class="lv-ref" placeholder="Referenz" data-lab="${l.id}">
<button class="btn lv-add" data-lab="${l.id}"><svg><use href="#i-plus"/></svg></button>
</div>
</div>`
)
.join("");
return `${cards}<div class="card"><button class="btn primary" id="lab-new"><svg><use href="#i-plus"/></svg>Neuer Laborbefund</button></div>`;
}
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 `<div class="card"><h3>Bilder</h3><div class="photo-grid">
${bundle.photos.map((p) => `<div class="photo-tile"><img src="${p.fileUrl}" alt="${esc(p.filename)}"><div class="fn">${esc(p.filename)}</div></div>`).join("")}
<div class="photo-tile add" id="add-photo"><svg><use href="#i-upload"/></svg>Foto hinzufügen</div>
</div></div>`;
}
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 `<div class="card"><h3>Vorlagen erstellen</h3>
<p class="hint" style="margin-top:-4px">Erzeugt ein PDF mit eurem Briefkopf, direkt für ${esc(a.name)} — landet automatisch unten in den Dokumenten.</p>
<div class="sig-actions" style="margin-top:10px">
<button class="btn" id="tpl-ernaehrungsplan"><svg><use href="#i-file"/></svg>Ernährungsplan erstellen</button>
<button class="btn" id="tpl-rezept"><svg><use href="#i-file"/></svg>Rezept erstellen</button>
</div>
</div>
<div class="card"><h3>Dokumente</h3>
<div class="dropzone" id="doc-upload">PDF, Scan oder Foto ablegen — oder <b>Datei auswählen</b></div>
<div id="doc-list">${bundle.documents.map(docRow).join("") || '<p class="prose" style="color:var(--ink-faint)">Noch keine Dokumente.</p>'}</div>
</div>`;
}
function docRow(d) {
const kb = d.size_bytes ? Math.max(1, Math.round(d.size_bytes / 1024)) + " KB" : "";
return `<div class="doc-row"><div class="doc-icon"><svg><use href="#i-file"/></svg></div>
<div><div class="doc-name">${esc(d.filename)}</div><div class="doc-meta">${fmtDate(d.uploaded_at)} · ${kb}</div></div>
<button class="btn doc-open" data-id="${d.id}">Öffnen</button></div>`;
}
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 `<div class="fp-row" data-row="${i}">
<div class="fp-row-head">
<input type="text" class="fp-futtermittel" placeholder="z. B. Trockenfutter Marke X">
<button type="button" class="fp-row-remove" title="Zeile entfernen"><svg><use href="#i-trash"/></svg></button>
</div>
<div class="fp-row-grid">
<div class="fp-field"><label class="lbl">Tage</label><div class="chips">${WOCHENTAGE.map((t) => `<label class="chip-check"><input type="checkbox" class="fp-tag" value="${t}"><span>${t}</span></label>`).join("")}</div></div>
<div class="fp-field"><label class="lbl">Wann</label><div class="chips">${TAGESZEITEN.map((t) => `<label class="chip-check"><input type="checkbox" class="fp-zeit" value="${t}"><span>${t}</span></label>`).join("")}</div></div>
<div class="fp-field"><label class="lbl">Menge</label><input type="text" class="fp-menge" placeholder="z. B. 200 g"></div>
</div>
</div>`;
}
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 = `<h2>Ernährungsplan für ${esc(bundle.animal.name)}</h2>
<div id="fp-rows" style="max-height:50vh; overflow-y:auto; margin-bottom:4px; padding-right:2px">${ernaehrungsRowHtml(0)}</div>
<button type="button" class="btn" id="fp-add-row" style="margin-bottom:14px">+ Zeile hinzufügen</button>
<div class="modal-field"><label class="lbl">Anmerkungen</label><textarea id="fp-notes" style="width:100%;min-height:60px;border:1px solid var(--border);border-radius:var(--radius-s);padding:9px 11px;font-size:13.5px;outline:0;background:var(--surface);font-family:inherit"></textarea></div>
<div id="fp-status"></div>
<div class="modal-actions">
<button type="button" class="btn" id="modal-cancel">Abbrechen</button>
<button type="button" class="btn primary" id="fp-create">PDF erstellen</button>
</div>`;
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 = '<div class="modal-status error">Bitte mindestens ein Futtermittel eintragen.</div>';
return;
}
const notes = document.getElementById("fp-notes").value.trim();
const bodyHtml = `<table class="lab-table" style="width:100%;border-collapse:collapse">
<thead><tr><th style="text-align:left;border-bottom:1px solid #e2d0bd;padding:6px">Futtermittel</th><th style="text-align:left;border-bottom:1px solid #e2d0bd;padding:6px">Tag</th><th style="text-align:left;border-bottom:1px solid #e2d0bd;padding:6px">Wann</th><th style="text-align:left;border-bottom:1px solid #e2d0bd;padding:6px">Menge</th></tr></thead>
<tbody>${rows
.map(
(r) =>
`<tr><td style="padding:6px;border-bottom:1px solid #e2d0bd">${esc(r.futtermittel)}</td><td style="padding:6px;border-bottom:1px solid #e2d0bd">${esc(r.tage.join(", ") || "—")}</td><td style="padding:6px;border-bottom:1px solid #e2d0bd">${esc(r.zeiten.join(", ") || "—")}</td><td style="padding:6px;border-bottom:1px solid #e2d0bd">${esc(r.menge || "—")}</td></tr>`
)
.join("")}</tbody></table>
${notes ? `<h2>Anmerkungen</h2><p>${esc(notes)}</p>` : ""}`;
document.getElementById("fp-status").innerHTML = '<div class="modal-status ok">PDF wird erstellt …</div>';
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 = `<h2>Rezept für ${esc(bundle.animal.name)}</h2>
<div class="modal-field"><label class="lbl">Datum</label><input id="rz-date" type="text" value="${esc(todayStr)}"></div>
<div class="modal-field"><label class="lbl">Verordnung</label><textarea id="rz-text" style="width:100%;min-height:180px;border:1px solid var(--border);border-radius:var(--radius-s);padding:9px 11px;font-size:13.5px;outline:0;background:var(--surface);font-family:inherit" placeholder="z. B. Präparate, Dosierung, Anwendungshinweise …"></textarea></div>
<div id="rz-status"></div>
<div class="modal-actions">
<button type="button" class="btn" id="modal-cancel">Abbrechen</button>
<button type="button" class="btn primary" id="rz-create">PDF erstellen</button>
</div>`;
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 = '<div class="modal-status error">Bitte einen Text eintragen.</div>';
return;
}
const dateStr = document.getElementById("rz-date").value.trim();
document.getElementById("rz-status").innerHTML = '<div class="modal-status ok">PDF wird erstellt …</div>';
const bodyHtml = `<p><b>Datum:</b> ${esc(dateStr)}</p><p style="white-space:pre-wrap">${esc(text)}</p>
<p style="margin-top:26px;font-size:10.5px;font-style:italic;color:#7a6c60">Empfehlung im Rahmen der Tierheilpraxis keine tierärztliche Verschreibung.</p>`;
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 = `<div class="card"><h3>Datenschutzrechtliche Einwilligung</h3><div class="consent-list">
${CONSENT_ITEMS.map(
(it) => `<label class="consent-item"><input type="checkbox" data-key="${it.key}" ${items[it.key] ? "checked" : ""}><span>${esc(it.label)}</span></label>`
).join("")}
</div></div>`;
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
? `<div class="sig-pad-wrap"><canvas class="sig-pad" id="sig-pad" width="600" height="200"></canvas>
<div class="sig-actions"><button class="btn" id="sig-clear">Löschen</button><button class="btn primary" id="sig-save">Unterschrift speichern</button></div></div>`
: c.signature_url
? `<div class="sig-box"><img src="${c.signature_url}" alt="Unterschrift"></div>
<div class="sig-actions"><button class="btn" id="sig-redo">Neu unterschreiben</button></div>`
: `<div class="sig-actions"><button class="btn primary" id="sig-start">Auf dem Tablet unterschreiben lassen</button></div>`;
return `${checklist}<div class="card"><h3>Datenschutz &amp; Einverständnis</h3>
<div class="consent-row"><div><div class="t">Behandlungseinverständnis</div><div class="d">${granted ? `Erteilt am ${fmtDate(c.signed_at)}${c.method ? " · " + esc(c.method) : ""}` : "Noch nicht erteilt"}</div></div>
<button class="${granted ? "badge-ok" : "badge-off"}" id="consent-toggle">${granted ? '<svg><use href="#i-check"/></svg>Erteilt' : "Nicht erteilt"}</button></div>
<div class="consent-row"><div><div class="t">Kontakt für Erinnerungen / Neuigkeiten</div><div class="d">${c.marketing_ok ? "Zustimmung erteilt" : "Keine Zustimmung erteilt"}</div></div>
<button class="${c.marketing_ok ? "badge-ok" : "badge-off"}" id="marketing-toggle">${c.marketing_ok ? '<svg><use href="#i-check"/></svg>Ja' : "Nein"}</button></div>
${sigSection}
${emailSection("einwilligung", c)}
</div>`;
}
// 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 ? `<div class="email-status ${status.kind}">${esc(status.msg)}</div>` : "";
if (!record || !record.signature_path) return statusHtml;
const lastSent = record.email_sent_at ? `<p class="hint" style="margin-top:10px">Zuletzt per E-Mail gesendet am ${fmtDate(record.email_sent_at)}.</p>` : "";
return `${lastSent}<div class="sig-actions" style="margin-top:${lastSent ? "0" : "10px"}"><button class="btn email-send-btn" data-email-kind="${kind}">PDF per E-Mail senden</button></div>${statusHtml}`;
}
function buildOwnerBlockHtml(owner, animal) {
const esc2 = esc;
return `
<div class="row"><span class="lbl">Tierhalter/in</span><span>${esc2(owner.name)}</span></div>
<div class="row"><span class="lbl">Adresse</span><span>${esc2(owner.address) || "—"}</span></div>
<div class="row"><span class="lbl">E-Mail</span><span>${esc2(owner.email) || "—"}</span></div>
<div class="row"><span class="lbl">Tier</span><span>${esc2(animal.name)} (${esc2(animal.species_label)})</span></div>`;
}
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) => `<h2>${esc(s.title)}</h2>${s.html}`).join("")
: `<ul>${CONSENT_ITEMS.map((it) => `<li class="${getConsentItems()[it.key] ? "checked" : "unchecked"}">${esc(it.label)}</li>`).join("")}</ul>`;
const signedInfoHtml = `<p><b>Unterschrieben von:</b> ${esc(owner.name)}<br><b>Datum:</b> ${fmtDate(record.signed_at)}<br><b>Methode:</b> ${esc(record.method || "")}</p>`;
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) => `<h4>${esc(s.title)}</h4>${s.html}`).join("");
const sigSection = showContractSigPad
? `<div class="sig-pad-wrap"><canvas class="sig-pad" id="vertrag-sig-pad" width="600" height="200"></canvas>
<div class="sig-actions"><button class="btn" id="vertrag-sig-clear">Löschen</button><button class="btn primary" id="vertrag-sig-save">Unterschrift speichern</button></div></div>`
: c.signature_url
? `<div class="sig-box"><img src="${c.signature_url}" alt="Unterschrift"></div>
<div class="sig-actions"><button class="btn" id="vertrag-sig-redo">Neu unterschreiben</button></div>`
: `<div class="sig-actions"><button class="btn primary" id="vertrag-sig-start" ${c.briefing_confirmed ? "" : "disabled"}>Auf dem Tablet unterschreiben lassen</button></div>
${c.briefing_confirmed ? "" : '<p class="hint">Erst die Bestätigung oben ankreuzen, dann kann unterschrieben werden.</p>'}`;
return `<div class="card">
<h3>Behandlungsvertrag mit Allgemeinen Geschäftsbedingungen</h3>
<div class="contract-text">${sections}</div>
<label class="confirm-row"><input type="checkbox" id="vertrag-confirm" ${c.briefing_confirmed ? "checked" : ""}>
<span>Ich habe das Aufklärungsgespräch verstanden und habe keine weiteren Fragen. Ich willige hiermit nach ausreichender Bedenkzeit in die vorgeschlagene Untersuchung und Behandlung ein.</span></label>
</div>
<div class="card"><h3>Unterschrift</h3>
<div class="consent-row"><div><div class="t">Vertrag unterschrieben</div><div class="d">${granted ? `Am ${fmtDate(c.signed_at)}${c.method ? " · " + esc(c.method) : ""}` : "Noch nicht unterschrieben"}</div></div>
<span class="${granted ? "badge-ok" : "badge-off"}">${granted ? '<svg><use href="#i-check"/></svg>Erteilt' : "Nicht unterschrieben"}</span></div>
${sigSection}
${emailSection("vertrag", c)}
</div>`;
}
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 `<div class="card">
<h3>Untersuchung dokumentieren</h3>
<div class="af-grid" id="exam-form">
${EXAM_SCHEMA.map((f) => `<div class="af-field"><label>${esc(f.label)}</label><input type="text" data-exam-key="${f.key}"></div>`).join("")}
<div class="af-field af-wide"><label>Auffälligkeiten / Besonderes</label><textarea data-exam-key="auffaelligkeiten"></textarea></div>
<div class="af-field af-wide"><label>Empfehlung / Beratung / Verbleib</label><textarea data-exam-key="empfehlung"></textarea></div>
</div>
<button class="btn primary" id="exam-save" style="margin-top:12px"><svg><use href="#i-plus"/></svg>Untersuchung speichern</button>
</div>`;
}
function panelExamHistory() {
if (!bundle.exams.length) return "";
return `<div class="card"><h3>Untersuchungsverlauf</h3><div class="timeline">
${bundle.exams
.map((ex) => {
const rows = EXAM_SCHEMA.filter((f) => ex[f.key]).map((f) => `<span class="chip">${esc(f.label)}: ${esc(ex[f.key])}</span>`).join("");
return `<div class="tl-item"><div class="date">${ex.date}</div>
${rows ? `<div class="chips" style="margin:6px 0">${rows}</div>` : ""}
${ex.auffaelligkeiten ? `<p><b>Auffälligkeiten:</b> ${esc(ex.auffaelligkeiten)}</p>` : ""}
${ex.empfehlung ? `<p><b>Empfehlung:</b> ${esc(ex.empfehlung)}</p>` : ""}
</div>`;
})
.join("")}
</div></div>`;
}
function panelBehandlung(a) {
const findings = bundle.findings
.map(
(p) => `<div class="finding" data-id="${p.id}"><div class="sw" style="background:${SEV[p.severity].color}"></div>
<div class="tx"><b>${SEV[p.severity].label}</b> — ${esc(p.text)}</div>
<button class="rm" data-id="${p.id}" aria-label="Befund entfernen">✕</button></div>`
)
.join("") || `<p class="prose" style="color:var(--ink-faint)">Noch keine Markierungen. Auf die Silhouette klicken, um einen Befund einzutragen.</p>`;
return `${panelExamForm()}${panelExamHistory()}<div class="card">
<h3>Körper-Silhouette</h3>
<p class="bm-hint">Auf die Silhouette klicken oder tippen, um an der berührten Stelle einen Befund zu markieren.</p>
<div class="bm-layout">
<div class="bm-stage" id="bm-stage" style="${SILHOUETTE_ASPECT[a.species] ? `aspect-ratio:${SILHOUETTE_ASPECT[a.species]}` : ""}">
${silhouette(a.species)}
${bundle.findings.map((p, i) => `<div class="bm-pin" data-id="${p.id}" style="left:${p.x}%; top:${p.y}%; background:${SEV[p.severity].color}"><span>${i + 1}</span></div>`).join("")}
${bmPopup ? bmPopupHtml(bmPopup) : ""}
</div>
<div class="finding-list"><h3>Markierte Befunde</h3>${findings}</div>
</div>
</div>`;
}
function bmPopupHtml(p) {
const left = Math.min(Math.max(p.x, 10), 60);
const top = Math.min(Math.max(p.y, 8), 58);
return `<div class="bm-pop" style="left:${left}%; top:${top}%">
<textarea id="pop-text" placeholder="Was ist aufgefallen?">${esc(p.text || "")}</textarea>
<div class="sev-row">
<button class="sev-btn ${p.sev === "ok" ? "sel-ok" : ""}" data-sev="ok">unauffällig</button>
<button class="sev-btn ${p.sev === "warn" ? "sel-warn" : ""}" data-sev="warn">beobachten</button>
<button class="sev-btn ${p.sev === "critical" ? "sel-critical" : ""}" data-sev="critical">auffällig</button>
</div>
<div class="bm-pop-actions">
${p.editId ? '<button class="btn danger" id="pop-delete">Löschen</button>' : ""}
<button class="btn" id="pop-cancel">Abbrechen</button>
<button class="btn primary" id="pop-save">Speichern</button>
</div>
</div>`;
}
function silhouette(species) {
if (SILHOUETTE_IMAGE[species]) {
return `<img class="silhouette" src="../assets/${SILHOUETTE_IMAGE[species]}" alt="">`;
}
if (species === "tortoise")
return `<svg class="silhouette" viewBox="0 0 220 150" preserveAspectRatio="xMidYMid meet">
<g fill="var(--ink-faint)">
<ellipse cx="118" cy="78" rx="30" ry="22" opacity=".5"/>
<ellipse cx="118" cy="78" rx="62" ry="42" opacity=".85"/>
<circle cx="42" cy="72" r="14" opacity=".85"/>
<ellipse cx="70" cy="42" rx="10" ry="9" opacity=".85"/>
<ellipse cx="70" cy="114" rx="10" ry="9" opacity=".85"/>
<ellipse cx="166" cy="42" rx="10" ry="9" opacity=".85"/>
<ellipse cx="166" cy="114" rx="10" ry="9" opacity=".85"/>
<ellipse cx="184" cy="78" rx="9" ry="7" opacity=".85"/>
</g>
<g stroke="var(--surface-alt)" stroke-width="1.6" fill="none" opacity=".9">
<path d="M70 78h96M118 44v68M92 52l16 14M144 52l-16 14M92 104l16-14M144 104l-16-14"/>
<ellipse cx="118" cy="78" rx="62" ry="42"/>
</g>
</svg>`;
return `<svg class="silhouette" viewBox="0 0 220 150" preserveAspectRatio="xMidYMid meet">
<g fill="var(--ink-faint)" opacity=".55"><use href="#i-paw" x="70" y="35" width="80" height="80"/></g>
</svg>`;
}
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();