Upload via GUI (10 Dateien)

This commit is contained in:
Git Manager GUI
2026-09-14 20:56:41 +02:00
commit 6733901d99
10 changed files with 8679 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
node_modules/
dist/
.fellakte.lock
*.log
+157
View File
@@ -0,0 +1,157 @@
# Fellakte — Anleitung
Die digitale Patientenmappe für die Tierheilpraxis Iris Hack. Ersetzt die
Papier-Karteikarte: ein Ordner pro Tier mit allem drin — Stammdaten, Anamnese,
Notizen, Körperkarte, Termine, Dokumente, Unterschriften.
Alle Daten liegen **nur bei dir** (auf den Praxis-PCs bzw. dem gemeinsamen Ordner) —
keine Cloud, keine Internetverbindung nötig für den täglichen Betrieb.
---
## 1. Erste Einrichtung (einmalig, auf dem ersten PC)
1. Die Installationsdatei ausführen (`Fellakte Setup X.X.X.exe` unter Windows,
die `.dmg` auf dem Mac).
2. **Windows:** Dem Installationsassistenten folgen (Fertigstellen reicht, Standard-
Einstellungen lassen). **Mac:** Die `.dmg` öffnen, Fellakte in den
Programme-Ordner ziehen. Da die App nicht käuflich signiert ist, zeigt macOS
beim allerersten Start eine Warnung — dann mit **Rechtsklick auf Fellakte →
Öffnen → nochmal Öffnen bestätigen**. Danach startet sie ganz normal per
Doppelklick.
3. Beim ersten Start fragt Fellakte nach einem **Datenordner**. Hier legt sie
alles ab (Kartei + Fotos/Dokumente). Wichtig für später: diesen Ordner an
einem Ort wählen, den auch der zweite PC erreichen kann — z. B. ein
gemeinsames Netzlaufwerk, ein NAS, oder ein synchronisierter Ordner
(OneDrive, Dropbox). Nutzt du nur einen PC, reicht ein ganz normaler Ordner.
## 2. Zweiten PC (oder Mac) einrichten
Genau wie Schritt 1 — nur bei der Frage nach dem Datenordner **denselben Ordner**
auswählen wie beim ersten PC (also z. B. dasselbe Netzlaufwerk). Danach sehen
beide PCs dieselbe Kartei.
**Faustregel:** Nicht an zwei Geräten gleichzeitig an derselben Akte arbeiten.
Fellakte warnt, wenn ein anderes Gerät den Ordner kürzlich benutzt hat — das ist
aber nur ein Hinweis, keine echte Sperre.
## 3. Einen Besitzer & ein Tier anlegen
- Links auf **"+ Neuer Besitzer"** → Name, Telefon, E-Mail, Adresse eintragen
- Beim Besitzer auf das **+**-Symbol klicken → neues Tier anlegen (Name, Tierart,
Rasse, Geburtsdatum, Gewicht, Chip-Nummer)
- Ein Besitzer kann mehrere Tiere haben — einfach mehrfach auf **+** klicken
Alle Werte lassen sich später jederzeit anklicken und ändern (speichert
automatisch).
## 4. Die Mappe eines Tieres
Nach Klick auf ein Tier links öffnen sich oben die Reiter:
| Reiter | Wofür |
|---|---|
| **Stammdaten** | Grunddaten des Tiers + Besitzerdaten, Status (aktiv/verstorben) |
| **Anamnese** | Der ausführliche Fragebogen (Herkunft, Haltung, Ernährung, Verhalten, Vorerkrankungen …) |
| **Notizen** | Kurze, schnelle Einträge — ein Textfeld, Enter drücken zum Speichern |
| **Behandlung** | Klickbare Körperkarte zum Markieren von Befunden + Untersuchungsbogen (Puls, Temperatur, …) |
| **Verlauf** | Größere Einträge mit Titel (z. B. "Kontrolltermin") |
| **Labor** | Laborbefunde mit einzelnen Werten |
| **Bilder** | Fotos hochladen |
| **Dokumente** | Sonstige Dateien hochladen + hier entstehen auch alle erzeugten PDFs |
| **Datenschutz** | DSGVO-Einwilligung mit Checkliste + Unterschrift |
| **Behandlungsvertrag** | Vertragstext + Unterschrift |
### Befunde auf der Körperkarte markieren
Im Reiter **Behandlung** auf die Körper-Silhouette tippen/klicken → Notiz
eintragen, Einstufung wählen (unauffällig/beobachten/auffällig) → Speichern.
Vorhandene Markierungen antippen, um sie zu bearbeiten oder zu löschen.
### Unterschreiben lassen (Datenschutz / Behandlungsvertrag)
Im jeweiligen Reiter auf **"Auf dem Tablet unterschreiben lassen"** — das Gerät
(Tablet, Touchscreen-PC) dem Kunden reichen, unterschreiben lassen, speichern.
Es wird automatisch ein PDF erzeugt (landet im Reiter Dokumente) und — falls
E-Mail-Versand eingerichtet ist (siehe Einstellungen) — direkt an den Kunden
verschickt.
## 5. Vorlagen (Ernährungsplan, Rezept)
Im Reiter **Dokumente** unter "Vorlagen erstellen" → gewünschte Vorlage
auswählen, ausfüllen, "PDF erstellen". Landet ebenfalls im Reiter Dokumente,
mit eurem Briefkopf.
## 6. Kalender
Oben auf **"Kalender"** wechseln. **"+ Neuer Termin"** → Terminart antippen
(füllt Titel + übliche Dauer automatisch aus), Datum/Uhrzeit/Ort eintragen.
Termine mit Uhrzeit lösen automatisch eine Erinnerung aus (Standard: 30 Minuten
vorher, einstellbar unter Einstellungen).
## 7. Verzeichnis
Oben auf **"Verzeichnis"** — durchsuchbare Liste von Präparaten/Mitteln
(z. B. Bachblüten). Eigene Einträge über "+ Neuer Eintrag" ergänzen.
## 8. Datensicherung
Läuft **automatisch** einmal täglich im Hintergrund. Unter dem Zahnrad-Symbol
oben rechts → Reiter **Datensicherung** siehst du, wann zuletzt gesichert wurde,
kannst manuell sichern, und im Notfall eine ältere Sicherung wiederherstellen
(dabei wird der aktuelle Stand vorher automatisch selbst gesichert, nichts geht
verloren).
## 9. Tablet oder Handy verbinden
Die Kartei lässt sich auch von einem Tablet, Handy oder zusätzlichen Mac aus
nutzen — läuft komplett über euer eigenes WLAN, keine Cloud, keine Kosten.
**Einmalig koppeln:**
1. Am PC: Zahnrad-Symbol → Reiter **Tablet-Zugang****"Neues Gerät koppeln"**
→ zeigt einen QR-Code + 6-stelligen Code (10 Minuten gültig)
2. Am Tablet/Handy (im **selben WLAN**): QR-Code mit der Kamera scannen, oder
die angezeigte Adresse im Browser öffnen und den Code eintippen
3. Im Browser-Menü **"Zum Home-Bildschirm hinzufügen"** — danach wie eine
normale App mit eigenem Symbol startbar
**Im Alltag:**
- Vor der Fahrt zum Kunden: das betreffende Tier noch im WLAN kurz öffnen
(merkt es sich für offline)
- Beim Kunden: Notizen und Befunde funktionieren auch **ganz ohne Empfang**
oben rechts steht dann "Offline — X wartend"
- Zurück im WLAN: gleicht sich automatisch ab, kein Knopf zu drücken
- PDFs erstellen, Unterschrift und E-Mail-Versand brauchen dagegen Verbindung
zu einem der Praxis-PCs (der muss dafür an sein und die App offen haben)
**Zugriff entziehen:** Zahnrad → Tablet-Zugang → beim jeweiligen Gerät
"Zugriff entziehen".
## 10. Ein Tier ist verstorben
Im Reiter Stammdaten → "Als verstorben markieren". Bleibt in der Kartei
erhalten (mit kleinem Symbol markiert), lässt sich bei Bedarf rückgängig machen.
## 11. Einen Besitzer löschen
Neben dem Besitzernamen links auf das Mülleimer-Symbol. **Löscht unwiderruflich
auch alle seine Tiere** (inkl. Notizen, Dokumente, Fotos, Unterschriften) — vorher
wird noch mal nachgefragt.
## 12. Lexware
Oben rechts der Knopf "Lexware" öffnet direkt Lexware (Pfad einmalig unter
Zahnrad → Einstellungen hinterlegen).
## 13. Bei Problemen
| Was passiert | Was tun |
|---|---|
| "Datenordner wurde zuletzt woanders benutzt" | Kurz nachfragen, ob der andere PC gerade wirklich läuft, bevor beide gleichzeitig speichern |
| Tablet zeigt "Code falsch/abgelaufen" | Am PC einen neuen Code erzeugen (10 Min. gültig) |
| Tablet zeigt "Offline" obwohl WLAN da ist | Prüfen, ob der Praxis-PC an ist und Fellakte dort geöffnet ist |
| E-Mail-Versand klappt nicht | Zahnrad → Einstellungen → E-Mail-Zugangsdaten prüfen, "Verbindung testen" |
| Etwas ist aus Versehen falsch eingetragen worden | Wert einfach anklicken und korrigieren (speichert automatisch) |
| Größeres Durcheinander | Zahnrad → Datensicherung → passende ältere Sicherung wiederherstellen |
Bei allem, was hier nicht auftaucht: einfach schildern, was passiert ist.
+5001
View File
File diff suppressed because it is too large Load Diff
+67
View File
@@ -0,0 +1,67 @@
{
"name": "fellakte",
"version": "0.7.0",
"description": "Digitale Patientenmappe für die Tierheilpraxis Iris Hack",
"author": "Tierheilpraxis Iris Hack",
"main": "main/main.js",
"private": true,
"scripts": {
"start": "electron .",
"check": "node --check main/main.js && node --check main/db.js && node --check main/lock.js && node --check main/ipc.js && node --check main/preload.js && node --check main/backup.js && node --check main/reminders.js && node --check main/webserver.js && node --check main/documents.js",
"dist": "electron-builder --win",
"dist:mac": "electron-builder --mac"
},
"dependencies": {
"express": "^5.2.1",
"nodemailer": "^10.0.9",
"qrcode": "^1.5.4",
"sql.js": "^1.11.0"
},
"devDependencies": {
"electron": "^32.2.0",
"electron-builder": "^26.15.3"
},
"build": {
"appId": "de.tierheilpraxis-hack.fellakte",
"productName": "Fellakte",
"copyright": "Tierheilpraxis Iris Hack",
"publish": null,
"directories": {
"output": "dist"
},
"files": [
"main/**/*",
"renderer/**/*",
"web-client/**/*",
"assets/**/*",
"package.json",
"!**/.fellakte.lock"
],
"win": {
"target": [
{
"target": "nsis",
"arch": [
"x64"
]
}
],
"icon": "assets/icon.ico"
},
"mac": {
"target": [
{ "target": "dmg", "arch": ["universal"] }
],
"category": "public.app-category.medical",
"icon": "assets/logo.png"
},
"nsis": {
"oneClick": false,
"perMachine": false,
"allowToChangeInstallationDirectory": true,
"createDesktopShortcut": true,
"createStartMenuShortcut": true,
"shortcutName": "Fellakte"
}
}
}
+2228
View File
File diff suppressed because it is too large Load Diff
+109
View File
@@ -0,0 +1,109 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta http-equiv="Content-Security-Policy" content="default-src 'self' file:; img-src 'self' file: data:; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src https://fonts.gstatic.com; script-src 'self'">
<title>Fellakte</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Dancing+Script:wght@600;700&family=Montserrat:wght@500;600;700&family=Source+Sans+Pro:wght@400;600;700&display=swap">
<link rel="stylesheet" href="styles.css">
</head>
<body>
<svg width="0" height="0" style="position:absolute">
<defs>
<symbol id="i-id" viewBox="0 0 24 24"><rect x="3" y="5" width="18" height="14" rx="2.5" fill="none" stroke="currentColor" stroke-width="1.7"/><circle cx="8.3" cy="11" r="1.8" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M6 15.3c.5-1.4 1.6-2 2.3-2s1.8.6 2.3 2M13.5 9.5h5M13.5 12.5h5M13.5 15.5h3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></symbol>
<symbol id="i-clip" viewBox="0 0 24 24"><rect x="5" y="4" width="14" height="17" rx="2" fill="none" stroke="currentColor" stroke-width="1.7"/><rect x="9" y="2.5" width="6" height="3.5" rx="1.2" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M8 10.5h8M8 14h8M8 17.5h5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></symbol>
<symbol id="i-pencil" viewBox="0 0 24 24"><path d="M4 20l.9-3.9L15.6 5.4a1.6 1.6 0 0 1 2.3 0l.7.7a1.6 1.6 0 0 1 0 2.3L8 19.1 4 20z" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/><path d="M14 7l3 3" stroke="currentColor" stroke-width="1.6"/></symbol>
<symbol id="i-paw" viewBox="0 0 24 24"><ellipse cx="12" cy="16" rx="5.4" ry="4.4" fill="currentColor"/><circle cx="5.6" cy="9.8" r="2.1" fill="currentColor"/><circle cx="10.2" cy="6.3" r="2.1" fill="currentColor"/><circle cx="14.8" cy="6.3" r="2.1" fill="currentColor"/><circle cx="18.4" cy="9.8" r="2.1" fill="currentColor"/></symbol>
<symbol id="i-clock" viewBox="0 0 24 24"><circle cx="12" cy="12" r="8.5" fill="none" stroke="currentColor" stroke-width="1.7"/><path d="M12 7.5V12l3 2" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></symbol>
<symbol id="i-tube" viewBox="0 0 24 24"><path d="M9 3h6M10 3v11.5a2 2 0 0 0 4 0V3" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/><path d="M10 12.5h4" stroke="currentColor" stroke-width="1.6"/><circle cx="12" cy="17.5" r="3.3" fill="none" stroke="currentColor" stroke-width="1.6"/></symbol>
<symbol id="i-image" viewBox="0 0 24 24"><rect x="3.5" y="4.5" width="17" height="15" rx="2" fill="none" stroke="currentColor" stroke-width="1.6"/><circle cx="8.5" cy="9.5" r="1.6" fill="none" stroke="currentColor" stroke-width="1.4"/><path d="M4.5 16.5l5-4.5 3 2.5 3.5-3.5 4.5 4.5" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/></symbol>
<symbol id="i-file" viewBox="0 0 24 24"><path d="M6 3h8l4.5 4.5V21H6z" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/><path d="M14 3v4.5h4.5" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/></symbol>
<symbol id="i-shield" viewBox="0 0 24 24"><path d="M12 3l7 3v5.5c0 5-3 8-7 9.5-4-1.5-7-4.5-7-9.5V6z" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/><path d="M9 12l2 2 4-4.3" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/></symbol>
<symbol id="i-search" viewBox="0 0 24 24"><circle cx="10.5" cy="10.5" r="6.5" fill="none" stroke="currentColor" stroke-width="1.7"/><path d="M15.5 15.5L20 20" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></symbol>
<symbol id="i-phone" viewBox="0 0 24 24"><path d="M6 3.5h3l1.5 4-2 1.5a11 11 0 0 0 5 5l1.5-2 4 1.5v3a1.5 1.5 0 0 1-1.6 1.5C10.5 17.7 6.3 13.5 4.5 6.6A1.5 1.5 0 0 1 6 3.5z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/></symbol>
<symbol id="i-mail" viewBox="0 0 24 24"><rect x="3.5" y="5.5" width="17" height="13" rx="2" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M4.5 6.5l7.5 6 7.5-6" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></symbol>
<symbol id="i-check" viewBox="0 0 24 24"><path d="M5 12.5l4.5 4.5L19 7" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/></symbol>
<symbol id="i-plus" viewBox="0 0 24 24"><path d="M12 5v14M5 12h14" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"/></symbol>
<symbol id="i-upload" viewBox="0 0 24 24"><path d="M12 15V4M8 8l4-4 4 4" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/><path d="M4 15v3.5A1.5 1.5 0 0 0 5.5 20h13a1.5 1.5 0 0 0 1.5-1.5V15" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></symbol>
<symbol id="i-x" viewBox="0 0 24 24"><path d="M6 6l12 12M18 6L6 18" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></symbol>
<symbol id="i-dog" viewBox="0 0 24 24"><path d="M8 15.5c-2.2 0-3.5-1.6-3.5-3.3 0-1.9 1.5-3.4 2.7-4.3-.3-1-.1-2 .6-2.6.9-.8 2.1-.3 2.5.6.6-.2 1.3-.3 1.9 0 .4-.9 1.6-1.4 2.5-.6.7.6.9 1.6.6 2.6 1.2.9 2.7 2.4 2.7 4.3 0 1.7-1.3 3.3-3.5 3.3" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/><circle cx="9.3" cy="11.3" r=".9" fill="currentColor"/><circle cx="14.3" cy="11.3" r=".9" fill="currentColor"/><path d="M10.5 13.3c.5.5 1.5.5 2 0" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></symbol>
<symbol id="i-cat" viewBox="0 0 24 24"><path d="M6.5 6l1.8 3.2M17.5 6l-1.8 3.2" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><path d="M6 12a6 6 0 0 1 12 0c0 2.7-1.3 4.2-3 4.8-.8-.5-2.2-.5-3 0-1.7-.6-6-2.1-6-4.8z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/><circle cx="9.6" cy="11.6" r=".85" fill="currentColor"/><circle cx="14.4" cy="11.6" r=".85" fill="currentColor"/><path d="M11.2 13.2h1.6l-.8 1z" fill="currentColor"/></symbol>
<symbol id="i-turtle" viewBox="0 0 24 24"><ellipse cx="12" cy="13" rx="7" ry="5.4" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M12 7.6v10.8M5.6 13h12.8M7.2 9.2l3 2M16.8 9.2l-3 2M7.2 16.8l3-2M16.8 16.8l-3-2" stroke="currentColor" stroke-width="1.1"/><circle cx="4" cy="9.5" r="1.5" fill="none" stroke="currentColor" stroke-width="1.4"/></symbol>
<symbol id="i-contract" viewBox="0 0 24 24"><rect x="4" y="3" width="16" height="18" rx="2" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M8 7.5h8M8 10.5h8M8 13.5h5" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/><path d="M7 17.3c1-1.1 2-1.1 3 0s2 1.1 3 0 2-1.1 3 0" stroke="currentColor" stroke-width="1.4" fill="none" stroke-linecap="round"/></symbol>
<symbol id="i-gear" viewBox="0 0 24 24"><circle cx="12" cy="12" r="3" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M12 3.5v2.4M12 18.1v2.4M20.5 12h-2.4M5.9 12H3.5M17.7 6.3l-1.7 1.7M8 16l-1.7 1.7M17.7 17.7L16 16M8 8 6.3 6.3" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></symbol>
<symbol id="i-trash" viewBox="0 0 24 24"><path d="M5 7h14M9 7V5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2m-8 0v12a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V7" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/><path d="M10 11v6M14 11v6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></symbol>
<symbol id="i-rip" viewBox="0 0 24 24"><path d="M8 21V11a4 4 0 0 1 8 0v10" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/><path d="M12 3v5M9.5 5.5h5" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/><path d="M5 21h14" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></symbol>
<symbol id="i-calendar" viewBox="0 0 24 24"><rect x="3.5" y="5" width="17" height="16" rx="2" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M3.5 9.5h17M8 3v4M16 3v4" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></symbol>
<symbol id="i-folder" viewBox="0 0 24 24"><path d="M3.5 6.5a1.5 1.5 0 0 1 1.5-1.5h4l2 2h9a1.5 1.5 0 0 1 1.5 1.5v9a1.5 1.5 0 0 1-1.5 1.5H5A1.5 1.5 0 0 1 3.5 17.5z" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/></symbol>
<symbol id="i-chevron-left" viewBox="0 0 24 24"><path d="M15 5l-7 7 7 7" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></symbol>
<symbol id="i-chevron-right" viewBox="0 0 24 24"><path d="M9 5l7 7-7 7" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></symbol>
<symbol id="i-external" viewBox="0 0 24 24"><path d="M9 6H6a1.5 1.5 0 0 0-1.5 1.5v10A1.5 1.5 0 0 0 6 19h10a1.5 1.5 0 0 0 1.5-1.5V15" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/><path d="M13 5h6v6M19 5l-9 9" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></symbol>
<symbol id="i-flask" viewBox="0 0 24 24"><path d="M10 3h4M10 3v6l-5 9.5A1.5 1.5 0 0 0 6.3 21h11.4a1.5 1.5 0 0 0 1.3-2.5L14 9V3" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/><path d="M8.5 15h7" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></symbol>
<symbol id="i-rabbit" viewBox="0 0 24 24"><path d="M9 10c-1-3-.5-6 1-7s2 1.5 1.5 4.5M15 10c1-3 .5-6-1-7s-2 1.5-1.5 4.5" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><ellipse cx="12" cy="15" rx="6" ry="5.5" fill="none" stroke="currentColor" stroke-width="1.5"/><circle cx="9.7" cy="13.5" r=".9" fill="currentColor"/><circle cx="14.3" cy="13.5" r=".9" fill="currentColor"/><path d="M11 16.2c.6.5 1.4.5 2 0" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></symbol>
</defs>
</svg>
<div class="app">
<header class="topbar">
<div class="brandmark">
<img src="../assets/logo.png" alt="Tierheilpraxis Iris Hack" class="brand-logo">
<div>
<div class="brand-word">Fellakte</div>
<div class="brand-sub">Tierheilpraxis Iris Hack</div>
</div>
</div>
<div class="view-switch">
<button class="view-switch-btn active" id="view-patients-btn" data-view="patients"><svg><use href="#i-folder"/></svg>Patienten</button>
<button class="view-switch-btn" id="view-calendar-btn" data-view="calendar"><svg><use href="#i-calendar"/></svg>Kalender</button>
<button class="view-switch-btn" id="view-remedies-btn" data-view="remedies"><svg><use href="#i-flask"/></svg>Verzeichnis</button>
</div>
<button class="btn ghost icon-btn" id="mobile-back-btn" title="Zurück zur Liste"><svg><use href="#i-chevron-left"/></svg></button>
<div class="crumb" id="crumb"></div>
<div class="topbar-spacer"></div>
<button class="btn ghost" id="lexware-btn" title="Lexware öffnen"><svg><use href="#i-external"/></svg>Lexware</button>
<button class="btn ghost icon-btn" id="settings-btn" title="Einstellungen (E-Mail-Versand, Lexware)"><svg><use href="#i-gear"/></svg></button>
</header>
<div class="shell">
<nav class="directory" id="directory">
<div class="search"><svg width="15" height="15"><use href="#i-search"/></svg><input id="search-input" placeholder="Besitzer oder Tier suchen…" aria-label="Suche"></div>
<button class="btn primary block" id="new-owner-btn"><svg><use href="#i-plus"/></svg>Neuer Besitzer</button>
<div id="directory-list"></div>
</nav>
<main class="main" id="main">
<div class="empty-state" id="empty-state">
<svg viewBox="0 0 24 24" width="40" height="40"><use href="#i-paw"/></svg>
<p>Noch kein Tier ausgewählt.<br>Links einen Besitzer anlegen und ein Tier hinzufügen, um seine Mappe zu öffnen.</p>
</div>
<div id="folder-view" hidden>
<div class="folder-head">
<div class="folder-title">
<div class="avatar-lg" id="avatar-lg"></div>
<div>
<h1 id="animal-name"></h1>
<div class="chips" id="animal-chips"></div>
</div>
</div>
<div class="owner-card" id="owner-card"></div>
</div>
<div class="tabstrip" id="tabstrip"></div>
<div id="panels"></div>
</div>
<div id="calendar-view" hidden></div>
<div id="remedies-view" hidden></div>
</main>
</div>
</div>
<div class="modal-overlay" id="modal-overlay" hidden>
<div class="modal-card" id="modal-card"></div>
</div>
<script src="app.js"></script>
</body>
</html>
+452
View File
@@ -0,0 +1,452 @@
:root{
--bg:#f7f0e8;
--surface:#ffffff;
--surface-alt:#efe1d2;
--surface-raised:#fffaf5;
--border:#e2d0bd;
--ink:#3a332c;
--ink-soft:#7a6c60;
--ink-faint:#a89c8f;
--accent:#b48f8e;
--accent-ink:#7c5c5a;
--accent-tint:#e9dad7;
--gold:#b8975a;
--ok:#6f8f5c; --ok-tint:#e3ead9;
--warn:#c08a3e; --warn-tint:#f3e4cd;
--critical:#b5533f;--critical-tint:#f3ded8;
--tab-1:#b48f8e; --tab-2:#c98a86; --tab-3:#c4a05a;
--tab-4:#7f9a6e; --tab-5:#5f97a0; --tab-6:#ba6b4f;
--tab-7:#9b7fa0; --tab-8:#ad7a5c; --tab-9:#6f7f8c; --tab-10:#5f7a9a;
--shadow:0 1px 2px rgba(58,41,30,.06),0 6px 18px rgba(58,41,30,.07);
--radius-s:7px; --radius-m:12px; --radius-l:20px;
--font-display:'Dancing Script',cursive;
--font-head:'Montserrat',system-ui,sans-serif;
--font-body:'Source Sans Pro',system-ui,sans-serif;
}
@media (prefers-color-scheme:dark){
:root:not([data-theme="light"]){
--bg:#211b17; --surface:#2a2320; --surface-alt:#2e2621;
--surface-raised:#332a24; --border:#463b32;
--ink:#f1e6da; --ink-soft:#c8b6a6; --ink-faint:#8f8074;
--accent:#dfb0ac; --accent-ink:#f1d7d3; --accent-tint:#453431;
--gold:#d8b978;
--ok:#96b481; --ok-tint:#33402c;
--warn:#dcac63; --warn-tint:#453824;
--critical:#d98871; --critical-tint:#452f2a;
--tab-1:#d3a6a4; --tab-2:#dba09b; --tab-3:#d4b170;
--tab-4:#9bb787; --tab-5:#7bb2ba; --tab-6:#d1866a;
--tab-7:#b598ba; --tab-8:#c2926f; --tab-9:#8b9aa6; --tab-10:#8fa8c4;
--shadow:0 1px 2px rgba(0,0,0,.4),0 10px 24px rgba(0,0,0,.35);
}
}
:root[data-theme="dark"]{
--bg:#211b17; --surface:#2a2320; --surface-alt:#2e2621;
--surface-raised:#332a24; --border:#463b32;
--ink:#f1e6da; --ink-soft:#c8b6a6; --ink-faint:#8f8074;
--accent:#dfb0ac; --accent-ink:#f1d7d3; --accent-tint:#453431;
--gold:#d8b978;
--ok:#96b481; --ok-tint:#33402c;
--warn:#dcac63; --warn-tint:#453824;
--critical:#d98871; --critical-tint:#452f2a;
--tab-1:#d3a6a4; --tab-2:#dba09b; --tab-3:#d4b170;
--tab-4:#9bb787; --tab-5:#7bb2ba; --tab-6:#d1866a;
--tab-7:#b598ba; --tab-8:#c2926f; --tab-9:#8b9aa6; --tab-10:#8fa8c4;
--shadow:0 1px 2px rgba(0,0,0,.4),0 10px 24px rgba(0,0,0,.35);
}
*{box-sizing:border-box;}
/* An element's own `display` rule (author CSS) always wins over the browser's
default `[hidden]{display:none}` (user-agent CSS), regardless of selector
specificity — origin beats specificity in the cascade. Several elements here
toggle visibility via the `hidden` attribute *and* declare their own `display`
(.modal-overlay, .empty-state), so without this !important safety net they'd
stay visually active — and, for a `position:fixed` overlay, silently swallow
every click on the page — even while `hidden` reads true. */
[hidden]{ display:none !important; }
@media (prefers-reduced-motion:reduce){ *{animation:none!important;transition:none!important;} }
html,body{height:100%;}
body{
margin:0; background:var(--bg); color:var(--ink);
font-family:var(--font-body); font-size:15px; line-height:1.55;
-webkit-font-smoothing:antialiased;
}
h1,h2,h3{font-family:var(--font-head); text-wrap:balance; margin:0;}
button, input, textarea, select{font-family:inherit; color:inherit;}
:focus-visible{outline:2px solid var(--accent); outline-offset:2px; border-radius:4px;}
::selection{background:var(--accent-tint); color:var(--ink);}
.app{ display:grid; grid-template-rows:auto 1fr; height:100vh; }
.topbar{
display:flex; align-items:center; gap:16px; padding:12px 20px;
background:var(--surface); border-bottom:1px solid var(--border);
}
.brandmark{ display:flex; align-items:center; gap:10px; flex-shrink:0; }
.brand-logo{ height:40px; width:auto; display:block; }
.brand-word{ font-family:var(--font-display); font-weight:700; font-size:22px; color:var(--accent-ink); line-height:1; }
.brand-sub{ font-family:var(--font-head); font-size:9px; letter-spacing:.14em; text-transform:uppercase; color:var(--ink-faint); }
.view-switch{ display:flex; gap:2px; background:var(--surface-alt); border-radius:99px; padding:3px; flex-shrink:0; }
.view-switch-btn{ display:flex; align-items:center; gap:6px; border:0; background:transparent; padding:7px 14px; border-radius:99px; font-family:var(--font-head); font-size:12.5px; font-weight:600; color:var(--ink-soft); cursor:pointer; }
.view-switch-btn svg{ width:14px; height:14px; }
.view-switch-btn.active{ background:var(--surface); color:var(--accent-ink); box-shadow:var(--shadow); }
.crumb{ font-family:var(--font-head); font-size:13px; color:var(--ink-soft); display:flex; align-items:center; gap:6px; }
.crumb b{ color:var(--ink); font-weight:600; }
.topbar-spacer{ flex:1; }
.shell{ display:grid; grid-template-columns:272px 1fr; min-height:0; }
.directory{
background:var(--surface-alt); border-right:1px solid var(--border);
padding:16px 14px; overflow-y:auto; display:flex; flex-direction:column;
}
.search{
display:flex; align-items:center; gap:8px; background:var(--surface);
border:1px solid var(--border); border-radius:var(--radius-m); padding:9px 12px;
margin-bottom:10px; color:var(--ink-faint);
}
.search input{ border:0; background:transparent; outline:0; width:100%; font-size:13.5px; color:var(--ink); }
.btn.block{ width:100%; justify-content:center; margin-bottom:16px; }
.owner-group{ margin-bottom:14px; }
.owner-name-row{ display:flex; align-items:center; gap:6px; padding:0 8px 6px; }
.owner-name{
font-family:var(--font-head); font-size:11px; font-weight:700; letter-spacing:.06em;
color:var(--ink-faint); text-transform:uppercase; flex:1;
}
.add-animal-btn{ background:none; border:0; color:var(--ink-faint); cursor:pointer; padding:2px 4px; border-radius:5px; flex-shrink:0; }
.add-animal-btn:hover{ color:var(--accent-ink); background:var(--surface); }
.add-animal-btn.danger:hover{ color:var(--critical); background:var(--critical-tint); }
.add-animal-btn svg{ width:13px; height:13px; display:block; }
.animal-row{
display:flex; align-items:center; gap:10px; width:100%; text-align:left;
padding:9px 8px; border-radius:var(--radius-m); border:1px solid transparent;
background:transparent; cursor:pointer; margin-bottom:2px;
}
.animal-row:hover{ background:var(--surface); }
.animal-row.active{ background:var(--surface); border-color:var(--border); box-shadow:var(--shadow); }
.animal-avatar{
width:34px; height:34px; border-radius:10px; display:flex; align-items:center; justify-content:center;
flex-shrink:0; color:#fff;
}
.animal-avatar svg{ width:18px; height:18px; }
.animal-meta{ min-width:0; }
.animal-meta .nm{ font-weight:600; font-size:13.5px; display:flex; align-items:center; gap:4px; }
.animal-meta .sp{ font-size:11.5px; color:var(--ink-soft); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.animal-row.deceased .animal-avatar{ opacity:.55; }
.animal-row.deceased .nm{ color:var(--ink-soft); }
.rip-mark{ width:12px; height:12px; color:var(--ink-faint); flex-shrink:0; }
.empty-owners{ font-size:12.5px; color:var(--ink-faint); padding:8px; }
.main{ overflow-y:auto; padding:22px 28px 60px; }
.empty-state{
height:100%; display:flex; flex-direction:column; align-items:center; justify-content:center;
gap:14px; color:var(--ink-faint); text-align:center;
}
.empty-state svg{ color:var(--ink-faint); }
.empty-state p{ max-width:280px; font-size:14px; line-height:1.6; }
.folder-head{ display:flex; flex-wrap:wrap; gap:18px; align-items:flex-start; justify-content:space-between; margin-bottom:18px; }
.folder-title{ display:flex; align-items:center; gap:14px; }
.folder-title .avatar-lg{ width:56px; height:56px; border-radius:16px; display:flex; align-items:center; justify-content:center; color:#fff; flex-shrink:0; }
.folder-title .avatar-lg svg{ width:28px; height:28px; }
.folder-title h1{ font-size:26px; font-weight:700; }
.chips{ display:flex; flex-wrap:wrap; gap:7px; margin-top:7px; }
.chip{ font-size:11.5px; font-family:var(--font-head); font-weight:600; color:var(--ink-soft); background:var(--surface); border:1px solid var(--border); padding:4px 10px; border-radius:99px; display:inline-flex; align-items:center; gap:4px; }
.chip svg{ width:11px; height:11px; }
.chip.deceased-chip{ color:var(--ink-faint); background:var(--surface-alt); }
.owner-card{ background:var(--surface); border:1px solid var(--border); border-radius:var(--radius-m); padding:12px 14px; min-width:220px; box-shadow:var(--shadow); }
.owner-card .lbl{ font-family:var(--font-head); font-size:10px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; color:var(--ink-faint); margin-bottom:4px; }
.owner-card .nm{ font-weight:700; font-size:14.5px; margin-bottom:5px; }
.owner-card .line{ display:flex; gap:7px; align-items:center; font-size:12.5px; color:var(--ink-soft); margin-top:3px; }
.owner-card .line svg{ width:13px; height:13px; flex-shrink:0; opacity:.7; }
.tabstrip{ display:flex; flex-wrap:wrap; gap:3px; border-bottom:1px solid var(--border); margin-bottom:20px; }
.tab{
display:flex; align-items:center; gap:7px; padding:10px 13px; border:0; background:transparent;
font-family:var(--font-head); font-size:12.5px; font-weight:600; color:var(--ink-soft);
border-bottom:2.5px solid transparent; cursor:pointer; white-space:nowrap; margin-bottom:-1px;
}
.tab svg{ width:15px; height:15px; }
.tab:hover{ color:var(--ink); }
.tab.active{ color:var(--ink); border-bottom-color:var(--tab-color); }
.panel{ display:none; }
.panel.active{ display:block; }
.card{ background:var(--surface); border:1px solid var(--border); border-radius:var(--radius-m); padding:16px 18px; box-shadow:var(--shadow); }
.card + .card{ margin-top:14px; }
.card h3{ font-size:13px; font-weight:700; letter-spacing:.04em; text-transform:uppercase; color:var(--ink-soft); margin-bottom:12px; }
.grid-2{ display:grid; grid-template-columns:1fr 1fr; gap:14px 24px; }
.field .lbl{ font-size:11px; color:var(--ink-faint); margin-bottom:3px; font-family:var(--font-head); font-weight:600; letter-spacing:.03em; }
.field .val{ font-size:14.5px; font-weight:600; font-variant-numeric:tabular-nums; padding:4px 6px; margin:-4px -6px; border-radius:6px; cursor:text; }
.field .val:hover{ background:var(--surface-alt); }
.field input.edit-val{
width:100%; font-size:14.5px; font-weight:600; font-family:inherit; border:1px solid var(--accent); border-radius:6px;
padding:4px 6px; outline:0; background:var(--surface);
}
.prose{ font-size:14.5px; line-height:1.7; color:var(--ink); white-space:pre-wrap; }
.anamnese-box{
width:100%; min-height:160px; border:1px solid var(--border); border-radius:var(--radius-s); padding:12px 14px;
font-family:var(--font-body); font-size:14.5px; line-height:1.7; background:var(--surface); outline:0; resize:vertical;
}
.anamnese-box:focus{ border-color:var(--accent); }
.hint{ font-size:11.5px; color:var(--ink-faint); margin-top:8px; }
.anamnese-toggle{ display:flex; align-items:center; justify-content:space-between; }
.anamnese-toggle p{ margin:0; font-size:12.5px; color:var(--ink-faint); }
.af-section-grid{ display:flex; flex-direction:column; gap:16px; margin-top:14px; }
.af-section h4{
font-family:var(--font-head); font-size:11.5px; font-weight:700; letter-spacing:.05em; text-transform:uppercase;
color:var(--accent-ink); background:var(--accent-tint); display:inline-block; padding:4px 10px; border-radius:99px; margin-bottom:12px;
}
.af-grid{ display:grid; grid-template-columns:1fr 1fr; gap:12px 20px; }
.af-field{ display:flex; flex-direction:column; gap:4px; }
.af-field.af-wide{ grid-column:1 / -1; }
.af-field label{ font-size:11px; font-family:var(--font-head); font-weight:600; color:var(--ink-soft); }
.af-field input[type=text]{ border:1px solid var(--border); border-radius:var(--radius-s); padding:7px 9px; font-size:13px; background:var(--surface); outline:0; }
.af-field input[type=text]:focus{ border-color:var(--accent); }
.af-field textarea{ border:1px solid var(--border); border-radius:var(--radius-s); padding:8px 9px; font-size:13px; background:var(--surface); outline:0; min-height:56px; resize:vertical; font-family:inherit; }
.af-field textarea:focus{ border-color:var(--accent); }
.af-options{ display:flex; flex-wrap:wrap; gap:6px 14px; padding-top:2px; }
.af-opt{ display:flex; align-items:center; gap:5px; font-size:12.5px; color:var(--ink); cursor:pointer; }
.af-opt input{ accent-color:var(--accent); }
.af-pairs{ display:flex; flex-direction:column; gap:6px; }
.af-pair{ display:flex; align-items:center; gap:14px; font-size:12.5px; }
.af-pair .af-opt{ min-width:110px; }
.consent-list{ display:flex; flex-direction:column; gap:0; margin-top:4px; }
.consent-item{ display:flex; gap:10px; padding:9px 0; border-top:1px solid var(--border); align-items:flex-start; }
.consent-item:first-child{ border-top:0; }
.consent-item input{ margin-top:3px; accent-color:var(--accent); flex-shrink:0; }
.consent-item span{ font-size:13px; line-height:1.5; }
.btn{
font-family:var(--font-head); font-size:12.5px; font-weight:600; padding:8px 14px;
border-radius:var(--radius-s); border:1px solid var(--border); background:var(--surface);
cursor:pointer; display:inline-flex; align-items:center; gap:6px; color:var(--ink);
}
.btn:hover{ border-color:var(--accent); color:var(--accent-ink); }
.btn.primary{ background:var(--accent); border-color:var(--accent); color:#fff; }
.btn.primary:hover{ filter:brightness(1.05); color:#fff; }
.btn.danger{ background:var(--critical); border-color:var(--critical); color:#fff; }
.btn.danger:hover{ filter:brightness(1.05); color:#fff; }
.btn.ghost{ background:transparent; border-color:transparent; }
.btn svg{ width:14px; height:14px; }
.note-composer{ display:flex; gap:8px; margin-bottom:14px; }
.note-composer input{ flex:1; border:1px solid var(--border); border-radius:var(--radius-s); padding:9px 12px; background:var(--surface); font-size:13.5px; outline:0; }
.note{ display:flex; gap:12px; padding:11px 0; border-top:1px solid var(--border); }
.note:first-child{ border-top:0; }
.note .date{ font-family:var(--font-head); font-size:11px; color:var(--ink-faint); width:78px; flex-shrink:0; padding-top:1px; font-variant-numeric:tabular-nums; }
.timeline{ position:relative; padding-left:22px; }
.timeline::before{ content:""; position:absolute; left:5px; top:4px; bottom:4px; width:2px; background:var(--border); }
.tl-item{ position:relative; padding-bottom:18px; }
.tl-item:last-child{ padding-bottom:0; }
.tl-item::before{ content:""; position:absolute; left:-22px; top:3px; width:11px; height:11px; border-radius:50%; background:var(--tab-5); border:2px solid var(--surface); box-shadow:0 0 0 1px var(--border); }
.tl-item .date{ font-family:var(--font-head); font-size:11px; font-weight:700; color:var(--ink-faint); letter-spacing:.03em; }
.tl-item h4{ font-size:14px; margin:2px 0 3px; }
.tl-item p{ margin:0; font-size:13.5px; color:var(--ink-soft); }
.composer-2{ display:grid; grid-template-columns:1fr 2fr auto; gap:8px; margin-bottom:16px; }
.composer-2 input{ border:1px solid var(--border); border-radius:var(--radius-s); padding:9px 12px; background:var(--surface); font-size:13.5px; outline:0; }
.lab-table{ width:100%; border-collapse:collapse; font-size:13.5px; }
.lab-table th{ text-align:left; font-family:var(--font-head); font-size:10.5px; text-transform:uppercase; letter-spacing:.05em; color:var(--ink-faint); padding:6px 8px; border-bottom:1px solid var(--border); }
.lab-table td{ padding:8px 8px; border-bottom:1px solid var(--border); font-variant-numeric:tabular-nums; }
.lab-add-row{ display:grid; grid-template-columns:1fr 1fr 1fr auto; gap:8px; margin-top:10px; }
.lab-add-row input{ border:1px solid var(--border); border-radius:var(--radius-s); padding:7px 9px; background:var(--surface); font-size:12.5px; outline:0; }
.card-head-row{ display:flex; align-items:center; justify-content:space-between; }
.card-head-row .btn{ padding:5px 9px; font-size:11px; }
.photo-grid{ display:grid; grid-template-columns:repeat(auto-fill,minmax(120px,1fr)); gap:10px; }
.photo-tile{ aspect-ratio:1; border-radius:var(--radius-s); border:1px solid var(--border); display:flex; align-items:center; justify-content:center; color:var(--ink-faint); overflow:hidden; background:var(--surface-alt); position:relative; }
.photo-tile img{ width:100%; height:100%; object-fit:cover; display:block; }
.photo-tile .fn{ position:absolute; bottom:0; left:0; right:0; font-size:9.5px; background:rgba(0,0,0,.45); color:#fff; padding:3px 5px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.photo-tile svg{ width:26px; height:26px; }
.photo-tile.add{ background:var(--surface-alt); border-style:dashed; cursor:pointer; flex-direction:column; gap:6px; font-size:11px; font-family:var(--font-head); font-weight:600; }
.photo-tile.add:hover{ border-color:var(--accent); color:var(--accent-ink); }
.doc-row{ display:flex; align-items:center; gap:12px; padding:10px 4px; border-top:1px solid var(--border); }
.doc-row:first-child{ border-top:0; }
.doc-icon{ width:34px; height:34px; border-radius:8px; background:var(--surface-alt); display:flex; align-items:center; justify-content:center; flex-shrink:0; color:var(--ink-soft); }
.doc-icon svg{ width:16px; height:16px; }
.doc-name{ font-weight:600; font-size:13.5px; }
.doc-meta{ font-size:11.5px; color:var(--ink-faint); }
.doc-row .btn{ margin-left:auto; }
.dropzone{ border:1.5px dashed var(--border); border-radius:var(--radius-m); padding:22px; text-align:center; color:var(--ink-faint); font-size:13px; margin-bottom:14px; background:var(--surface-alt); cursor:pointer; }
.dropzone:hover{ border-color:var(--accent); color:var(--accent-ink); }
.dropzone b{ color:var(--accent-ink); font-weight:600; }
.consent-row{ display:flex; align-items:center; justify-content:space-between; gap:14px; padding:12px 0; border-top:1px solid var(--border); }
.consent-row:first-child{ border-top:0; }
.consent-row .t{ font-weight:600; font-size:13.5px; }
.consent-row .d{ font-size:12px; color:var(--ink-faint); margin-top:2px; }
.badge-ok{ display:flex; align-items:center; gap:5px; font-family:var(--font-head); font-size:11.5px; font-weight:700; color:var(--ok); background:var(--ok-tint); padding:5px 10px; border-radius:99px; white-space:nowrap; border:0; cursor:pointer; }
.badge-off{ display:flex; align-items:center; gap:5px; font-family:var(--font-head); font-size:11.5px; font-weight:700; color:var(--ink-faint); background:var(--surface-alt); padding:5px 10px; border-radius:99px; white-space:nowrap; border:0; cursor:pointer; }
.badge-ok svg, .badge-off svg{ width:12px; height:12px; }
.sig-box{ border:1px solid var(--border); border-radius:var(--radius-s); padding:6px; margin-top:10px; background:var(--surface-alt); }
.sig-box img{ width:100%; display:block; max-height:90px; object-fit:contain; }
.sig-pad-wrap{ margin-top:10px; }
.sig-pad{ width:100%; height:160px; border:1px solid var(--border); border-radius:var(--radius-s); background:#fff; touch-action:none; cursor:crosshair; }
.sig-actions{ display:flex; gap:8px; margin-top:8px; }
.bm-layout{ display:grid; grid-template-columns:1fr 260px; gap:18px; align-items:start; }
.bm-hint{ font-size:12.5px; color:var(--ink-faint); margin-bottom:10px; }
.bm-stage{ position:relative; background:var(--surface-alt); border:1px solid var(--border); border-radius:var(--radius-m); aspect-ratio:16/10.5; cursor:crosshair; overflow:hidden; }
.bm-stage svg.silhouette{ position:absolute; inset:0; width:100%; height:100%; }
.bm-stage img.silhouette{ position:absolute; inset:0; width:100%; height:100%; object-fit:contain; pointer-events:none; }
.bm-pin{ position:absolute; width:20px; height:20px; margin:-10px 0 0 -10px; border-radius:50% 50% 50% 0; transform:rotate(-45deg); border:2px solid var(--surface); box-shadow:0 1px 3px rgba(0,0,0,.25); cursor:pointer; }
.bm-pin span{ position:absolute; inset:0; display:flex; align-items:center; justify-content:center; transform:rotate(45deg); font-family:var(--font-head); font-size:9px; font-weight:800; color:#fff; }
.bm-pop{ position:absolute; width:248px; background:var(--surface); border:1px solid var(--border); border-radius:var(--radius-m); box-shadow:var(--shadow); padding:11px; z-index:10; }
.bm-pop textarea{ width:100%; border:1px solid var(--border); border-radius:var(--radius-s); padding:7px 9px; font-size:12.5px; resize:none; height:52px; outline:0; background:var(--surface); margin-bottom:8px; }
.sev-row{ display:flex; gap:5px; margin-bottom:9px; }
.sev-btn{ flex:1; font-size:10.5px; font-family:var(--font-head); font-weight:700; padding:6px 4px; border-radius:6px; border:1.5px solid var(--border); background:var(--surface); cursor:pointer; color:var(--ink-soft); }
.sev-btn.sel-ok{ border-color:var(--ok); background:var(--ok-tint); color:var(--ok); }
.sev-btn.sel-warn{ border-color:var(--warn); background:var(--warn-tint); color:var(--warn); }
.sev-btn.sel-critical{ border-color:var(--critical); background:var(--critical-tint); color:var(--critical); }
.quicktype-row{ display:flex; flex-wrap:wrap; gap:7px; }
.quicktype-chip{ font-family:var(--font-head); font-size:12px; font-weight:600; padding:7px 14px; border-radius:99px; border:1.5px solid var(--border); background:var(--surface); color:var(--ink-soft); cursor:pointer; white-space:nowrap; transition:border-color .12s, color .12s, background .12s; }
.quicktype-chip:hover{ border-color:var(--accent); color:var(--accent-ink); }
.quicktype-chip.active{ border-color:var(--accent); background:var(--accent-tint); color:var(--accent-ink); }
/* Toggle-chip checkbox (Ernährungsplan Tage/Tageszeiten) — same visual language as
.quicktype-chip but backed by a real (visually hidden) checkbox input. */
.chip-check{ position:relative; display:inline-flex; }
.chip-check input{ position:absolute; inset:0; opacity:0; margin:0; cursor:pointer; }
.chip-check span{ font-family:var(--font-head); font-size:11.5px; font-weight:600; padding:6px 11px; border-radius:99px; border:1.5px solid var(--border); background:var(--surface); color:var(--ink-soft); white-space:nowrap; pointer-events:none; transition:border-color .12s, color .12s, background .12s; }
.chip-check:has(input:checked) span{ border-color:var(--accent); background:var(--accent-tint); color:var(--accent-ink); }
.fp-row{ border:1px solid var(--border); border-radius:var(--radius-m); padding:13px 14px; margin-bottom:10px; background:var(--surface-alt); }
.fp-row-head{ display:flex; gap:8px; margin-bottom:12px; }
.fp-row-head input{ flex:1; border:1px solid var(--border); border-radius:var(--radius-s); padding:8px 10px; font-size:13px; background:var(--surface); outline:0; }
.fp-row-head input:focus{ border-color:var(--accent); }
.fp-row-remove{ flex-shrink:0; width:34px; height:34px; display:flex; align-items:center; justify-content:center; border:1px solid var(--border); border-radius:var(--radius-s); background:var(--surface); color:var(--ink-soft); cursor:pointer; }
.fp-row-remove svg{ width:15px; height:15px; }
.fp-row-remove:hover{ border-color:var(--critical); color:var(--critical); }
.fp-row-grid{ display:flex; flex-wrap:wrap; gap:16px 22px; align-items:flex-start; }
.fp-field{ display:flex; flex-direction:column; gap:6px; }
.fp-field .lbl{ font-size:10.5px; font-family:var(--font-head); font-weight:600; color:var(--ink-faint); letter-spacing:.03em; }
.fp-field .chips{ display:flex; flex-wrap:wrap; gap:5px; }
.fp-field .fp-menge{ width:120px; border:1px solid var(--border); border-radius:var(--radius-s); padding:7px 9px; font-size:12.5px; background:var(--surface); outline:0; }
.fp-field .fp-menge:focus{ border-color:var(--accent); }
.bm-pop-actions{ display:flex; flex-wrap:wrap; gap:6px; justify-content:flex-end; }
.bm-pop-actions .btn.danger{ margin-right:auto; }
.bm-pop-actions .btn{ padding:6px 11px; font-size:11.5px; white-space:nowrap; }
.finding-list h3{ margin-bottom:10px; }
.finding{ display:flex; gap:9px; padding:9px 0; border-top:1px solid var(--border); align-items:flex-start; }
.finding:first-child{ border-top:0; }
.finding .sw{ width:10px; height:10px; border-radius:50%; margin-top:4px; flex-shrink:0; }
.finding .tx{ font-size:12.5px; line-height:1.45; }
.finding .rm{ margin-left:auto; background:none; border:0; color:var(--ink-faint); cursor:pointer; font-size:14px; line-height:1; padding:2px; flex-shrink:0; }
.finding .rm:hover{ color:var(--critical); }
.modal-overlay{ position:fixed; inset:0; background:rgba(33,27,23,.42); display:flex; align-items:center; justify-content:center; z-index:100; }
.modal-card{ width:380px; max-width:calc(100vw - 40px); max-height:calc(100vh - 40px); overflow-y:auto; background:var(--surface); border-radius:var(--radius-l); border:1px solid var(--border); box-shadow:var(--shadow); padding:22px; }
.modal-card h2{ font-size:17px; margin-bottom:16px; }
.modal-field{ margin-bottom:12px; }
.modal-field .lbl{ font-size:11px; font-family:var(--font-head); font-weight:600; color:var(--ink-faint); letter-spacing:.03em; margin-bottom:4px; display:block; }
.modal-field input, .modal-field select{ width:100%; border:1px solid var(--border); border-radius:var(--radius-s); padding:9px 11px; font-size:13.5px; outline:0; background:var(--surface); }
.modal-field input:focus, .modal-field select:focus{ border-color:var(--accent); }
.modal-actions{ display:flex; justify-content:flex-end; gap:8px; margin-top:18px; }
.modal-card.wide{ width:460px; }
.modal-card.xwide{ width:640px; }
.settings-tabstrip{ display:flex; gap:3px; border-bottom:1px solid var(--border); margin-bottom:18px; }
.settings-panel{ display:none; }
.settings-panel.active{ display:block; }
.backup-row{ display:flex; align-items:center; justify-content:space-between; gap:10px; padding:9px 0; border-bottom:1px solid var(--border); }
.backup-row:last-child{ border-bottom:0; }
.backup-row .d{ font-size:13px; }
.backup-row .d small{ display:block; font-size:11px; color:var(--ink-faint); margin-top:1px; }
.modal-field .modal-hint{ font-size:11px; color:var(--ink-faint); margin-top:6px; }
.modal-field label.check-row{ display:flex; align-items:center; gap:8px; font-size:12.5px; color:var(--ink); cursor:pointer; }
.modal-field label.check-row input{ width:auto; accent-color:var(--accent); }
.modal-status{ font-size:12px; border-radius:var(--radius-s); padding:8px 10px; margin-top:10px; }
.modal-status.ok{ background:var(--ok-tint); color:var(--ok); }
.modal-status.error{ background:var(--critical-tint); color:var(--critical); }
.icon-btn{ padding:8px; }
.icon-btn svg{ width:17px; height:17px; }
.contract-text{ max-height:320px; overflow-y:auto; border:1px solid var(--border); border-radius:var(--radius-s); padding:14px 16px; background:var(--surface-alt); font-size:12.5px; line-height:1.65; }
.contract-text h4{ font-family:var(--font-head); font-size:11.5px; font-weight:700; text-transform:uppercase; letter-spacing:.03em; color:var(--accent-ink); margin:14px 0 4px; }
.contract-text h4:first-child{ margin-top:0; }
.contract-text p{ margin:0 0 8px; }
.confirm-row{ display:flex; align-items:flex-start; gap:10px; margin-top:14px; padding-top:14px; border-top:1px solid var(--border); }
.confirm-row input{ margin-top:3px; accent-color:var(--accent); flex-shrink:0; }
.confirm-row span{ font-size:12.5px; line-height:1.5; }
.email-status{ font-size:12px; border-radius:var(--radius-s); padding:8px 11px; margin-top:10px; }
.email-status.ok{ background:var(--ok-tint); color:var(--ok); }
.email-status.error{ background:var(--critical-tint); color:var(--critical); }
.email-status.pending{ background:var(--surface-alt); color:var(--ink-faint); }
.cal-head{ display:flex; align-items:center; gap:14px; margin-bottom:18px; flex-wrap:wrap; }
.cal-head h1{ font-size:24px; font-weight:700; min-width:220px; }
.cal-nav{ display:flex; align-items:center; gap:4px; }
.cal-nav-btn{ width:32px; height:32px; display:flex; align-items:center; justify-content:center; border-radius:var(--radius-s); border:1px solid var(--border); background:var(--surface); cursor:pointer; color:var(--ink-soft); }
.cal-nav-btn:hover{ border-color:var(--accent); color:var(--accent-ink); }
.cal-nav-btn svg{ width:15px; height:15px; }
.cal-head-spacer{ flex:1; }
.cal-grid{ display:grid; grid-template-columns:repeat(7,1fr); gap:8px; }
.cal-dow{ font-family:var(--font-head); font-size:10.5px; font-weight:700; letter-spacing:.05em; text-transform:uppercase; color:var(--ink-faint); text-align:center; padding-bottom:4px; }
.cal-day{
background:var(--surface); border:1px solid var(--border); border-radius:var(--radius-m);
min-height:104px; padding:8px; cursor:pointer; display:flex; flex-direction:column; gap:4px;
}
.cal-day:hover{ border-color:var(--accent); }
.cal-day.other-month{ background:var(--surface-alt); opacity:.55; }
.cal-day.today{ border-color:var(--accent); box-shadow:0 0 0 1px var(--accent) inset; }
.cal-day-num{ font-family:var(--font-head); font-size:12px; font-weight:700; color:var(--ink-soft); }
.cal-day.today .cal-day-num{ color:var(--accent-ink); }
.cal-appt{
font-size:10.5px; font-family:var(--font-head); font-weight:600; padding:3px 6px; border-radius:6px;
background:var(--accent-tint); color:var(--accent-ink); white-space:nowrap; overflow:hidden; text-overflow:ellipsis;
cursor:pointer;
}
.cal-appt:hover{ filter:brightness(.97); }
.cal-appt-more{ font-size:10px; color:var(--ink-faint); font-family:var(--font-head); font-weight:600; padding:0 6px; }
.agenda-card{ margin-top:20px; }
.agenda-item{ display:flex; gap:12px; align-items:flex-start; padding:10px 0; border-top:1px solid var(--border); cursor:pointer; }
.agenda-item:first-child{ border-top:0; }
.agenda-item:hover .agenda-title{ color:var(--accent-ink); }
.agenda-date{ font-family:var(--font-head); font-size:11px; color:var(--ink-faint); width:100px; flex-shrink:0; line-height:1.4; font-variant-numeric:tabular-nums; }
.agenda-title{ font-weight:600; font-size:13.5px; }
.agenda-sub{ font-size:12px; color:var(--ink-soft); margin-top:1px; }
@media (max-width:880px){
.shell{ grid-template-columns:1fr; }
.bm-layout{ grid-template-columns:1fr; }
/* Master/detail on narrow screens: show either the Besitzer/Tier-Liste or the
geöffnete Mappe, never a half-hidden mix of both — body[data-view] + .has-animal
are kept up to date in app.js (switchView/renderFolder/back button). */
body[data-view="patients"]:not(.has-animal) .main{ display:none; }
body[data-view="patients"].has-animal .directory{ display:none; }
body[data-view="calendar"] .directory,
body[data-view="remedies"] .directory{ display:none; }
#mobile-back-btn{ display:none; }
body[data-view="patients"].has-animal #mobile-back-btn{ display:flex; }
.topbar{ flex-wrap:wrap; row-gap:8px; padding:10px 14px; }
.brand-sub{ display:none; }
.view-switch-btn{ padding:6px 9px; font-size:11px; }
.crumb{ font-size:12px; }
.crumb span{ display:none; } /* keep just the animal name (the <b>) on narrow screens */
.tabstrip{ flex-wrap:nowrap; overflow-x:auto; -webkit-overflow-scrolling:touch; }
.tab{ flex-shrink:0; }
.grid-2{ grid-template-columns:1fr; }
.af-grid{ grid-template-columns:1fr; }
.composer-2{ grid-template-columns:1fr; }
.lab-add-row{ grid-template-columns:1fr; }
.folder-head{ flex-direction:column; }
.owner-card{ min-width:0; width:100%; }
.settings-tabstrip{ flex-wrap:nowrap; overflow-x:auto; -webkit-overflow-scrolling:touch; }
body{ overflow-x:hidden; }
}
+135
View File
@@ -0,0 +1,135 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src https://fonts.gstatic.com; script-src 'self'">
<title>Fellakte</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Dancing+Script:wght@600;700&family=Montserrat:wght@500;600;700&family=Source+Sans+Pro:wght@400;600;700&display=swap">
<link rel="stylesheet" href="styles.css">
</head>
<body>
<svg width="0" height="0" style="position:absolute">
<defs>
<symbol id="i-id" viewBox="0 0 24 24"><rect x="3" y="5" width="18" height="14" rx="2.5" fill="none" stroke="currentColor" stroke-width="1.7"/><circle cx="8.3" cy="11" r="1.8" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M6 15.3c.5-1.4 1.6-2 2.3-2s1.8.6 2.3 2M13.5 9.5h5M13.5 12.5h5M13.5 15.5h3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></symbol>
<symbol id="i-clip" viewBox="0 0 24 24"><rect x="5" y="4" width="14" height="17" rx="2" fill="none" stroke="currentColor" stroke-width="1.7"/><rect x="9" y="2.5" width="6" height="3.5" rx="1.2" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M8 10.5h8M8 14h8M8 17.5h5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></symbol>
<symbol id="i-pencil" viewBox="0 0 24 24"><path d="M4 20l.9-3.9L15.6 5.4a1.6 1.6 0 0 1 2.3 0l.7.7a1.6 1.6 0 0 1 0 2.3L8 19.1 4 20z" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/><path d="M14 7l3 3" stroke="currentColor" stroke-width="1.6"/></symbol>
<symbol id="i-paw" viewBox="0 0 24 24"><ellipse cx="12" cy="16" rx="5.4" ry="4.4" fill="currentColor"/><circle cx="5.6" cy="9.8" r="2.1" fill="currentColor"/><circle cx="10.2" cy="6.3" r="2.1" fill="currentColor"/><circle cx="14.8" cy="6.3" r="2.1" fill="currentColor"/><circle cx="18.4" cy="9.8" r="2.1" fill="currentColor"/></symbol>
<symbol id="i-clock" viewBox="0 0 24 24"><circle cx="12" cy="12" r="8.5" fill="none" stroke="currentColor" stroke-width="1.7"/><path d="M12 7.5V12l3 2" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></symbol>
<symbol id="i-tube" viewBox="0 0 24 24"><path d="M9 3h6M10 3v11.5a2 2 0 0 0 4 0V3" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/><path d="M10 12.5h4" stroke="currentColor" stroke-width="1.6"/><circle cx="12" cy="17.5" r="3.3" fill="none" stroke="currentColor" stroke-width="1.6"/></symbol>
<symbol id="i-image" viewBox="0 0 24 24"><rect x="3.5" y="4.5" width="17" height="15" rx="2" fill="none" stroke="currentColor" stroke-width="1.6"/><circle cx="8.5" cy="9.5" r="1.6" fill="none" stroke="currentColor" stroke-width="1.4"/><path d="M4.5 16.5l5-4.5 3 2.5 3.5-3.5 4.5 4.5" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/></symbol>
<symbol id="i-file" viewBox="0 0 24 24"><path d="M6 3h8l4.5 4.5V21H6z" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/><path d="M14 3v4.5h4.5" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/></symbol>
<symbol id="i-shield" viewBox="0 0 24 24"><path d="M12 3l7 3v5.5c0 5-3 8-7 9.5-4-1.5-7-4.5-7-9.5V6z" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/><path d="M9 12l2 2 4-4.3" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/></symbol>
<symbol id="i-search" viewBox="0 0 24 24"><circle cx="10.5" cy="10.5" r="6.5" fill="none" stroke="currentColor" stroke-width="1.7"/><path d="M15.5 15.5L20 20" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></symbol>
<symbol id="i-phone" viewBox="0 0 24 24"><path d="M6 3.5h3l1.5 4-2 1.5a11 11 0 0 0 5 5l1.5-2 4 1.5v3a1.5 1.5 0 0 1-1.6 1.5C10.5 17.7 6.3 13.5 4.5 6.6A1.5 1.5 0 0 1 6 3.5z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/></symbol>
<symbol id="i-mail" viewBox="0 0 24 24"><rect x="3.5" y="5.5" width="17" height="13" rx="2" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M4.5 6.5l7.5 6 7.5-6" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></symbol>
<symbol id="i-check" viewBox="0 0 24 24"><path d="M5 12.5l4.5 4.5L19 7" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/></symbol>
<symbol id="i-plus" viewBox="0 0 24 24"><path d="M12 5v14M5 12h14" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"/></symbol>
<symbol id="i-upload" viewBox="0 0 24 24"><path d="M12 15V4M8 8l4-4 4 4" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/><path d="M4 15v3.5A1.5 1.5 0 0 0 5.5 20h13a1.5 1.5 0 0 0 1.5-1.5V15" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></symbol>
<symbol id="i-x" viewBox="0 0 24 24"><path d="M6 6l12 12M18 6L6 18" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></symbol>
<symbol id="i-dog" viewBox="0 0 24 24"><path d="M8 15.5c-2.2 0-3.5-1.6-3.5-3.3 0-1.9 1.5-3.4 2.7-4.3-.3-1-.1-2 .6-2.6.9-.8 2.1-.3 2.5.6.6-.2 1.3-.3 1.9 0 .4-.9 1.6-1.4 2.5-.6.7.6.9 1.6.6 2.6 1.2.9 2.7 2.4 2.7 4.3 0 1.7-1.3 3.3-3.5 3.3" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/><circle cx="9.3" cy="11.3" r=".9" fill="currentColor"/><circle cx="14.3" cy="11.3" r=".9" fill="currentColor"/><path d="M10.5 13.3c.5.5 1.5.5 2 0" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></symbol>
<symbol id="i-cat" viewBox="0 0 24 24"><path d="M6.5 6l1.8 3.2M17.5 6l-1.8 3.2" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><path d="M6 12a6 6 0 0 1 12 0c0 2.7-1.3 4.2-3 4.8-.8-.5-2.2-.5-3 0-1.7-.6-6-2.1-6-4.8z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/><circle cx="9.6" cy="11.6" r=".85" fill="currentColor"/><circle cx="14.4" cy="11.6" r=".85" fill="currentColor"/><path d="M11.2 13.2h1.6l-.8 1z" fill="currentColor"/></symbol>
<symbol id="i-turtle" viewBox="0 0 24 24"><ellipse cx="12" cy="13" rx="7" ry="5.4" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M12 7.6v10.8M5.6 13h12.8M7.2 9.2l3 2M16.8 9.2l-3 2M7.2 16.8l3-2M16.8 16.8l-3-2" stroke="currentColor" stroke-width="1.1"/><circle cx="4" cy="9.5" r="1.5" fill="none" stroke="currentColor" stroke-width="1.4"/></symbol>
<symbol id="i-contract" viewBox="0 0 24 24"><rect x="4" y="3" width="16" height="18" rx="2" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M8 7.5h8M8 10.5h8M8 13.5h5" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/><path d="M7 17.3c1-1.1 2-1.1 3 0s2 1.1 3 0 2-1.1 3 0" stroke="currentColor" stroke-width="1.4" fill="none" stroke-linecap="round"/></symbol>
<symbol id="i-gear" viewBox="0 0 24 24"><circle cx="12" cy="12" r="3" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M12 3.5v2.4M12 18.1v2.4M20.5 12h-2.4M5.9 12H3.5M17.7 6.3l-1.7 1.7M8 16l-1.7 1.7M17.7 17.7L16 16M8 8 6.3 6.3" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></symbol>
<symbol id="i-trash" viewBox="0 0 24 24"><path d="M5 7h14M9 7V5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2m-8 0v12a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V7" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/><path d="M10 11v6M14 11v6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></symbol>
<symbol id="i-rip" viewBox="0 0 24 24"><path d="M8 21V11a4 4 0 0 1 8 0v10" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/><path d="M12 3v5M9.5 5.5h5" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/><path d="M5 21h14" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></symbol>
<symbol id="i-calendar" viewBox="0 0 24 24"><rect x="3.5" y="5" width="17" height="16" rx="2" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M3.5 9.5h17M8 3v4M16 3v4" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></symbol>
<symbol id="i-folder" viewBox="0 0 24 24"><path d="M3.5 6.5a1.5 1.5 0 0 1 1.5-1.5h4l2 2h9a1.5 1.5 0 0 1 1.5 1.5v9a1.5 1.5 0 0 1-1.5 1.5H5A1.5 1.5 0 0 1 3.5 17.5z" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/></symbol>
<symbol id="i-chevron-left" viewBox="0 0 24 24"><path d="M15 5l-7 7 7 7" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></symbol>
<symbol id="i-chevron-right" viewBox="0 0 24 24"><path d="M9 5l7 7-7 7" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></symbol>
<symbol id="i-external" viewBox="0 0 24 24"><path d="M9 6H6a1.5 1.5 0 0 0-1.5 1.5v10A1.5 1.5 0 0 0 6 19h10a1.5 1.5 0 0 0 1.5-1.5V15" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/><path d="M13 5h6v6M19 5l-9 9" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></symbol>
<symbol id="i-flask" viewBox="0 0 24 24"><path d="M10 3h4M10 3v6l-5 9.5A1.5 1.5 0 0 0 6.3 21h11.4a1.5 1.5 0 0 0 1.3-2.5L14 9V3" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/><path d="M8.5 15h7" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></symbol>
<symbol id="i-rabbit" viewBox="0 0 24 24"><path d="M9 10c-1-3-.5-6 1-7s2 1.5 1.5 4.5M15 10c1-3 .5-6-1-7s-2 1.5-1.5 4.5" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><ellipse cx="12" cy="15" rx="6" ry="5.5" fill="none" stroke="currentColor" stroke-width="1.5"/><circle cx="9.7" cy="13.5" r=".9" fill="currentColor"/><circle cx="14.3" cy="13.5" r=".9" fill="currentColor"/><path d="M11 16.2c.6.5 1.4.5 2 0" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></symbol>
</defs>
</svg>
<div class="app">
<header class="topbar">
<div class="brandmark">
<img src="../assets/logo.png" alt="Tierheilpraxis Iris Hack" class="brand-logo">
<div>
<div class="brand-word">Fellakte</div>
<div class="brand-sub">Tierheilpraxis Iris Hack</div>
</div>
</div>
<div class="view-switch">
<button class="view-switch-btn active" id="view-patients-btn" data-view="patients"><svg><use href="#i-folder"/></svg>Patienten</button>
<button class="view-switch-btn" id="view-calendar-btn" data-view="calendar"><svg><use href="#i-calendar"/></svg>Kalender</button>
<button class="view-switch-btn" id="view-remedies-btn" data-view="remedies"><svg><use href="#i-flask"/></svg>Verzeichnis</button>
</div>
<button class="btn ghost icon-btn" id="mobile-back-btn" title="Zurück zur Liste"><svg><use href="#i-chevron-left"/></svg></button>
<div class="crumb" id="crumb"></div>
<div class="topbar-spacer"></div>
<span class="chip" id="sync-status" style="background:var(--ok-tint); color:var(--ok); border-color:transparent">Verbunden</span>
<!-- kept in the DOM (boot() wires these unconditionally) but hidden — Phase 1 is read-only -->
<button class="btn ghost" id="lexware-btn" hidden><svg><use href="#i-external"/></svg>Lexware</button>
<button class="btn ghost icon-btn" id="settings-btn" hidden><svg><use href="#i-gear"/></svg></button>
</header>
<div class="shell">
<nav class="directory" id="directory">
<div class="search"><svg width="15" height="15"><use href="#i-search"/></svg><input id="search-input" placeholder="Besitzer oder Tier suchen…" aria-label="Suche"></div>
<button class="btn primary block" id="new-owner-btn" hidden><svg><use href="#i-plus"/></svg>Neuer Besitzer</button>
<div id="directory-list"></div>
</nav>
<main class="main" id="main">
<div class="empty-state" id="empty-state">
<svg viewBox="0 0 24 24" width="40" height="40"><use href="#i-paw"/></svg>
<p>Noch kein Tier ausgewählt.<br>Links einen Besitzer bzw. ein Tier antippen, um seine Mappe zu öffnen.</p>
</div>
<div id="folder-view" hidden>
<div class="folder-head">
<div class="folder-title">
<div class="avatar-lg" id="avatar-lg"></div>
<div>
<h1 id="animal-name"></h1>
<div class="chips" id="animal-chips"></div>
</div>
</div>
<div class="owner-card" id="owner-card"></div>
</div>
<div class="tabstrip" id="tabstrip"></div>
<div id="panels"></div>
</div>
<div id="calendar-view" hidden></div>
<div id="remedies-view" hidden></div>
</main>
</div>
</div>
<div class="modal-overlay" id="modal-overlay" hidden>
<div class="modal-card" id="modal-card"></div>
</div>
<div class="modal-overlay" id="pairing-overlay">
<div class="modal-card" style="text-align:center">
<img src="../assets/logo.png" alt="" style="height:44px;margin-bottom:12px">
<h2>Mit Praxis-PC verbinden</h2>
<p class="hint" style="margin:-4px 0 18px">Code eingeben, der gerade am PC unter Einstellungen → Tablet-Zugang angezeigt wird.</p>
<form id="pair-form">
<div class="modal-field">
<input id="pair-code-input" type="text" inputmode="numeric" pattern="[0-9]*" maxlength="6" placeholder="123456" autocomplete="off" style="text-align:center;font-size:24px;letter-spacing:.25em;font-family:var(--font-head);font-weight:700" autofocus>
</div>
<div id="pair-status" class="hint" style="min-height:16px;margin-bottom:10px"></div>
<button type="submit" class="btn primary block">Verbinden</button>
</form>
</div>
</div>
<div id="readonly-toast" hidden style="position:fixed;bottom:20px;left:50%;transform:translateX(-50%);background:var(--ink);color:var(--surface);padding:10px 20px;border-radius:99px;font-family:var(--font-head);font-size:12.5px;font-weight:600;box-shadow:var(--shadow);z-index:300;white-space:nowrap">Bearbeiten kommt in einer späteren Phase — aktuell nur zum Ansehen.</div>
<div id="warning-banner" hidden style="position:fixed;top:14px;left:50%;transform:translateX(-50%);background:var(--warn-tint);color:var(--warn);border:1px solid var(--warn);padding:10px 16px;border-radius:var(--radius-m);font-family:var(--font-head);font-size:12.5px;font-weight:600;box-shadow:var(--shadow);z-index:300;display:flex;align-items:center;gap:12px;max-width:calc(100vw - 40px)">
<span id="warning-banner-text"></span>
<button type="button" id="warning-banner-close" style="background:none;border:0;color:inherit;font-size:16px;line-height:1;cursor:pointer;padding:0"></button>
</div>
<script src="offline-store.js"></script>
<script src="pair-client.js"></script>
</body>
</html>
+91
View File
@@ -0,0 +1,91 @@
// offline-store.js — the tablet's local data store (IndexedDB), so the app keeps
// working with zero connectivity once the directory/an animal has been opened while
// still on the practice WiFi. Two jobs: (1) cache what's been read, so it can be
// re-displayed offline; (2) queue what's been written while offline, so it can be
// replayed against the practice PC once reachable again.
"use strict";
(function () {
const DB_NAME = "fellakte-offline";
const DB_VERSION = 1;
let dbPromise = null;
function openDb() {
if (dbPromise) return dbPromise;
dbPromise = new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, DB_VERSION);
req.onupgradeneeded = () => {
const db = req.result;
if (!db.objectStoreNames.contains("bundles")) db.createObjectStore("bundles", { keyPath: "id" });
if (!db.objectStoreNames.contains("meta")) db.createObjectStore("meta", { keyPath: "key" });
if (!db.objectStoreNames.contains("queue")) db.createObjectStore("queue", { keyPath: "id", autoIncrement: true });
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
return dbPromise;
}
// Runs a write (put/add/delete) against `name` and resolves once the transaction
// commits. The return value of `fn` (an IDBRequest) is not needed by any caller.
async function runWrite(name, fn) {
const db = await openDb();
return new Promise((resolve, reject) => {
const t = db.transaction(name, "readwrite");
fn(t.objectStore(name));
t.oncomplete = () => resolve();
t.onerror = () => reject(t.error);
});
}
// Runs a single read request against `name` and resolves with its result
// (undefined on error, so callers can treat a miss the same as "not cached").
async function runRead(name, fn) {
const db = await openDb();
return new Promise((resolve) => {
const t = db.transaction(name, "readonly");
const req = fn(t.objectStore(name));
req.onsuccess = () => resolve(req.result);
req.onerror = () => resolve(undefined);
});
}
window.offlineStore = {
async saveDirectory(list) {
await runWrite("meta", (s) => s.put({ key: "directory", value: list, at: Date.now() }));
},
async getDirectory() {
const row = await runRead("meta", (s) => s.get("directory"));
return row ? row.value : null;
},
async saveBundle(bundle) {
if (!bundle || !bundle.animal) return;
await runWrite("bundles", (s) => s.put({ id: bundle.animal.id, bundle, at: Date.now() }));
},
async getBundle(id) {
const row = await runRead("bundles", (s) => s.get(id));
return row ? row.bundle : null;
},
// Scans cached bundles for one containing a finding with this id — used for
// update/delete offline, where the caller only has the finding id, not the
// animal it belongs to (same shape as the desktop IPC calls).
async findAnimalIdForFinding(findingId) {
const rows = (await runRead("bundles", (s) => s.getAll())) || [];
const hit = rows.find((r) => r.bundle.findings.some((f) => f.id === findingId));
return hit ? hit.id : null;
},
async enqueue(op) {
await runWrite("queue", (s) => s.add({ ...op, createdAt: Date.now() }));
},
async listQueue() {
return (await runRead("queue", (s) => s.getAll())) || [];
},
async removeFromQueue(id) {
await runWrite("queue", (s) => s.delete(id));
},
async queueCount() {
return (await this.listQueue()).length;
},
};
})();
+435
View File
@@ -0,0 +1,435 @@
// pair-client.js — loaded before app.js. Defines window.api for the web client
// (talking to main/webserver.js over the local network instead of Electron IPC),
// handles the one-time pairing screen, and only injects app.js once a valid
// session is confirmed — app.js calls window.api immediately on load, so it must
// never run before window.api (and a working session) exist.
//
// Phase 3: offline. Reads fall back to the local cache (offline-store.js) when the
// practice PC isn't reachable; writes to Notizen/Befunde queue locally and apply
// optimistically to the cache, then replay automatically once reachable again. This
// only works for a tab that was already open (and the animal already viewed) while
// still on the practice WiFi — a from-scratch cold load with zero network needs a
// Service Worker, which needs HTTPS, which this plain-WLAN setup deliberately skips.
"use strict";
(function () {
const TOKEN_KEY = "fellakte_token";
let token = localStorage.getItem(TOKEN_KEY);
let isOnline = true;
let syncing = false;
const overlay = document.getElementById("pairing-overlay");
const form = document.getElementById("pair-form");
const codeInput = document.getElementById("pair-code-input");
const status = document.getElementById("pair-status");
const toast = document.getElementById("readonly-toast");
function showPairing(prefillCode) {
overlay.hidden = false;
if (prefillCode) codeInput.value = prefillCode;
codeInput.focus();
}
function hidePairing() {
overlay.hidden = true;
}
function authHeaders() {
return token ? { Authorization: "Bearer " + token } : {};
}
let toastTimer = null;
function showToast(text) {
if (!toast) return;
toast.textContent = text;
toast.hidden = false;
clearTimeout(toastTimer);
toastTimer = setTimeout(() => (toast.hidden = true), 3200);
}
function readOnlyNotice() {
showToast("Bearbeiten kommt in einer späteren Phase — aktuell nur zum Ansehen.");
}
function offlineUnavailableNotice() {
showToast("Ohne Verbindung aktuell nicht möglich.");
}
function showWarningBanner(info) {
const banner = document.getElementById("warning-banner");
const text = document.getElementById("warning-banner-text");
if (!banner || !text) return;
const when = new Date(info.updatedAt).toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" });
text.textContent = `Achtung: "${info.host}" wurde zuletzt um ${when} Uhr ebenfalls verwendet — kurz absprechen, bevor beide gleichzeitig speichern.`;
banner.hidden = false;
}
async function renderSyncStatus() {
const chip = document.getElementById("sync-status");
if (!chip) return;
const pending = await window.offlineStore.queueCount();
if (isOnline && pending === 0) {
chip.textContent = "Verbunden";
chip.style.background = "var(--ok-tint)";
chip.style.color = "var(--ok)";
} else if (isOnline && pending > 0) {
chip.textContent = `Synchronisiere … (${pending})`;
chip.style.background = "var(--warn-tint)";
chip.style.color = "var(--warn)";
} else if (pending > 0) {
chip.textContent = `Offline — ${pending} wartend`;
chip.style.background = "var(--warn-tint)";
chip.style.color = "var(--warn)";
} else {
chip.textContent = "Offline";
chip.style.background = "var(--surface-alt)";
chip.style.color = "var(--ink-faint)";
}
}
function setOnline(v) {
const changed = v !== isOnline;
isOnline = v;
renderSyncStatus();
if (changed && v) trySync();
}
// ---- reads: network first, cache fallback ----
async function apiGet(path, cache) {
try {
const res = await fetch(path, { headers: authHeaders() });
if (res.status === 401) {
token = null;
localStorage.removeItem(TOKEN_KEY);
showPairing();
throw new Error("unauthorized");
}
if (!res.ok) throw new Error("request-failed:" + res.status);
const data = await res.json();
setOnline(true);
if (cache && cache.kind === "directory") window.offlineStore.saveDirectory(data);
if (cache && cache.kind === "bundle") window.offlineStore.saveBundle(data);
return data;
} catch (err) {
if (err.message === "unauthorized") throw err;
setOnline(false);
if (cache && cache.kind === "directory") {
const cached = await window.offlineStore.getDirectory();
if (cached) return cached;
}
if (cache && cache.kind === "bundle") {
const cached = await window.offlineStore.getBundle(cache.key);
if (cached) return cached;
}
throw err;
}
}
// ---- writes: network first; on failure, queue + apply optimistically to the
// cache when `offlineSpec` says this kind of write supports that (Notizen,
// Befunde). Surfaces the server's "someone else is using this data folder right
// now" advisory as a dismissible banner (same idea as the desktop app's startup
// dialog) — advisory only, matching how the two practice PCs already behave.
async function apiSend(method, path, body, offlineSpec) {
try {
const res = await fetch(path, {
method,
headers: { ...authHeaders(), "Content-Type": "application/json" },
body: body !== undefined ? JSON.stringify(body) : undefined,
});
if (res.status === 401) {
token = null;
localStorage.removeItem(TOKEN_KEY);
showPairing();
throw new Error("unauthorized");
}
if (!res.ok) throw new Error("request-failed:" + res.status);
const data = await res.json().catch(() => ({}));
setOnline(true);
if (data && data.warning) showWarningBanner(data.warning);
return data;
} catch (err) {
if (err.message === "unauthorized") throw err;
setOnline(false);
if (!offlineSpec) {
offlineUnavailableNotice();
throw err;
}
await window.offlineStore.enqueue({ method, path, body });
await applyOptimistic(offlineSpec, body);
renderSyncStatus();
return { ok: true, offline: true };
}
}
async function applyOptimistic(spec, body) {
const nowIso = new Date().toISOString();
const localId = () => "lokal-" + Date.now() + "-" + Math.random().toString(36).slice(2, 7);
if (spec.kind === "note") {
const bundle = await window.offlineStore.getBundle(body.animalId);
if (!bundle) return;
bundle.notes.unshift({
id: localId(),
animal_id: body.animalId,
date: new Date().toLocaleDateString("de-DE"),
text: body.text,
created_at: nowIso,
});
await window.offlineStore.saveBundle(bundle);
} else if (spec.kind === "finding-add") {
const bundle = await window.offlineStore.getBundle(body.animalId);
if (!bundle) return;
bundle.findings.push({
id: localId(),
animal_id: body.animalId,
x: body.x,
y: body.y,
severity: body.severity,
text: body.text,
created_at: nowIso,
});
await window.offlineStore.saveBundle(bundle);
} else if (spec.kind === "finding-update") {
const animalId = await window.offlineStore.findAnimalIdForFinding(spec.findingId);
if (!animalId) return;
const bundle = await window.offlineStore.getBundle(animalId);
const f = bundle.findings.find((x) => x.id === spec.findingId);
if (f) {
f.severity = body.severity;
f.text = body.text;
await window.offlineStore.saveBundle(bundle);
}
} else if (spec.kind === "finding-delete") {
const animalId = await window.offlineStore.findAnimalIdForFinding(spec.findingId);
if (!animalId) return;
const bundle = await window.offlineStore.getBundle(animalId);
bundle.findings = bundle.findings.filter((x) => x.id !== spec.findingId);
await window.offlineStore.saveBundle(bundle);
}
}
// Replays the queue in order against the real server. Stops (leaves the rest
// queued) at the first failure — still offline, or the practice PC went away
// mid-sync. On a full, non-empty drain, reloads so the whole page re-reads fresh,
// server-confirmed data instead of the optimistic local copies.
async function trySync() {
if (syncing) return;
syncing = true;
try {
const queue = await window.offlineStore.listQueue();
if (!queue.length) {
setOnline(await pingServer());
return;
}
let drained = 0;
for (const op of queue) {
try {
const res = await fetch(op.path, {
method: op.method,
headers: { ...authHeaders(), "Content-Type": "application/json" },
body: op.body !== undefined ? JSON.stringify(op.body) : undefined,
});
if (res.status === 401) {
token = null;
localStorage.removeItem(TOKEN_KEY);
showPairing();
return;
}
if (!res.ok) throw new Error("sync-failed:" + res.status);
await window.offlineStore.removeFromQueue(op.id);
drained++;
} catch {
setOnline(false);
return; // still offline (or PC unreachable) — rest stays queued for next try
}
}
setOnline(true);
if (drained > 0) location.reload();
} finally {
syncing = false;
}
}
async function pingServer() {
try {
await fetch("/api/directory", { headers: authHeaders() });
return true; // any HTTP response at all means the practice PC is reachable
} catch {
return false;
}
}
// Resolves with `fallback` after showing the read-only notice — safe for callers
// that just re-render afterward (await refreshBundle(); renderPanels();).
function stub(fallback) {
return async () => {
readOnlyNotice();
return fallback;
};
}
// Rejects instead — used only where the caller immediately depends on the result
// (e.g. `const id = await createAnimal(...); await selectAnimal(id)`), so a
// silently-wrong id can't cascade into a broken render.
function rejectingStub() {
return async () => {
readOnlyNotice();
throw new Error("read-only");
};
}
window.api = {
listDirectory: () => apiGet("/api/directory", { kind: "directory" }),
getAnimalBundle: (id) => apiGet("/api/animal/" + encodeURIComponent(id), { kind: "bundle", key: id }),
listAppointments: (fromDate, toDate) =>
apiGet(`/api/appointments?from=${encodeURIComponent(fromDate || "")}&to=${encodeURIComponent(toDate || "")}`),
listRemedies: (query) => apiGet("/api/remedies?q=" + encodeURIComponent(query || "")),
searchNotes: (query) => apiGet("/api/search-notes?q=" + encodeURIComponent(query || "")),
openDocument: async (documentId) => {
const { url } = await apiGet("/api/document-url/" + encodeURIComponent(documentId));
window.open(url, "_blank");
return true;
},
documentAbsPath: stub(null),
dataFolder: stub(null),
createOwner: rejectingStub(),
createAnimal: rejectingStub(),
updateAnimalField: stub(true),
updateOwnerField: stub(true),
deleteAnimal: stub(true),
deleteOwner: stub(true),
addNote: (animalId, text) => apiSend("POST", "/api/note", { animalId, text }, { kind: "note" }),
addHistoryEntry: stub(true),
addLabResult: stub(null),
addLabValue: stub(true),
deleteLabResult: stub(true),
addFinding: (animalId, finding) => apiSend("POST", "/api/finding", { animalId, ...finding }, { kind: "finding-add" }),
updateFinding: (id, patch) =>
apiSend("PATCH", "/api/finding/" + encodeURIComponent(id), patch, { kind: "finding-update", findingId: id }),
deleteFinding: (id) =>
apiSend("DELETE", "/api/finding/" + encodeURIComponent(id), undefined, { kind: "finding-delete", findingId: id }),
addExam: stub(null),
// Phase 4: needs a live connection to the practice PC (PDF/E-Mail are server-side
// operations — see the file header) — not queued offline like Notizen/Befunde.
setConsent: (animalId, patch) => apiSend("POST", "/api/consent", { animalId, ...patch }),
saveSignature: (animalId, dataUrl) => apiSend("POST", "/api/consent/signature", { animalId, dataUrl }),
setContract: (animalId, patch) => apiSend("POST", "/api/contract", { animalId, ...patch }),
saveContractSignature: (animalId, dataUrl) => apiSend("POST", "/api/contract/signature", { animalId, dataUrl }),
generatePdf: (animalId, kind, content) => apiSend("POST", "/api/document/pdf", { animalId, kind, ...content }),
generateTemplatePdf: (animalId, filenamePrefix, content) =>
apiSend("POST", "/api/document/template-pdf", { animalId, filenamePrefix, ...content }),
sendDocumentEmail: (animalId, kind, file) => apiSend("POST", "/api/document/email", { animalId, kind, ...file }),
addRemedy: stub(null),
updateRemedy: stub(true),
deleteRemedy: stub(true),
// Termine: not yet queued for offline (Phase 3 scope is Notizen/Befunde) — a
// clear "not available offline" notice instead of queueing silently.
addAppointment: (data) => apiSend("POST", "/api/appointment", data),
updateAppointment: (id, data) => apiSend("PATCH", "/api/appointment/" + encodeURIComponent(id), data),
deleteAppointment: (id) => apiSend("DELETE", "/api/appointment/" + encodeURIComponent(id)),
getLexwarePath: stub(null),
setLexwarePath: stub(true),
openLexware: stub({ ok: false, error: "not-available" }),
getSmtpSettings: stub(null),
setSmtpSettings: stub(true),
testSmtpSettings: stub({ ok: false, error: "not-available" }),
getReminderLead: stub(30),
setReminderLead: stub(true),
backupNow: stub({ ok: false, error: "not-available" }),
getBackupInfo: stub({ count: 0, last: null }),
listBackups: stub([]),
openBackupFolder: stub(true),
restoreBackup: stub({ ok: false, error: "not-available" }),
addDocument: stub([]),
};
const warningClose = document.getElementById("warning-banner-close");
if (warningClose) warningClose.addEventListener("click", () => (document.getElementById("warning-banner").hidden = true));
function loadApp() {
const s = document.createElement("script");
s.src = "app.js";
document.body.appendChild(s);
}
// Distinguishes "token is genuinely invalid" (401 → must re-pair) from "can't
// reach the practice PC right now" (network error → proceed in offline mode with
// whatever's cached, a valid token just can't be re-confirmed this moment).
async function checkSession() {
try {
const res = await fetch("/api/directory", { headers: authHeaders() });
if (res.status === 401) return "invalid";
return "online";
} catch {
return "offline";
}
}
form.addEventListener("submit", async (e) => {
e.preventDefault();
const code = codeInput.value.trim();
if (!code) return;
status.textContent = "Verbinde …";
try {
const res = await fetch("/api/pair", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ code, deviceName: guessDeviceName() }),
});
if (!res.ok) {
status.textContent = res.status === 410 || res.status === 429
? "Code abgelaufen. Am PC einen neuen Code erzeugen."
: "Code falsch — bitte nochmal prüfen.";
return;
}
const data = await res.json();
token = data.token;
localStorage.setItem(TOKEN_KEY, token);
location.reload();
} catch {
status.textContent = "Keine Verbindung zum Praxis-PC — gleiches WLAN?";
}
});
function guessDeviceName() {
const ua = navigator.userAgent || "";
if (/iPad/.test(ua)) return "iPad";
if (/iPhone/.test(ua)) return "iPhone";
if (/Android/.test(ua)) return "Android-Gerät";
if (/Macintosh/.test(ua)) return "Mac";
return "Gerät";
}
(async function init() {
const params = new URLSearchParams(location.search);
const prefill = params.get("pair");
if (!token) {
showPairing(prefill);
return;
}
const state = await checkSession();
if (state === "invalid") {
showPairing(prefill);
return;
}
hidePairing();
if (prefill) history.replaceState(null, "", location.pathname);
isOnline = state === "online";
loadApp();
renderSyncStatus();
setInterval(trySync, 15000);
window.addEventListener("online", trySync);
if (state === "online") trySync(); // drain any queue left over from last time
})();
})();