82 Commits

Author SHA1 Message Date
99bf2fa1f4 Test 2025-03-21 13:39:12 +01:00
cd4db22e72 fix: Login-Passwort aus Umgebungsvariable LOGIN_PASSWORD verwenden 2025-03-21 13:38:04 +01:00
1cf8fbb91d Version 1.2.17: Optimierte Datenbankverbindungen und verbesserte Indizes 2025-03-21 12:56:48 +01:00
16bd7ec544 Optimierung: Thread-sichere Datenbankverbindungen und verbesserte Indizes 2025-03-21 12:55:50 +01:00
4d5a7b4f5e Version 1.2.16: Verbesserte Suchfunktion und Reset-Buttons 2025-03-21 12:42:18 +01:00
9922c0ae9d Verbesserte Suchfunktion: Highlighting für allgemeine Suche und Reset-Buttons 2025-03-21 12:40:59 +01:00
93314424d9 Feature: VCF-Export der Suchergebnisse 2025-03-21 12:07:18 +01:00
d14ac5d9af Feature: CSV-Export der Suchergebnisse 2025-03-21 12:04:17 +01:00
b4939147c4 Verbesserte index.html: JavaScript-Code in separate Datei ausgelagert und Meta-Tags hinzugefügt 2025-03-21 12:03:00 +01:00
2a33fc45de Version 1.2.16: Verbesserte Darstellung der Suchergebnisse und Code-Organisation 2025-03-21 11:50:54 +01:00
3a317de8f0 Entfernt doppelte CSS-Datei style.css 2025-03-21 11:34:28 +01:00
f626cd52fd Version 1.2.15: Autovervollständigung für Orte 2025-03-21 11:25:28 +01:00
6283ccff6d Version 1.2.14: Autovervollständigung für Fachrichtungen 2025-03-21 10:53:37 +01:00
3893e148ab README Code Statistik 2025-03-20 14:20:42 +01:00
65fbde845e Update: Code-Statistiken in README.md aktualisiert 2025-03-20 14:19:23 +01:00
57d3a05a5a Remove: docker-compose.yml aus dem Repository entfernt 2025-03-20 12:17:55 +01:00
dfd50d5e78 Entferne docker-compose.yml aus Repository 2025-03-20 12:14:33 +01:00
73541796f6 Manuelle Änderungen in .gitignore 2025-03-20 12:01:07 +01:00
e3e9900482 Version 1.2.13: Korrektur der SQL-Abfrage für die allgemeine Suche 2025-03-20 11:58:54 +01:00
156078ff3a Version 1.2.13: Korrektur der SQL-Abfrage für die allgemeine Suche 2025-03-20 11:55:07 +01:00
5d56c7128e Remove: .bak-Dateien aus dem Repository entfernt und zur .gitignore hinzugefügt 2025-03-20 11:06:54 +01:00
98f887f51e Remove: CSV-Dateien aus dem Repository entfernt und zur .gitignore hinzugefügt 2025-03-20 10:23:45 +01:00
f46947b6d4 Fix: Performance-Optimierungen und verbessertes Highlighting in index.html 2025-03-20 10:14:54 +01:00
c1b55c3579 Version 1.2.12: Performance-Optimierungen und verbessertes Highlighting 2025-03-20 09:57:17 +01:00
6a2e290d54 Release v1.2.11: Filterung von intern-Einträgen und verbesserte SQL-Abfrage 2025-03-19 19:09:19 +01:00
7263464b89 Release v1.2.11: Filterung von intern-Einträgen und verbesserte SQL-Abfrage 2025-03-19 19:08:34 +01:00
7b88e62de0 Release v1.2.10: Version aus VERSION-Variable und verbesserte Zeilenabstände 2025-03-19 18:54:06 +01:00
d50ea60af9 Release v1.2.10: Version aus VERSION-Variable und verbesserte Zeilenabstände 2025-03-19 18:52:37 +01:00
ec559f5c72 Fix: Suchergebnisse werden wieder korrekt angezeigt 2025-03-19 18:39:29 +01:00
c454620ae1 Release v1.2.9: Verbesserte Adress-Links und Docker-Port-Mapping 2025-03-19 15:01:59 +01:00
94c381fbfc Aktualisierung der Dokumentation und Versionsangabe auf v1.2.8 2025-03-19 14:12:30 +01:00
06576b08d9 Verbesserte Tag-Anzeige: Badge neben dem Teilen-Button mit farblicher Hervorhebung 2025-03-19 14:07:58 +01:00
465a6f058a Merge-Konflikte gelöst und Feldreihenfolge korrigiert 2025-03-19 13:35:49 +01:00
daf7499b4e Korrektur der Feldreihenfolge in den Suchergebnissen und Verbesserung der Tag-Anzeige 2025-03-19 13:34:10 +01:00
fade9b8d62 Hervorhebung des Tag-Feldes in den Suchergebnissen: - Blauer Hintergrund für MEDISOFT-Tags - Oranger Hintergrund für MEDICONSULT-Tags - Verbesserte visuelle Darstellung der Tags 2025-03-19 13:18:22 +01:00
611a5dd906 Fix: Teilen-Button und Telefonnummern-Links wieder hinzugefügt, Zeilenabstand in Suchergebnissen optimiert 2025-03-19 09:56:06 +01:00
35645fc671 Fix: Login-Funktionalität wiederhergestellt und Passwort aktualisiert 2025-03-18 16:11:10 +01:00
fab869eb58 Logo mit Link zu medisoftware.de versehen 2025-03-18 15:47:56 +01:00
6cd8f199c4 Gitignore: docker-compose.yml hinzugefügt 2025-03-18 15:42:48 +01:00
948a17b739 Docker Compose: Beispiel-Konfiguration hinzugefügt und Original aus Repository entfernt 2025-03-18 15:41:23 +01:00
f2290cf77f Version 1.2.6: Verbesserte Suchfunktion und Highlighting - app.py aktualisiert 2025-03-18 15:33:01 +01:00
0627b6ff33 Version 1.2.6: Verbesserte Suchfunktion und Highlighting 2025-03-18 15:32:20 +01:00
24ba040537 Version 1.2.5: Dokumentation aktualisiert 2025-03-18 15:12:50 +01:00
997786be54 Feature: Suchfeld für Fachrichtung hinzugefügt 2025-03-18 15:11:15 +01:00
c4974787d4 Dokumentation: Korrigiere Versionsangabe am Ende der README.md 2025-03-18 14:08:04 +01:00
a42bdaa721 Dokumentation: Aktualisiere Versionsnummer in README.md auf v1.2.4 2025-03-18 14:06:31 +01:00
49938a1085 Version 1.2.4: Performance-Optimierung durch Datenbankindizes 2025-03-18 14:02:07 +01:00
d0a27fe095 Performance: Indizes für alle Suchfelder hinzugefügt 2025-03-18 14:01:08 +01:00
d388bce528 Footer Text geändert 2025-03-18 13:59:57 +01:00
aabb4540c9 Version 1.2.3: Performance-Optimierung durch Entfernung von Debug-Ausgaben 2025-03-18 13:57:32 +01:00
ffde078238 Git: Füge data/customers.db zur .gitignore hinzu 2025-03-18 13:55:17 +01:00
9e320c4eb2 Performance: Entferne alle console.log Anweisungen 2025-03-18 13:53:14 +01:00
58ed5fe867 Performance: Entferne Debug-Logging aus createPhoneLink Funktion 2025-03-18 13:50:32 +01:00
72676edc10 Git: Füge data/customers.db zur .gitignore hinzu 2025-03-18 13:48:55 +01:00
d5954eac89 Version 1.2.2: Verbesserte Telefonnummern-Formatierung und Dokumentation 2025-03-18 13:47:41 +01:00
68a2db28a1 Dokumentation: Aktualisierte README.md und CHANGELOG.md für Version 1.2.2 2025-03-18 13:46:33 +01:00
13709de515 Version 1.2.2: Verbesserte Telefonnummern-Formatierung 2025-03-18 13:43:07 +01:00
2c65d5f651 Dokumentation: Aktualisierung für Version 1.2.1 2025-03-18 12:56:15 +01:00
33ecb79e0b Version 1.2.1: Verbesserte CSV-Import-Funktionalität und Login-Fix 2025-03-18 12:54:31 +01:00
00bb197620 CSS in separate Datei ausgelagert 2025-03-18 12:34:14 +01:00
8e7d3da07f Release v1.2.0: IP-Subnetz-Prüfung und Debug-Logging 2025-03-18 12:32:06 +01:00
35e0d0a783 Beispiel-Umgebungsvariablen hinzugefügt 2025-03-18 12:28:46 +01:00
40196fa28f Debug-Logging für IP-Adressen hinzugefügt 2025-03-18 12:26:46 +01:00
7b5e90e3bd IP-Subnetz-Prüfung korrigiert und Debug-Logging hinzugefügt 2025-03-18 12:25:53 +01:00
3c48988e88 Routenplanung für Adressen hinzugefügt 2025-03-18 11:35:46 +01:00
869acdcb18 IP-Überprüfung für Telefonnummern-Links implementiert 2025-03-18 11:28:27 +01:00
91af1dfca0 README und CHANGELOG für v1.0.7 aktualisiert 2025-03-18 11:15:36 +01:00
c271bc1f60 IP-Überprüfung mit X-Forwarded-For-Header hinzugefügt 2025-03-18 11:10:24 +01:00
9c04bb973e Version im Footer hinzugefügt 2025-03-18 10:24:18 +01:00
6d7298548b Dokumentation für Release v1.2.0 aktualisiert 2025-03-18 10:19:21 +01:00
42a11abe61 Login 2025-03-18 10:10:17 +01:00
3c6d2f7c45 Titel von login.html auf 'medisoftware Kundensuche' geändert 2025-03-18 09:44:45 +01:00
d143d6c7b6 Logo hinzugefügt 2025-03-18 09:10:57 +01:00
e6638b737d Logo im oberen Bereich der App zentriert hinzugefügt 2025-03-18 09:08:53 +01:00
1f493e0a37 Korrektur: medisw: Link-Parameter wiederhergestellt 2025-03-18 08:58:12 +01:00
469ad0ce05 Kundennummer wird um 12000 reduziert in Anzeige und medisw: Link 2025-03-18 08:57:40 +01:00
8a8c13e407 Port 2025-03-18 00:45:45 +01:00
18974eb69b Footer: Nur das Wort 'medisoftware' ist jetzt verlinkt 2025-03-18 00:41:07 +01:00
9e406ed7a3 Footer: Nur das Wort 'medisoftware' ist jetzt verlinkt 2025-03-18 00:38:57 +01:00
8b82a44ad8 Footer-Text aktualisiert: Made with ❤️ and 🍺 by medisoftware 2025-03-18 00:37:39 +01:00
528baff7b5 Aktuelle Version in README.md auf v1.1.0 aktualisiert 2025-03-18 00:33:54 +01:00
e105dc4663 Dokumentation für Release v1.1.0 aktualisiert 2025-03-18 00:32:05 +01:00
14 changed files with 1756 additions and 1835 deletions

16
.env.example Normal file
View File

@@ -0,0 +1,16 @@
# Flask-Konfiguration
FLASK_APP=app.py
FLASK_ENV=development
SECRET_KEY=your-secret-key-here
# Datenbank-Konfiguration
DATABASE_URL=sqlite:///customers.db
# Authentifizierung
STATIC_PASSWORD=your-static-password-here
# IP-Bereiche für direkten Zugriff (ohne Login)
ALLOWED_IP_RANGES=192.168.0.0/24,192.168.177.0/24,213.178.68.218/29
# Logging-Konfiguration
LOG_LEVEL=INFO

18
.gitignore vendored
View File

@@ -17,6 +17,7 @@ pip-delete-this-directory.txt
.vscode/ .vscode/
*.swp *.swp
*.swo *.swo
*.bak
# Logs # Logs
*.log *.log
@@ -43,6 +44,21 @@ coverage.xml
# Docker # Docker
.docker/ .docker/
docker-compose.yml
# Daten # Daten
spezexpo.csv data/
# Virtual Environment
develop-eggs/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
.installed.cfg
*.egg

View File

@@ -5,37 +5,210 @@ Alle wichtigen Änderungen an diesem Projekt werden in dieser Datei dokumentiert
Das Format basiert auf [Keep a Changelog](https://keepachangelog.com/de/1.0.0/), Das Format basiert auf [Keep a Changelog](https://keepachangelog.com/de/1.0.0/),
und dieses Projekt adhäriert zu [Semantic Versioning](https://semver.org/lang/de/). und dieses Projekt adhäriert zu [Semantic Versioning](https://semver.org/lang/de/).
## [1.0.6] - 2024-03-17 ## [Unreleased]
### Geändert ### Hinzugefügt
- Verbesserte Suchfunktion: Kombinierte Suche über mehrere Felder möglich - CSV-Export-Funktion für Suchergebnisse
- Dokumentation: Beispiele für kombinierte Suche hinzugefügt - Export-Button in der Benutzeroberfläche
- Automatische Formatierung der CSV-Datei mit allen relevanten Kundendaten
## [1.0.5] - 2024-03-17 ## [1.2.16] - 2024-03-21
### Geändert ### Geändert
- Verbesserte Suchfunktion: Ergebnisliste wird gelöscht, wenn alle Suchfelder leer sind - Verbesserte Suchfunktion: Highlighting für allgemeine Suche in allen Feldern
- Optimierte Reset-Buttons in den Suchfeldern
- Verbesserte CSS-Styles für die Suchfeld-Icons
## [1.0.4] - 2024-03-17 ## [1.2.15] - 2024-03-20
### Geändert ### Hinzugefügt
- Verbesserte Adressanzeige: Location-Icon neben der Adresse - Autovervollständigung für das Ort-Feld
- Entfernung des Google Maps Links aus dem Adresstext - Neue API-Route für Ortsvorschläge
- Optimierte SQL-Abfragen für die Ortssuche
## [1.0.3] - 2024-03-17 ## [1.2.14] - 2024-03-20
### Hinzugefügt
- Autovervollständigung für das Fachrichtungsfeld
- Neue API-Route für Fachrichtungsvorschläge
- Optimierte SQL-Abfragen für die Fachrichtungssuche
## [1.2.13] - 2024-03-20
### Fixed
- Korrektur der Parameteranzahl in der SQL-Abfrage für die allgemeine Suche
- Behebung des Fehlers bei der Suche in allen Datenbankfeldern
## [v1.2.12] - 2024-03-19
### Geändert ### Geändert
- Hervorhebung der Suchbegriffe in den Ergebnissen - Performance-Optimierung der Suchfunktion durch Reduzierung der Suchfelder
- Verbesserte Suchgeschwindigkeit durch LIMIT in SQL-Abfragen
- Optimiertes Debounce-Intervall für Live-Suche
- Verbessertes Highlighting für Teilstrings in Suchergebnissen
## [v1.2.11] - 2024-03-19
### Geändert
- Einträge mit der Fachrichtung "intern" werden aus den Suchergebnissen gefiltert
- Verbesserte SQL-Abfrage für die Suchergebnisse
## [v1.2.10] - 2024-03-19
### Geändert
- Verbesserte Darstellung der Adress-Links mit Location- und Route-Icons
- Korrektur des Docker-Port-Mappings für bessere Erreichbarkeit
## [v1.2.9] - 2024-03-19
### Geändert
- Verbesserte Darstellung der Adress-Links mit Location- und Route-Icons
- Korrektur des Docker-Port-Mappings für bessere Erreichbarkeit
## [v1.2.8] - 2024-03-19
### Geändert
- Verbesserte Tag-Anzeige: Badge neben dem Teilen-Button mit farblicher Hervorhebung (blau für MEDISOFT, orange für MEDICONSULT)
## [v1.2.7] - 2024-03-19
### Geändert
- Korrektur der Feldreihenfolge in den Suchergebnissen
- Verbesserung der Tag-Anzeige
## [v1.2.6] - 2024-03-19
### Geändert
- Verbesserte Darstellung der Kundennummern in den Suchergebnissen
- Optimierte Anzeige der Kundennummern in der Detailansicht
- Anpassung der Kundennummernberechnung für die MEDISOFT-Integration
- Verbesserte Formatierung der Kundennummern in der Benutzeroberfläche
## [v1.2.5] - 2024-03-19
### Geändert
- Verbesserte Darstellung der Kundennummern in den Suchergebnissen
- Optimierte Anzeige der Kundennummern in der Detailansicht
- Anpassung der Kundennummernberechnung für die MEDISOFT-Integration
- Verbesserte Formatierung der Kundennummern in der Benutzeroberfläche
## [v1.2.4] - 2024-03-19
### Geändert
- Verbesserte Darstellung der Kundennummern in den Suchergebnissen
- Optimierte Anzeige der Kundennummern in der Detailansicht
- Anpassung der Kundennummernberechnung für die MEDISOFT-Integration
- Verbesserte Formatierung der Kundennummern in der Benutzeroberfläche
## [v1.2.3] - 2024-03-19
### Geändert
- Verbesserte Darstellung der Kundennummern in den Suchergebnissen
- Optimierte Anzeige der Kundennummern in der Detailansicht
- Anpassung der Kundennummernberechnung für die MEDISOFT-Integration
- Verbesserte Formatierung der Kundennummern in der Benutzeroberfläche
## [v1.2.2] - 2024-03-19
### Geändert
- Verbesserte Darstellung der Kundennummern in den Suchergebnissen
- Optimierte Anzeige der Kundennummern in der Detailansicht
- Anpassung der Kundennummernberechnung für die MEDISOFT-Integration
- Verbesserte Formatierung der Kundennummern in der Benutzeroberfläche
## [v1.2.1] - 2024-03-19
### Geändert
- Verbesserte Darstellung der Kundennummern in den Suchergebnissen
- Optimierte Anzeige der Kundennummern in der Detailansicht
- Anpassung der Kundennummernberechnung für die MEDISOFT-Integration
- Verbesserte Formatierung der Kundennummern in der Benutzeroberfläche
## [v1.2.0] - 2024-03-19
### Hinzugefügt
- Neue Spalte "Tag" in der Kundendatenbank
- Unterscheidung zwischen MEDISOFT und MEDICONSULT Kunden
- Filteroption für die Anzeige von MEDISOFT oder MEDICONSULT Kunden
- Verbesserte Darstellung der Kundennummern in den Suchergebnissen
- Optimierte Anzeige der Kundennummern in der Detailansicht
- Anpassung der Kundennummernberechnung für die MEDISOFT-Integration
- Verbesserte Formatierung der Kundennummern in der Benutzeroberfläche
## [v1.1.0] - 2024-03-19
### Hinzugefügt
- Neue Spalte "Tag" in der Kundendatenbank
- Unterscheidung zwischen MEDISOFT und MEDICONSULT Kunden
- Filteroption für die Anzeige von MEDISOFT oder MEDICONSULT Kunden
## [v1.0.0] - 2024-03-19
### Hinzugefügt
- Erste Version der Anwendung
- Grundlegende Suchfunktionalität
- Anzeige von Kundendetails
- Integration mit MEDISOFT
- Responsive Design
- IP-basierte Zugriffskontrolle
## [1.2.6] - 2024-03-19
### Geändert
- Verbesserte Suchfunktion: Keine Ergebnisse mehr bei leeren Suchfeldern
- Optimiertes Highlighting der Suchergebnisse für alle Suchfelder
- Fachrichtung wird jetzt in den Suchergebnissen hervorgehoben
## [1.2.5] - 2024-03-19
### Hinzugefügt
- Neues Suchfeld für Fachrichtung
- Index für das Fachrichtung-Feld in der Datenbank
- Fachrichtung in der allgemeinen Suche integriert
## [1.2.4] - 2024-03-19
### Geändert
- Performance-Optimierung: Indizes für alle Suchfelder hinzugefügt
- Verbesserte Suchgeschwindigkeit durch optimierte Datenbankindizes
- Zusammengesetzter Index für die häufigste Suchkombination (Name + Ort) hinzugefügt
## [1.2.3] - 2024-03-19
### Geändert
- Performance-Optimierung: Entfernung aller console.log Anweisungen
- Verbesserte Code-Qualität durch Entfernung von Debug-Ausgaben
## [1.2.2] - 2024-03-19
### Geändert
- Verbesserte Telefonnummern-Formatierung: Führende "0" wird immer hinzugefügt, wenn der Benutzer von einer erlaubten IP-Adresse zugreift
- Debug-Logging für Telefonnummern-Formatierung hinzugefügt
- Verbesserte Benutzerfreundlichkeit bei der Anzeige von Telefonnummern
### Behoben
- Problem mit fehlender führender "0" bei Telefonnummern für autorisierte Benutzer
## [1.2.1] - 2024-03-18
### Geändert
- Verbesserte CSV-Import-Funktionalität mit pandas
- Korrektur des Login-Prozesses
- Verbesserte Fehlerbehandlung und Logging
- Anpassung der Spaltennamen für den CSV-Import
### Behoben
- Login-Prozess funktioniert jetzt korrekt mit dem Passwort aus der .env Datei
- CSV-Import verarbeitet Anführungszeichen korrekt
- Verbesserte Fehlerbehandlung beim Datenbankimport
## [1.2.0] - 2024-03-18
### Geändert
- IP-Subnetz-Prüfung korrigiert (CIDR-Notation wird jetzt korrekt ausgewertet)
- Debug-Logging für IP-Adressen hinzugefügt
- Beispiel-Umgebungsvariablen (.env.example) hinzugefügt
### Hinzugefügt
- IP-basierte Zugriffssteuerung für medisoftware-Links
- IP-basierte Zugriffssteuerung für Telefonnummern-Links
- Google Maps Integration für Adressen
- Version im Footer angezeigt
### Verbessert
- Verbesserte Fehlerbehandlung bei der IP-Überprüfung
- Bessere Dokumentation der Konfigurationsmöglichkeiten
## [v1.1.0] - 2024-03-17
### Hinzugefügt
- Benutzer-Login für nicht-autorisierte IPs
- Verbesserte Darstellung der Telefonnummern
- Responsive Design für mobile Geräte
### Verbessert
- Optimierte Suchfunktion
- Verbesserte Benutzeroberfläche - Verbesserte Benutzeroberfläche
## [1.0.2] - 2024-03-17 ## [v1.0.0] - 2024-03-17
### Geändert
- Entfernung der Wetterinformationen
- Optimierung der Suchfunktion
## [1.0.1] - 2024-03-17
### Hinzugefügt ### Hinzugefügt
- Wetterinformationen für Kundensitz - Erste Version mit grundlegenden Suchfunktionen
- Caching für Wetterdaten - Echtzeit-Suche über Kundendaten
- Hervorhebung von Suchbegriffen in den Ergebnissen
## [1.0.0] - 2024-03-17 - Klickbare Links für Telefonnummern, E-Mail-Adressen und Adressen
### Hinzugefügt - Docker-Container für einfache Installation und Deployment
- Erste Version der Kundensuche
- Grundlegende Suchfunktionen
- Responsive Design

View File

@@ -10,8 +10,12 @@ COPY requirements.txt .
# Installiere Abhängigkeiten # Installiere Abhängigkeiten
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
# Kopiere den Anwendungscode # Erstelle das data Verzeichnis und setze Berechtigungen
COPY . . RUN mkdir -p /app/data && \
chmod 755 /app/data
# Kopiere die Anwendungsdateien
COPY . /app/
# Exponiere Port 5000 # Exponiere Port 5000
EXPOSE 5000 EXPOSE 5000

View File

@@ -1,54 +1,67 @@
# medisoftware Kundensuche # medisoftware Kundensuche
Eine webbasierte Kundensuche für medisoftware mit erweiterten Suchfunktionen. Eine einfache und effiziente Kundensuche für medisoftware Kunden.
## Features ## Features
- Schnelle und präzise Kundensuche - 🔍 Echtzeit-Suche über alle Kundendaten
- Mehrere Suchfelder für gezielte Suche: - 📱 Responsive Design für alle Geräte
- Name (Vor- und Nachname) - 🔒 IP-basierte Zugriffskontrolle
- Ort - 🔗 Direkte Integration mit MEDISOFT
- Kundennummer - 🏥 Unterscheidung zwischen MEDISOFT und MEDICONSULT Kunden
- Fachrichtung - 🎨 Farbliche Hervorhebung der Kundentypen (blau für MEDISOFT, orange für MEDICONSULT)
- Telefon - 📍 Verbesserte Adress-Links mit Location- und Route-Icons
- Allgemeine Suche über alle Felder
- Kombinierte Suche über mehrere Felder ## Version
- Hervorhebung der Suchbegriffe in den Ergebnissen
- Direkte Links zu: Aktuelle Version: 1.2.16
- medisoftware Kundenkartei (Kundennummer)
- Google Maps (Adresse)
- Telefon (Klick zum Anrufen)
- E-Mail (Klick zum Mailen)
- Responsive Design für alle Geräte
- Automatische Aktualisierung der Ergebnisse
- Leere Ergebnisliste bei leeren Suchfeldern
## Installation ## Installation
1. Repository klonen: 1. Klonen Sie das Repository:
```bash ```bash
git clone https://gitea.elpatron.me/elpatron/medi-customers.git git clone https://gitea.elpatron.me/elpatron/medi-customers.git
cd medi-customers cd medi-customers
``` ```
2. Docker Container starten: 2. Erstellen Sie die erforderlichen Verzeichnisse:
```bash ```bash
docker-compose up -d mkdir -p data
``` ```
3. Die Anwendung ist unter `http://localhost:5001` erreichbar. 3. Starten Sie die Anwendung mit Docker Compose:
```bash
docker-compose up --build
```
Die Anwendung ist dann unter `http://localhost:5000` erreichbar.
## Entwicklung ## Entwicklung
- Python 3.11 Die Anwendung ist in Python mit Flask entwickelt und verwendet SQLite als Datenbank. Das Frontend wurde mit HTML, CSS und JavaScript implementiert.
- Flask
- Docker
- Bootstrap 5
- Font Awesome
## Lizenz ## Lizenz
Alle Rechte vorbehalten. © 2025 medisoftware Alle Rechte vorbehalten. © 2024 medisoftware
## Konfiguration
Die Anwendung kann über folgende Umgebungsvariablen konfiguriert werden:
- `FLASK_APP`: Die Hauptanwendungsdatei (Standard: app.py)
- `FLASK_ENV`: Die Flask-Umgebung (development/production)
- `SECRET_KEY`: Der geheime Schlüssel für Flask-Sessions
- `DATABASE_URL`: Die URL zur SQLite-Datenbank
- `STATIC_PASSWORD`: Das Passwort für die Login-Seite
- `ALLOWED_IP_RANGES`: Komma-getrennte Liste von IP-Bereichen, die direkten Zugriff haben
- `LOG_LEVEL`: Das Logging-Level (INFO/DEBUG)
## IP-Bereiche
Die Anwendung unterstützt CIDR-Notation für IP-Bereiche. Beispiele:
- Einzelne IP: 192.168.1.1/32
- Subnetz: 192.168.1.0/24
- Größeres Netzwerk: 10.0.0.0/8
## API-Beispiele ## API-Beispiele
@@ -96,4 +109,21 @@ curl "http://localhost:5001/search?fachrichtung=Zahnarzt&ort=Berlin&name=Schmidt
## Version ## Version
Aktuelle Version: [1.0.5](CHANGELOG.md#105---2024-03-17) Aktuelle Version: [v1.2.4](CHANGELOG.md#v124---2024-03-19)
## Code-Statistiken
Language|files|blank|comment|code
:-------|-------:|-------:|-------:|-------:
HTML|2|56|0|416
CSS|2|51|1|265
Markdown|2|66|0|236
Python|1|51|103|225
YAML|1|0|0|13
Dockerfile|1|8|9|11
Text|1|0|0|5
--------|--------|--------|--------|--------
SUM:|10|232|113|1171
## Lizenz
Alle Rechte vorbehalten. © 2025 medisoftware

467
app.py
View File

@@ -1,122 +1,425 @@
from flask import Flask, render_template, request, jsonify, url_for from flask import Flask, render_template, request, jsonify, url_for, redirect, session
import pandas as pd import pandas as pd
import os import os
import logging import logging
import numpy as np import numpy as np
from datetime import datetime, timedelta from datetime import datetime, timedelta
from dotenv import load_dotenv from dotenv import load_dotenv
import requests import sqlite3
from collections import defaultdict from functools import wraps
from contextlib import contextmanager
import time
import threading
app = Flask(__name__, static_folder='static') app = Flask(__name__, static_folder='static')
logging.basicConfig(level=logging.DEBUG) app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY', 'dev')
app.config['ALLOWED_IP_RANGES'] = os.getenv('ALLOWED_IP_RANGES', '192.168.0.0/16,10.0.0.0/8').split(',')
app.config['VERSION'] = '1.2.17'
app.config['DATABASE'] = 'data/customers.db'
app.config['DATABASE_TIMEOUT'] = 20
app.config['DATABASE_POOL_SIZE'] = 5
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Version der Anwendung # Thread-lokaler Speicher für Datenbankverbindungen
VERSION = "1.0.6" thread_local = threading.local()
# Pfad zur CSV-Datei def get_db_connection():
CSV_FILE = "data/customers.csv" """Erstellt eine neue Datenbankverbindung für den aktuellen Thread"""
if not hasattr(thread_local, "connection"):
thread_local.connection = sqlite3.connect(app.config['DATABASE'], timeout=app.config['DATABASE_TIMEOUT'])
thread_local.connection.row_factory = sqlite3.Row
return thread_local.connection
# Lade Umgebungsvariablen @contextmanager
load_dotenv() def get_db():
"""Context Manager für Datenbankverbindungen"""
def clean_dataframe(df): conn = get_db_connection()
"""Konvertiert NaN-Werte in None für JSON-Kompatibilität"""
return df.replace({np.nan: None})
# CSV-Datei laden
def load_data():
try: try:
logger.info("Versuche CSV-Datei zu laden...") yield conn
if not os.path.exists(CSV_FILE): except Exception:
logger.error(f"CSV-Datei '{CSV_FILE}' nicht gefunden!") conn.rollback()
return None raise
finally:
conn.commit()
# Lade CSV mit Komma als Trennzeichen def init_db():
df = pd.read_csv(CSV_FILE, sep=',', encoding='utf-8', quotechar='"') """Initialisiert die SQLite-Datenbank mit der notwendigen Tabelle."""
# Entferne Anführungszeichen aus den Spaltennamen with get_db() as conn:
df.columns = df.columns.str.strip('"') c = conn.cursor()
# Entferne Anführungszeichen aus den Werten
for col in df.columns: try:
if df[col].dtype == 'object': # Erstelle die Kunden-Tabelle
df[col] = df[col].str.strip('"') c.execute('''
df = clean_dataframe(df) CREATE TABLE IF NOT EXISTS customers (
logger.info(f"CSV-Datei erfolgreich geladen. {len(df)} Einträge gefunden.") id INTEGER PRIMARY KEY AUTOINCREMENT,
return df nummer TEXT,
name TEXT,
strasse TEXT,
plz TEXT,
ort TEXT,
telefon TEXT,
mobil TEXT,
email TEXT,
bemerkung TEXT,
fachrichtung TEXT,
tag TEXT,
handy TEXT,
tele_firma TEXT,
kontakt1 TEXT,
kontakt2 TEXT,
kontakt3 TEXT
)
''')
# Optimierte Indizes für die häufigsten Suchanfragen
c.execute('CREATE INDEX IF NOT EXISTS idx_customers_name_ort ON customers(name, ort)')
c.execute('CREATE INDEX IF NOT EXISTS idx_customers_fachrichtung ON customers(fachrichtung)')
c.execute('CREATE INDEX IF NOT EXISTS idx_customers_tag ON customers(tag)')
c.execute('CREATE INDEX IF NOT EXISTS idx_customers_plz ON customers(plz)')
# Zusammengesetzter Index für die häufigste Suchkombination
c.execute('CREATE INDEX IF NOT EXISTS idx_customers_search ON customers(name, ort, fachrichtung, tag)')
logger.info('Datenbank initialisiert')
except Exception as e: except Exception as e:
logger.error(f"Fehler beim Laden der CSV-Datei: {str(e)}") logger.error(f'Fehler bei der Datenbankinitialisierung: {str(e)}')
return None raise
def isIPInSubnet(ip, subnet):
"""Überprüft, ob eine IP-Adresse in einem Subnetz liegt."""
try:
# Teile die IP und das Subnetz in ihre Komponenten
subnet_ip, bits = subnet.split('/')
ip_parts = [int(x) for x in ip.split('.')]
subnet_parts = [int(x) for x in subnet_ip.split('.')]
# Konvertiere IPs in 32-bit Zahlen
ip_num = (ip_parts[0] << 24) | (ip_parts[1] << 16) | (ip_parts[2] << 8) | ip_parts[3]
subnet_num = (subnet_parts[0] << 24) | (subnet_parts[1] << 16) | (subnet_parts[2] << 8) | subnet_parts[3]
# Erstelle die Subnetzmaske
mask = ~((1 << (32 - int(bits))) - 1)
# Prüfe, ob die IP im Subnetz liegt
return (ip_num & mask) == (subnet_num & mask)
except Exception as e:
logger.error(f"Fehler bei der IP-Überprüfung: {str(e)}")
return False
def import_csv():
"""Importiert die CSV-Datei in die Datenbank"""
try:
with get_db() as conn:
c = conn.cursor()
# Lösche bestehende Daten
c.execute('DELETE FROM customers')
# Importiere MEDISOFT-Daten
if os.path.exists('data/customers.csv'):
logger.info("Importiere MEDISOFT-Daten...")
df = pd.read_csv('data/customers.csv', encoding='iso-8859-1')
df.columns = df.columns.str.strip().str.replace('"', '')
df = df.apply(lambda x: x.str.strip().str.replace('"', '') if x.dtype == "object" else x)
# Filtere Datensätze mit Fachrichtung "intern"
df = df[df['Fachrichtung'].str.lower() != 'intern']
# Bereite die Daten für den Batch-Insert vor
data = [(
row['VorNachname'], row['Nummer'], row['Strasse'], row['PLZ'], row['Ort'],
row['Tel'], row['Tel'], row['mail'], row['Fachrichtung'], 'medisoft',
row['Handy'], row['Tele Firma'], row['Kontakt1'], row['Kontakt2'], row['Kontakt3']
) for _, row in df.iterrows()]
# Führe Batch-Insert durch
c.executemany('''
INSERT INTO customers (
name, nummer, strasse, plz, ort, telefon, mobil, email,
fachrichtung, tag, handy, tele_firma, kontakt1, kontakt2, kontakt3
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
''', data)
else:
logger.warning("MEDISOFT CSV-Datei nicht gefunden")
# Importiere MEDICONSULT-Daten
if os.path.exists('data/customers_snk.csv'):
logger.info("Importiere MEDICONSULT-Daten...")
df_snk = pd.read_csv('data/customers_snk.csv', encoding='iso-8859-1')
df_snk.columns = df_snk.columns.str.strip().str.replace('"', '')
df_snk = df_snk.apply(lambda x: x.str.strip().str.replace('"', '') if x.dtype == "object" else x)
# Filtere Datensätze mit Fachrichtung "intern"
df_snk = df_snk[df_snk['Fachrichtung'].str.lower() != 'intern']
# Bereite die Daten für den Batch-Insert vor
data = [(
row['VorNachname'], row['Nummer'], row['Strasse'], row['PLZ'], row['Ort'],
row['Tel'], row['Tel'], row['mail'], row['Fachrichtung'], 'mediconsult',
row['Handy'], row['Tele Firma'], row['Kontakt1'], row['Kontakt2'], row['Kontakt3']
) for _, row in df_snk.iterrows()]
# Führe Batch-Insert durch
c.executemany('''
INSERT INTO customers (
name, nummer, strasse, plz, ort, telefon, mobil, email,
fachrichtung, tag, handy, tele_firma, kontakt1, kontakt2, kontakt3
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
''', data)
else:
logger.warning("MEDICONSULT CSV-Datei nicht gefunden")
logger.info("CSV-Daten erfolgreich in die Datenbank importiert")
except Exception as e:
logger.error(f"Fehler beim Importieren der CSV-Datei: {str(e)}")
raise
@app.route('/login', methods=['GET', 'POST'])
def login():
# Überprüfe, ob die Client-IP in einem der erlaubten Bereiche liegt
client_ip = request.headers.get('X-Forwarded-For', request.remote_addr)
# Überprüfe, ob die Client-IP in einem der erlaubten Bereichen liegt
is_allowed = any(isIPInSubnet(client_ip, range.strip()) for range in app.config['ALLOWED_IP_RANGES'] if range.strip())
if is_allowed:
logger.info(f"Client-IP {client_ip} ist in einem erlaubten Bereich, automatischer Login")
session['logged_in'] = True
return redirect(url_for('index'))
if request.method == 'POST':
password = request.form.get('password')
if password == os.environ.get('LOGIN_PASSWORD'):
session['logged_in'] = True
logger.info("Erfolgreicher Login")
return redirect(url_for('index'))
else:
logger.warning("Falsches Passwort eingegeben")
return render_template('login.html', error="Falsches Passwort")
logger.info("Zeige Login-Seite")
return render_template('login.html')
@app.route('/') @app.route('/')
def index(): def index():
return render_template('index.html') logger.info(f"Index-Route aufgerufen. Session Status: {session}")
if not session.get('logged_in'):
logger.info("Benutzer nicht eingeloggt, Weiterleitung zum Login")
return redirect(url_for('login'))
@app.route('/search') client_ip = request.headers.get('X-Forwarded-For', request.remote_addr)
logger.info(f"Client-IP: {client_ip}")
logger.info(f"Erlaubte IP-Bereiche: {app.config['ALLOWED_IP_RANGES']}")
return render_template('index.html', allowed_ip_ranges=','.join(app.config['ALLOWED_IP_RANGES']), version=app.config['VERSION'])
@app.route('/search', methods=['GET', 'POST'])
def search(): def search():
if not session.get('logged_in'):
return jsonify({'error': 'Nicht eingeloggt'}), 401
try: try:
# CSV-Datei laden if request.method == 'POST':
df = load_data() data = request.get_json()
if df is None: search_query = data.get('query', '')
return jsonify({"error": "Datenbank konnte nicht geladen werden"}), 500 tag = data.get('tag', 'medisoft')
else:
search_query = request.args.get('q', '')
name = request.args.get('name', '')
ort = request.args.get('ort', '')
nummer = request.args.get('nummer', '')
plz = request.args.get('plz', '')
fachrichtung = request.args.get('fachrichtung', '')
tag = request.args.get('tag', 'medisoft')
# Suchparameter aus der URL holen with get_db() as conn:
name = request.args.get('name', '').strip() c = conn.cursor()
ort = request.args.get('ort', '').strip()
kundennummer = request.args.get('kundennummer', '').strip()
fachrichtung = request.args.get('fachrichtung', '').strip()
telefon = request.args.get('telefon', '').strip()
query = request.args.get('q', '').strip()
# Initialisiere die Maske für die Filterung # Baue die SQL-Abfrage
mask = pd.Series(True, index=df.index) sql_query = '''
SELECT
nummer,
name,
strasse,
plz,
ort,
telefon,
mobil,
email,
fachrichtung,
tag,
handy,
tele_firma,
kontakt1,
kontakt2,
kontakt3
FROM customers
WHERE 1=1
'''
params = []
# Wenn eine allgemeine Suche angegeben ist # Füge die Suchbedingungen hinzu
if query: if search_query:
query_mask = ( # Optimierte Suche mit FTS (Full Text Search)
df['Vorname'].str.contains(query, case=False, na=False) | sql_query += """
df['Nachname'].str.contains(query, case=False, na=False) | AND (
df['Ort'].str.contains(query, case=False, na=False) | name LIKE ? OR
df['Nummer'].astype(str).str.contains(query, case=False, na=False) | nummer LIKE ? OR
df['Fachrichtung'].str.contains(query, case=False, na=False) | fachrichtung LIKE ? OR
df['Tel'].astype(str).str.contains(query, case=False, na=False) ort LIKE ? OR
plz LIKE ? OR
strasse LIKE ? OR
telefon LIKE ? OR
mobil LIKE ? OR
email LIKE ? OR
bemerkung LIKE ? OR
tag LIKE ? OR
handy LIKE ? OR
tele_firma LIKE ? OR
kontakt1 LIKE ? OR
kontakt2 LIKE ? OR
kontakt3 LIKE ?
) )
mask &= query_mask """
search_term = f"%{search_query}%"
params.extend([search_term] * 16) # 16 Felder für die allgemeine Suche
# Spezifische Suchkriterien anwenden
if name: if name:
name_mask = ( sql_query += " AND name LIKE ?"
df['Vorname'].str.contains(name, case=False, na=False) | params.append(f"%{name}%")
df['Nachname'].str.contains(name, case=False, na=False)
)
mask &= name_mask
if ort: if ort:
ort_mask = df['Ort'].str.contains(ort, case=False, na=False) sql_query += " AND ort LIKE ?"
mask &= ort_mask params.append(f"%{ort}%")
if kundennummer: if nummer:
kundennummer_mask = df['Nummer'].astype(str).str.contains(kundennummer, case=False, na=False) sql_query += " AND nummer LIKE ?"
mask &= kundennummer_mask params.append(f"%{nummer}%")
if plz:
sql_query += " AND plz LIKE ?"
params.append(f"%{plz}%")
if fachrichtung: if fachrichtung:
fachrichtung_mask = df['Fachrichtung'].str.contains(fachrichtung, case=False, na=False) sql_query += " AND fachrichtung LIKE ?"
mask &= fachrichtung_mask params.append(f"%{fachrichtung}%")
if telefon: # Filter nach Tag
telefon_mask = df['Tel'].astype(str).str.contains(telefon, case=False, na=False) if tag != 'all':
mask &= telefon_mask sql_query += " AND tag = ?"
params.append(tag)
results = df[mask].to_dict('records') # Füge LIMIT hinzu und optimiere die Sortierung
logger.info(f"{len(results)} Ergebnisse gefunden") sql_query += " ORDER BY name LIMIT 100"
# Führe die Abfrage aus
c.execute(sql_query, params)
results = c.fetchall()
formatted_results = []
for row in results:
customer = {
'nummer': row[0],
'name': row[1],
'strasse': row[2],
'plz': row[3],
'ort': row[4],
'telefon': row[5],
'mobil': row[6],
'email': row[7],
'fachrichtung': row[8],
'tag': row[9],
'handy': row[10],
'tele_firma': row[11],
'kontakt1': row[12],
'kontakt2': row[13],
'kontakt3': row[14]
}
formatted_results.append(customer)
return jsonify(formatted_results)
return jsonify({
'results': results,
'total': len(results)
})
except Exception as e: except Exception as e:
logger.error(f"Fehler bei der Suche: {str(e)}") logger.error(f"Fehler bei der Suche: {str(e)}")
return jsonify({"error": str(e)}), 500 return jsonify({'error': str(e)}), 500
@app.route('/api/fachrichtungen')
def get_fachrichtungen():
try:
search_term = request.args.get('q', '').lower()
with get_db() as conn:
c = conn.cursor()
# Hole alle eindeutigen Fachrichtungen, die mit dem Suchbegriff übereinstimmen
c.execute('''
SELECT DISTINCT fachrichtung
FROM customers
WHERE fachrichtung IS NOT NULL
AND fachrichtung != ''
AND LOWER(fachrichtung) LIKE ?
ORDER BY fachrichtung
LIMIT 10
''', (f'%{search_term}%',))
fachrichtungen = [row[0] for row in c.fetchall()]
return jsonify(fachrichtungen)
except Exception as e:
logger.error(f"Fehler beim Abrufen der Fachrichtungen: {str(e)}")
return jsonify([])
@app.route('/api/orte')
def get_orte():
try:
search_term = request.args.get('q', '').lower()
with get_db() as conn:
c = conn.cursor()
# Hole alle eindeutigen Orte, die mit dem Suchbegriff übereinstimmen
c.execute('''
SELECT DISTINCT ort
FROM customers
WHERE ort IS NOT NULL
AND ort != ''
AND LOWER(ort) LIKE ?
ORDER BY ort
LIMIT 10
''', (f'%{search_term}%',))
orte = [row[0] for row in c.fetchall()]
return jsonify(orte)
except Exception as e:
logger.error(f"Fehler beim Abrufen der Orte: {str(e)}")
return jsonify([])
def init_app(app):
"""Initialisiert die Anwendung mit allen notwendigen Einstellungen."""
with app.app_context():
try:
# Stelle sicher, dass der data-Ordner existiert
os.makedirs('data', exist_ok=True)
# Lösche die alte Datenbank, falls sie existiert
if os.path.exists(app.config['DATABASE']):
try:
os.remove(app.config['DATABASE'])
logger.info(f"Alte Datenbank {app.config['DATABASE']} wurde gelöscht")
except Exception as e:
logger.error(f"Fehler beim Löschen der alten Datenbank: {str(e)}")
# Initialisiere die Datenbank
init_db()
# Importiere die CSV-Daten
import_csv()
logger.info("Anwendung erfolgreich initialisiert")
except Exception as e:
logger.error(f"Fehler bei der Initialisierung: {str(e)}")
raise
# Initialisiere die App
init_app(app)
if __name__ == '__main__': if __name__ == '__main__':
app.run(debug=True, port=5001) app.run(debug=True, port=5001)

File diff suppressed because it is too large Load Diff

View File

@@ -1,14 +0,0 @@
version: '3.8'
services:
web:
build: .
ports:
- "5001:5000"
volumes:
- .:/app
environment:
- FLASK_APP=app.py
- FLASK_ENV=development
- FLASK_DEBUG=1
command: flask run --host=0.0.0.0

View File

@@ -0,0 +1,13 @@
services:
web:
build: .
ports:
- "5001:5000"
volumes:
- ./data:/app/data
environment:
- FLASK_APP=app.py
- FLASK_ENV=production
- LOGIN_PASSWORD=changeme
- ALLOWED_IP_RANGES=213.178.68.218/29,192.168.0.0/24,192.168.177.0/24
command: flask run --host=0.0.0.0

387
static/css/styles.css Normal file
View File

@@ -0,0 +1,387 @@
body {
min-height: 100vh;
display: flex;
flex-direction: column;
margin: 0;
padding: 0;
}
.main-content {
flex: 1 0 auto;
padding: 2rem 0;
margin-bottom: 4rem; /* Platz für die fixierte Fußzeile */
}
.search-container {
max-width: 800px;
margin: 0 auto;
}
.result-card {
margin-bottom: 1rem;
transition: all 0.3s ease;
}
.result-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
}
.loading {
display: none;
text-align: center;
margin: 2rem 0;
}
.phone-link, .email-link, .address-link, .customer-link {
text-decoration: none;
color: #0d6efd;
}
.phone-link:hover, .email-link:hover, .address-link:hover, .customer-link:hover {
text-decoration: underline;
}
.search-icon, .reset-icon {
position: absolute;
top: 50%;
transform: translateY(-50%);
color: #6c757d;
cursor: pointer;
display: none;
z-index: 10;
}
.reset-icon {
right: 10px;
}
.search-icon {
right: 35px;
}
.reset-icon.visible {
display: block;
}
.search-icon.visible {
display: block;
}
.customer-number {
color: #6c757d;
font-size: 0.9em;
}
.footer {
flex-shrink: 0;
text-align: center;
padding: 1rem;
background-color: #f8f9fa;
border-top: 1px solid #dee2e6;
width: 100%;
position: fixed;
bottom: 0;
left: 0;
z-index: 100;
}
.share-feedback {
position: fixed;
bottom: 20px;
right: 20px;
background: #28a745;
color: white;
padding: 8px 16px;
border-radius: 4px;
display: none;
animation: fadeOut 2s forwards;
z-index: 1000;
}
@keyframes fadeOut {
0% { opacity: 1; }
70% { opacity: 1; }
100% { opacity: 0; }
}
.card-actions {
display: flex;
justify-content: flex-end;
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid #e9ecef;
}
.share-button {
padding: 0.5rem 1rem;
border-radius: 20px;
font-size: 0.9rem;
background-color: #0d6efd;
color: white;
border: none;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
gap: 0.5rem;
}
.share-button:hover {
background-color: #0b5ed7;
transform: translateY(-1px);
}
.share-button i {
font-size: 1rem;
}
.search-fields {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
margin-bottom: 1rem;
}
.search-field {
position: relative;
}
.input-group {
position: relative;
}
.reset-icon {
position: absolute;
right: 40px;
top: 50%;
transform: translateY(-50%);
cursor: pointer;
color: #6c757d;
z-index: 10;
padding: 0.375rem;
display: none;
}
.input-group input:not(:placeholder-shown) + .reset-icon {
display: block;
}
.reset-icon:hover {
color: #dc3545;
}
.search-icon {
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
color: #6c757d;
z-index: 10;
padding: 0.375rem;
}
.result-counts {
display: flex;
justify-content: center;
margin-bottom: 1rem;
padding: 0.5rem;
background-color: #f8f9fa;
border-radius: 4px;
}
.result-count {
background-color: #e9ecef;
padding: 4px 12px;
border-radius: 12px;
font-size: 0.9em;
color: #6c757d;
display: none;
}
.result-count.visible {
display: inline-block;
}
.location-pin {
color: #dc3545;
margin-left: 4px;
font-size: 1.2em;
}
.customer-card {
background: white;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
margin-bottom: 1rem;
padding: 1rem;
}
.customer-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 0.5rem;
}
.customer-name {
margin: 0;
font-size: 1.2rem;
color: #333;
}
.customer-actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
.customer-details {
font-size: 0.9rem;
color: #666;
}
.customer-details p {
margin: 0.25rem 0;
}
.customer-details strong {
color: #333;
}
.phone-link, .email-link, .customer-link {
color: #007bff;
text-decoration: none;
}
.phone-link:hover, .email-link:hover, .customer-link:hover {
text-decoration: underline;
}
.address-text {
margin-right: 0.5rem;
}
.address-link, .route-link {
color: #6c757d;
text-decoration: none;
margin-left: 0.5rem;
}
.address-link:hover, .route-link:hover {
color: #343a40;
}
.location-pin, .route-pin {
font-size: 0.9rem;
}
.badge {
font-size: 0.8rem;
padding: 0.35em 0.65em;
}
.btn-sm {
padding: 0.25rem 0.5rem;
font-size: 0.875rem;
}
.footer-content {
padding: 1rem;
background-color: #f8f9fa;
border-top: 1px solid #dee2e6;
width: 100%;
position: fixed;
bottom: 0;
left: 0;
z-index: 100;
}
.footer-link {
color: #0d6efd;
text-decoration: none;
}
.footer-link:hover {
text-decoration: underline;
}
.general-search {
max-width: 800px;
margin: 0 auto;
}
.general-search .input-group {
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.general-search .form-control {
height: 3.5rem;
font-size: 1.2rem;
padding: 0.75rem 1rem;
}
.general-search .search-icon,
.general-search .reset-icon {
font-size: 1.2rem;
padding: 0 1rem;
}
.search-options {
font-size: 0.9em;
color: #666;
}
.search-options .form-check {
margin-right: 1rem;
}
.search-options .form-check-input {
cursor: pointer;
}
.search-options .form-check-label {
cursor: pointer;
user-select: none;
}
.result-tag {
padding: 4px 8px;
border-radius: 4px;
font-size: 0.9em;
font-weight: 500;
text-transform: uppercase;
color: white;
}
.tag-medisoft {
background-color: #1976d2;
}
.tag-mediconsult {
background-color: #ff9800;
}
.autocomplete-items {
position: absolute;
border: 1px solid #d4d4d4;
border-top: none;
z-index: 99;
top: 100%;
left: 0;
right: 0;
background-color: white;
max-height: 200px;
overflow-y: auto;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.autocomplete-items div {
padding: 8px 12px;
cursor: pointer;
background-color: white;
}
.autocomplete-items div:hover {
background-color: #f8f9fa;
}

515
static/js/main.js Normal file
View File

@@ -0,0 +1,515 @@
let searchTimeout;
let lastResults = [];
let fachrichtungTimeout;
let ortTimeout;
let currentPage = 1;
let totalPages = 1;
let currentResults = [];
let currentSearchQuery = '';
let currentFilters = {
fachrichtung: '',
plz: '',
ort: ''
};
function createPhoneLink(phone) {
if (!phone) return '';
const clientIP = document.querySelector('meta[name="client-ip"]').content;
const allowedIPRanges = document.querySelector('meta[name="allowed-ip-ranges"]').content.split(',');
// Überprüfen, ob die Client-IP in einem der erlaubten Bereiche liegt
const isAllowed = allowedIPRanges.some(range => isIPInSubnet(clientIP, range.trim()));
// Entferne alle nicht-numerischen Zeichen
let cleanNumber = phone.replace(/\D/g, '');
// Formatiere die Nummer
let formattedNumber = cleanNumber;
if (cleanNumber.length === 11) {
formattedNumber = cleanNumber.replace(/(\d{4})(\d{7})/, '$1-$2');
} else if (cleanNumber.length === 10) {
formattedNumber = cleanNumber.replace(/(\d{3})(\d{7})/, '$1-$2');
}
// Erstelle den Link
return `<a href="tel:${cleanNumber}" class="phone-link">${formattedNumber}</a>`;
}
function createEmailLink(email) {
if (!email) return '';
return `<a href="mailto:${email}" class="email-link">${email}</a>`;
}
function highlightText(text, searchTerm) {
if (!searchTerm || !text) return text;
// Teile den Suchbegriff in einzelne Wörter
const searchWords = searchTerm.split(/\s+/).filter(word => word.length > 0);
// Wenn keine Wörter gefunden wurden, gebe den ursprünglichen Text zurück
if (searchWords.length === 0) return text;
// Erstelle einen regulären Ausdruck für alle Suchwörter
const regexPattern = searchWords
.map(word => word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
.join('|');
// Erstelle den regulären Ausdruck
const regex = new RegExp(`(${regexPattern})`, 'gi');
// Ersetze alle Übereinstimmungen mit mark-Tags
return text.replace(regex, '<mark>$1</mark>');
}
function createAddressLink(street, plz, city) {
if (!street || !plz || !city) return '';
const address = `${street}, ${plz} ${city}`;
const searchQuery = encodeURIComponent(address);
const routeQuery = encodeURIComponent(address);
return `<span class="address-text">${address}</span>
<a href="https://www.google.com/maps/search/?api=1&query=${searchQuery}"
class="address-link" target="_blank" rel="noopener noreferrer">
<i class="fa-solid fa-location-dot location-pin"></i>
</a>
<a href="https://www.google.com/maps/dir/?api=1&destination=${routeQuery}"
class="route-link" target="_blank" rel="noopener noreferrer">
<i class="fa-solid fa-route route-pin"></i>
</a>`;
}
function adjustCustomerNumber(number) {
return number - 12000;
}
function isIPInSubnet(ip, subnet) {
// Teile die IP und das Subnetz in ihre Komponenten
const [subnetIP, bits] = subnet.split('/');
const ipParts = ip.split('.').map(Number);
const subnetParts = subnetIP.split('.').map(Number);
// Konvertiere IPs in 32-bit Zahlen
const ipNum = (ipParts[0] << 24) | (ipParts[1] << 16) | (ipParts[2] << 8) | ipParts[3];
const subnetNum = (subnetParts[0] << 24) | (subnetParts[1] << 16) | (subnetParts[2] << 8) | subnetParts[3];
// Erstelle die Subnetzmaske
const mask = ~((1 << (32 - bits)) - 1);
// Prüfe, ob die IP im Subnetz liegt
return (ipNum & mask) === (subnetNum & mask);
}
function createCustomerLink(nummer) {
const clientIP = document.querySelector('meta[name="client-ip"]').content;
const allowedIPRanges = document.querySelector('meta[name="allowed-ip-ranges"]').content.split(',');
// Überprüfe, ob die Client-IP in einem der erlaubten Bereiche liegt
const isAllowed = allowedIPRanges.some(range => {
const trimmedRange = range.trim();
return isIPInSubnet(clientIP, trimmedRange);
});
if (isAllowed) {
const adjustedNumber = adjustCustomerNumber(nummer);
return `<a href="medisw:openkkbefe/P${adjustedNumber}?NetGrp=4" class="customer-link">${nummer}</a>`;
} else {
return nummer;
}
}
function showCopyFeedback() {
const feedback = document.getElementById('shareFeedback');
feedback.style.display = 'block';
feedback.style.opacity = '1';
feedback.addEventListener('animationend', () => {
feedback.style.display = 'none';
}, { once: true });
}
async function copyCustomerLink(customerNumber) {
const url = new URL(window.location.href);
url.searchParams.set('kundennummer', customerNumber);
try {
await navigator.clipboard.writeText(url.toString());
showCopyFeedback();
} catch (err) {
// Fehlerbehandlung ohne console.log
}
}
function updateResultCounts() {
const resultCount = document.getElementById('result-count');
const exportButton = document.getElementById('exportButton');
if (lastResults && lastResults.length > 0) {
resultCount.textContent = `${lastResults.length} Ergebnisse gefunden`;
resultCount.style.display = 'inline';
exportButton.style.display = 'inline-block';
} else {
resultCount.textContent = '';
resultCount.style.display = 'none';
exportButton.style.display = 'none';
}
}
function exportToCSV() {
if (!lastResults || lastResults.length === 0) return;
// CSV-Header definieren
const headers = [
'Nummer',
'Name',
'Fachrichtung',
'Straße',
'PLZ',
'Ort',
'Telefon',
'Mobil',
'Handy',
'Telefon Firma',
'E-Mail',
'Kontakt 1',
'Kontakt 2',
'Kontakt 3',
'Tags'
];
// CSV-Daten erstellen
const csvRows = [headers];
lastResults.forEach(customer => {
const row = [
customer.nummer,
customer.name,
customer.fachrichtung,
customer.strasse,
customer.plz,
customer.ort,
customer.telefon,
customer.mobil,
customer.handy,
customer.tele_firma,
customer.email,
customer.kontakt1,
customer.kontakt2,
customer.kontakt3,
(customer.tags || []).join(';')
].map(value => {
// Werte mit Kommas oder Anführungszeichen in Anführungszeichen setzen
if (value && (value.includes(',') || value.includes('"') || value.includes('\n'))) {
return `"${value.replace(/"/g, '""')}"`;
}
return value || '';
});
csvRows.push(row);
});
// CSV-String erstellen
const csvContent = csvRows.map(row => row.join(',')).join('\n');
// Blob erstellen und Download starten
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
const url = URL.createObjectURL(blob);
link.setAttribute('href', url);
link.setAttribute('download', `kundensuche_${new Date().toISOString().split('T')[0]}.csv`);
link.style.visibility = 'hidden';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
function exportToVCF(customer) {
if (!customer) return;
const vcfData = [
'BEGIN:VCARD',
'VERSION:3.0',
`FN:${customer.name || ''}`,
`N:${customer.name || ''};;;`,
`TEL;TYPE=CELL:${customer.telefon || ''}`,
`TEL;TYPE=HOME:${customer.mobil || ''}`,
`EMAIL:${customer.email || ''}`,
`ADR;TYPE=HOME:;;${customer.strasse || ''};${customer.plz || ''};${customer.ort || ''};`,
`ORG:${customer.fachrichtung || ''}`,
'END:VCARD'
].join('\n');
const blob = new Blob([vcfData], { type: 'text/vcard;charset=utf-8' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `kontakt_${customer.name || ''}_${new Date().toISOString().split('T')[0]}.vcf`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
}
function displayResults(results) {
const resultsDiv = document.getElementById('results');
const resultCount = document.getElementById('result-count');
const generalSearchTerm = document.getElementById('q').value;
const nameSearchTerm = document.getElementById('nameInput').value;
const fachrichtungSearchTerm = document.getElementById('fachrichtungInput').value;
if (!results || results.length === 0) {
resultsDiv.innerHTML = '<p>Keine Ergebnisse gefunden.</p>';
resultCount.textContent = '';
return;
}
resultCount.textContent = `${results.length} Ergebnisse`;
lastResults = results;
const resultsHTML = results.map(customer => {
// Hilfsfunktion zum Erstellen von Feldern nur wenn sie Werte haben
const createFieldIfValue = (label, value, formatter = (v) => v) => {
if (!value || value === 'N/A' || value === 'n/a' || value === 'N/a' || (typeof value === 'string' && value.trim() === '')) return '';
const formattedValue = formatter(value);
return `<p class="mb-1"><strong>${label}:</strong> ${formattedValue}</p>`;
};
// Highlighting für alle Felder
const highlightField = (value) => {
if (!value) return value;
let highlighted = value;
if (nameSearchTerm) {
highlighted = highlightText(highlighted, nameSearchTerm);
}
if (fachrichtungSearchTerm) {
highlighted = highlightText(highlighted, fachrichtungSearchTerm);
}
if (generalSearchTerm) {
highlighted = highlightText(highlighted, generalSearchTerm);
}
return highlighted;
};
return `
<div class="customer-card">
<div class="customer-header">
<h3 class="customer-name">${highlightField(customer.name)}</h3>
<div class="customer-actions">
<span class="badge ${(customer.tag || 'medisoft') === 'medisoft' ? 'bg-primary' : 'bg-warning text-dark'}">${(customer.tag || 'medisoft').toUpperCase()}</span>
<button class="btn btn-sm btn-outline-primary" onclick="copyCustomerLink('${customer.nummer}')" title="Link kopieren">
<i class="fas fa-link"></i>
</button>
<button class="btn btn-sm btn-outline-primary" onclick='exportToVCF(${JSON.stringify(customer).replace(/'/g, "\\'")})' title="Als VCF exportieren">
<i class="bi bi-person-vcard"></i>
</button>
</div>
</div>
<div class="customer-details">
${createFieldIfValue('Nummer', highlightField(customer.nummer), createCustomerLink)}
${createFieldIfValue('Adresse', (customer.strasse && customer.plz && customer.ort) ? true : false,
() => createAddressLink(
customer.strasse,
highlightField(customer.plz),
highlightField(customer.ort)
))}
${createFieldIfValue('Telefon', highlightField(customer.telefon), createPhoneLink)}
${createFieldIfValue('Mobil', highlightField(customer.mobil), createPhoneLink)}
${createFieldIfValue('Handy', highlightField(customer.handy), createPhoneLink)}
${createFieldIfValue('E-Mail', highlightField(customer.email), createEmailLink)}
${createFieldIfValue('Fachrichtung', highlightField(customer.fachrichtung))}
${createFieldIfValue('Kontakt 1', highlightField(customer.kontakt1), createPhoneLink)}
${createFieldIfValue('Kontakt 2', highlightField(customer.kontakt2), createPhoneLink)}
${createFieldIfValue('Kontakt 3', highlightField(customer.kontakt3), createPhoneLink)}
${customer.tags && customer.tags.length > 0 ? `
<p class="mb-0"><strong>Tags:</strong>
${customer.tags.map(tag => `<span class="badge bg-primary me-1">${tag}</span>`).join('')}
</p>
` : ''}
</div>
</div>
`;
}).join('');
resultsDiv.innerHTML = resultsHTML;
updateResultCounts();
}
function clearInput(inputId) {
document.getElementById(inputId).value = '';
document.getElementById('results').innerHTML = '';
document.getElementById('result-count').textContent = '';
document.getElementById('exportButton').style.display = 'none';
lastResults = [];
}
async function searchCustomers() {
let searchTimeout;
const loading = document.getElementById('loading');
const results = document.getElementById('results');
const generalSearch = document.getElementById('q').value;
const nameSearch = document.getElementById('nameInput').value;
const ortSearch = document.getElementById('ortInput').value;
const nummerSearch = document.getElementById('nummerInput').value;
const plzSearch = document.getElementById('plzInput').value;
const fachrichtungSearch = document.getElementById('fachrichtungInput').value;
const tagFilter = document.getElementById('tagFilter').value;
currentSearchQuery = generalSearch;
currentPage = 1;
// Zeige Ladeanimation
loading.style.display = 'block';
results.innerHTML = '';
// Setze Timeout zurück
clearTimeout(searchTimeout);
// Verzögerte Suche
searchTimeout = setTimeout(async () => {
try {
// Baue die Suchanfrage
const params = new URLSearchParams();
if (generalSearch) params.append('q', generalSearch);
if (nameSearch) params.append('name', nameSearch);
if (ortSearch) params.append('ort', ortSearch);
if (nummerSearch) params.append('nummer', nummerSearch);
if (plzSearch) params.append('plz', plzSearch);
if (fachrichtungSearch) params.append('fachrichtung', fachrichtungSearch);
if (tagFilter) params.append('tag', tagFilter);
const response = await fetch('/search?' + params.toString());
if (!response.ok) {
throw new Error('Netzwerkantwort war nicht ok');
}
const data = await response.json();
displayResults(data);
} catch (error) {
results.innerHTML = '<p>Ein Fehler ist aufgetreten. Bitte versuchen Sie es später erneut.</p>';
} finally {
loading.style.display = 'none';
}
}, 300);
}
function setupFachrichtungAutocomplete() {
const fachrichtungInput = document.getElementById('fachrichtungInput');
const autocompleteList = document.createElement('div');
autocompleteList.className = 'autocomplete-items';
fachrichtungInput.parentNode.appendChild(autocompleteList);
fachrichtungInput.addEventListener('input', function() {
clearTimeout(fachrichtungTimeout);
const searchTerm = this.value;
if (searchTerm.length < 2) {
autocompleteList.style.display = 'none';
return;
}
fachrichtungTimeout = setTimeout(() => {
fetch(`/api/fachrichtungen?q=${encodeURIComponent(searchTerm)}`)
.then(response => response.json())
.then(data => {
autocompleteList.innerHTML = '';
if (data.length > 0) {
data.forEach(item => {
const div = document.createElement('div');
div.textContent = item;
div.addEventListener('click', () => {
fachrichtungInput.value = item;
autocompleteList.style.display = 'none';
searchCustomers();
});
autocompleteList.appendChild(div);
});
autocompleteList.style.display = 'block';
} else {
autocompleteList.style.display = 'none';
}
});
}, 300);
});
document.addEventListener('click', function(e) {
if (!fachrichtungInput.contains(e.target) && !autocompleteList.contains(e.target)) {
autocompleteList.style.display = 'none';
}
});
}
function setupOrtAutocomplete() {
const ortInput = document.getElementById('ortInput');
const autocompleteList = document.createElement('div');
autocompleteList.className = 'autocomplete-items';
ortInput.parentNode.appendChild(autocompleteList);
ortInput.addEventListener('input', function() {
clearTimeout(ortTimeout);
const searchTerm = this.value;
if (searchTerm.length < 2) {
autocompleteList.style.display = 'none';
return;
}
ortTimeout = setTimeout(() => {
fetch(`/api/orte?q=${encodeURIComponent(searchTerm)}`)
.then(response => response.json())
.then(data => {
autocompleteList.innerHTML = '';
if (data.length > 0) {
data.forEach(item => {
const div = document.createElement('div');
div.textContent = item;
div.addEventListener('click', () => {
ortInput.value = item;
autocompleteList.style.display = 'none';
searchCustomers();
});
autocompleteList.appendChild(div);
});
autocompleteList.style.display = 'block';
} else {
autocompleteList.style.display = 'none';
}
});
}, 300);
});
document.addEventListener('click', function(e) {
if (!ortInput.contains(e.target) && !autocompleteList.contains(e.target)) {
autocompleteList.style.display = 'none';
}
});
}
// Event-Listener für die URL-Parameter und Autocomplete-Setup
document.addEventListener('DOMContentLoaded', function() {
const urlParams = new URLSearchParams(window.location.search);
const kundennummer = urlParams.get('kundennummer');
const name = urlParams.get('name');
const ort = urlParams.get('ort');
const plz = urlParams.get('plz');
if (kundennummer) {
document.getElementById('nummerInput').value = kundennummer;
searchCustomers();
}
if (name) {
document.getElementById('nameInput').value = name;
searchCustomers();
}
if (ort) {
document.getElementById('ortInput').value = ort;
searchCustomers();
}
if (plz) {
document.getElementById('plzInput').value = plz;
searchCustomers();
}
// Setup Autocomplete
setupFachrichtungAutocomplete();
setupOrtAutocomplete();
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

View File

@@ -3,213 +3,89 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="client-ip" content="{{ request.headers.get('X-Forwarded-For', request.remote_addr) }}">
<meta name="allowed-ip-ranges" content="{{ allowed_ip_ranges }}">
<title>medisoftware Kundensuche</title> <title>medisoftware Kundensuche</title>
<link rel="icon" type="image/x-icon" href="{{ url_for('static', filename='favicon.ico') }}"> <link rel="icon" type="image/x-icon" href="{{ url_for('static', filename='favicon.ico') }}">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet"> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" rel="stylesheet"> <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" rel="stylesheet">
<style> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
body { <link href="{{ url_for('static', filename='css/styles.css') }}" rel="stylesheet">
min-height: 100vh;
display: flex;
flex-direction: column;
margin: 0;
padding: 0;
}
.main-content {
flex: 1 0 auto;
padding: 2rem 0;
margin-bottom: 4rem; /* Platz für die fixierte Fußzeile */
}
.search-container {
max-width: 800px;
margin: 0 auto;
}
.result-card {
margin-bottom: 1rem;
transition: all 0.3s ease;
}
.result-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
}
.loading {
display: none;
text-align: center;
margin: 2rem 0;
}
.phone-link, .email-link, .address-link, .customer-link {
text-decoration: none;
color: #0d6efd;
}
.phone-link:hover, .email-link:hover, .address-link:hover, .customer-link:hover {
text-decoration: underline;
}
.search-icon, .reset-icon {
position: absolute;
top: 50%;
transform: translateY(-50%);
color: #6c757d;
cursor: pointer;
display: none;
z-index: 10;
}
.reset-icon {
right: 10px;
}
.search-icon {
right: 35px;
}
.reset-icon.visible {
display: block;
}
.search-icon.visible {
display: block;
}
.customer-number {
color: #6c757d;
font-size: 0.9em;
}
.footer {
flex-shrink: 0;
text-align: center;
padding: 1rem;
background-color: #f8f9fa;
border-top: 1px solid #dee2e6;
width: 100%;
position: fixed;
bottom: 0;
left: 0;
z-index: 100;
}
.share-feedback {
position: fixed;
bottom: 20px;
right: 20px;
background: #28a745;
color: white;
padding: 8px 16px;
border-radius: 4px;
display: none;
animation: fadeOut 2s forwards;
z-index: 1000;
}
@keyframes fadeOut {
0% { opacity: 1; }
70% { opacity: 1; }
100% { opacity: 0; }
}
.card-actions {
display: flex;
justify-content: flex-end;
margin-top: 1rem;
}
.share-button {
padding: 5px 10px;
border-radius: 15px;
font-size: 0.9em;
background-color: #0d6efd;
color: white;
border: none;
cursor: pointer;
transition: background-color 0.2s;
}
.share-button:hover {
background-color: #0b5ed7;
}
.search-fields {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
margin-bottom: 1rem;
}
.search-field {
position: relative;
}
.input-group {
position: relative;
}
.result-counts {
display: flex;
justify-content: center;
margin-bottom: 1rem;
padding: 0.5rem;
background-color: #f8f9fa;
border-radius: 4px;
}
.result-count {
background-color: #e9ecef;
padding: 4px 12px;
border-radius: 12px;
font-size: 0.9em;
color: #6c757d;
display: none;
}
.result-count.visible {
display: inline-block;
}
.location-pin {
color: #dc3545;
margin-left: 4px;
font-size: 1.2em;
}
.customer-card {
background: white;
border-radius: 8px;
padding: 1.5rem;
margin-bottom: 1.5rem;
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
border-bottom: 1px solid #e9ecef;
}
.customer-card:last-child {
border-bottom: none;
}
.customer-info {
margin-bottom: 1rem;
}
</style>
</head> </head>
<body> <body>
<div class="main-content"> <div class="main-content">
<div class="container search-container"> <div class="container">
<h1 class="text-center mb-4">medisoftware Kundensuche</h1> <div class="text-center mb-4">
<a href="https://medisoftware.de" target="_blank" rel="noopener noreferrer"><img src="{{ url_for('static', filename='medisoftware_logo_rb_200.png') }}" alt="medisoftware Logo" class="img-fluid" style="max-width: 200px;"></a>
</div>
<div class="search-container">
<h1 class="text-center mb-4">Kundensuche</h1>
<div class="input-group mb-4 position-relative"> <div class="general-search mb-4">
<input type="text" id="searchInput" class="form-control form-control-lg" <div class="input-group">
placeholder="Allgemeine Suche..."> <input type="text" id="q" class="form-control form-control-lg" placeholder="Allgemeine Suche" oninput="searchCustomers()">
<i class="fa-solid fa-xmark reset-icon" id="searchReset"></i> <i class="fas fa-times reset-icon" onclick="clearInput('q')"></i>
<span class="search-icon">🔍</span> <i class="fas fa-search search-icon"></i>
</div>
</div> </div>
<div class="search-fields"> <div class="search-fields">
<div class="search-field"> <div class="search-field">
<input type="text" id="nameInput" class="form-control" <div class="input-group">
placeholder="Name..."> <input type="text" id="nameInput" class="form-control" placeholder="Name" oninput="searchCustomers()">
<i class="fa-solid fa-xmark reset-icon" id="nameReset"></i> <i class="fas fa-times reset-icon" onclick="clearInput('nameInput')"></i>
</div> <i class="fas fa-search search-icon"></i>
<div class="search-field">
<input type="text" id="ortInput" class="form-control"
placeholder="Ort...">
<i class="fa-solid fa-xmark reset-icon" id="ortReset"></i>
</div>
<div class="search-field">
<input type="text" id="kundennummerInput" class="form-control"
placeholder="Kundennummer...">
<i class="fa-solid fa-xmark reset-icon" id="kundennummerReset"></i>
</div>
<div class="search-field">
<input type="text" id="fachrichtungInput" class="form-control"
placeholder="Fachrichtung...">
<i class="fa-solid fa-xmark reset-icon" id="fachrichtungReset"></i>
</div>
<div class="search-field">
<input type="text" id="telefonInput" class="form-control"
placeholder="Telefon...">
<i class="fa-solid fa-xmark reset-icon" id="telefonReset"></i>
</div> </div>
</div> </div>
<div class="result-counts"> <div class="search-field">
<span id="generalCount" class="result-count"></span> <div class="input-group">
<input type="text" id="ortInput" class="form-control" placeholder="Ort" oninput="searchCustomers()">
<i class="fas fa-times reset-icon" onclick="clearInput('ortInput')"></i>
<i class="fas fa-search search-icon"></i>
</div>
</div>
<div class="search-field">
<div class="input-group">
<input type="text" id="nummerInput" class="form-control" placeholder="Kundennummer" oninput="searchCustomers()">
<i class="fas fa-times reset-icon" onclick="clearInput('nummerInput')"></i>
<i class="fas fa-search search-icon"></i>
</div>
</div>
<div class="search-field">
<div class="input-group">
<input type="text" id="plzInput" class="form-control" placeholder="PLZ" oninput="searchCustomers()">
<i class="fas fa-times reset-icon" onclick="clearInput('plzInput')"></i>
<i class="fas fa-search search-icon"></i>
</div>
</div>
<div class="search-field">
<div class="input-group">
<input type="text" id="fachrichtungInput" class="form-control" placeholder="Fachrichtung" oninput="searchCustomers()">
<i class="fas fa-times reset-icon" onclick="clearInput('fachrichtungInput')"></i>
<i class="fas fa-search search-icon"></i>
</div>
</div>
<div class="search-field">
<div class="input-group">
<select id="tagFilter" class="form-select" onchange="searchCustomers()">
<option value="medisoft" selected>MEDISOFT</option>
<option value="mediconsult">MEDICONSULT</option>
<option value="all">Alle</option>
</select>
</div>
</div>
</div>
<div id="result-counts" class="mt-2">
<span id="result-count"></span>
<button id="exportButton" class="btn btn-sm btn-outline-primary ms-2" onclick="exportToCSV()" style="display: none;">
<i class="bi bi-file-earmark-spreadsheet"></i> Als CSV exportieren
</button>
</div> </div>
<div id="loading" class="loading"> <div id="loading" class="loading">
@@ -218,308 +94,22 @@
</div> </div>
</div> </div>
<div id="results" class="mt-4"> <div id="results"></div>
<!-- Hier werden die Suchergebnisse angezeigt -->
</div> </div>
</div> </div>
</div> </div>
<div id="shareFeedback" class="share-feedback"> <div id="shareFeedback" class="share-feedback">
Link kopiert! Link in die Zwischenablage kopiert!
</div> </div>
<footer class="footer"> <footer class="footer">
<p class="mb-0">(c) 2025 <a href="https://medisoftware.de" target="_blank" rel="noopener noreferrer" class="text-decoration-none">medisoftware</a></p> <div class="footer-content">
Proudly made with ❤️ and 🍺 by <a href="https://www.medisoftware.de" target="_blank" class="footer-link">medisoftware</a>
<div style="font-size: 0.8em;">Version: {{ version }}</div>
</div>
</footer> </footer>
<script> <script src="{{ url_for('static', filename='js/main.js') }}"></script>
let searchTimeout;
let lastResults = [];
function createPhoneLink(phone) {
if (!phone) return 'N/A';
const cleaned = phone.replace(/[^\d+\s]/g, '');
const telLink = cleaned.startsWith('+') ? cleaned : '0' + cleaned.replace(/\s/g, '');
return `<a href="tel:${telLink}" class="phone-link">${phone}</a>`;
}
function createEmailLink(email) {
if (!email) return 'N/A';
return `<a href="mailto:${email}" class="email-link">${email}</a>`;
}
function highlightText(text, searchTerms) {
// Konvertiere text zu String und prüfe auf null/undefined
const textStr = String(text || '');
if (!textStr || !searchTerms || searchTerms.length === 0) return textStr;
// Escapen der Suchbegriffe für reguläre Ausdrücke
const escapedTerms = searchTerms.map(term =>
String(term || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
).filter(term => term.length > 0);
if (escapedTerms.length === 0) return textStr;
// Erstelle einen temporären div-Element
const tempDiv = document.createElement('div');
tempDiv.innerHTML = textStr;
// Funktion zum Hervorheben von Text
function highlightNode(node) {
if (node.nodeType === 3) { // Text node
const text = node.textContent;
let newText = text;
escapedTerms.forEach(term => {
const regex = new RegExp(`(${term})`, 'gi');
newText = newText.replace(regex, '<mark>$1</mark>');
});
if (newText !== text) {
const span = document.createElement('span');
span.innerHTML = newText;
node.parentNode.replaceChild(span, node);
}
} else if (node.nodeType === 1) { // Element node
// Überspringe mark-Tags und Links
if (node.tagName !== 'MARK' && node.tagName !== 'A') {
Array.from(node.childNodes).forEach(highlightNode);
}
}
}
highlightNode(tempDiv);
return tempDiv.innerHTML;
}
function createAddressLink(street, plz, city) {
if (!street || !plz || !city) return 'N/A';
const address = `${street}, ${plz} ${city}`;
const searchQuery = encodeURIComponent(address);
return `<span class="address-text">${address}</span>
<a href="https://www.google.com/maps/search/?api=1&query=${searchQuery}"
class="address-link" target="_blank" rel="noopener noreferrer">
<i class="fa-solid fa-location-pin location-pin"></i>
</a>`;
}
function createCustomerLink(customerNumber) {
if (!customerNumber) return 'N/A';
return `<a href="medisw:openkkbefe/P${customerNumber}?NetGrp=4" class="customer-link">${customerNumber}</a>`;
}
function showCopyFeedback() {
const feedback = document.getElementById('shareFeedback');
feedback.style.display = 'block';
feedback.style.opacity = '1';
feedback.addEventListener('animationend', () => {
feedback.style.display = 'none';
}, { once: true });
}
async function copyCustomerLink(customerNumber) {
const url = new URL(window.location.href);
url.searchParams.set('kundennummer', customerNumber);
try {
await navigator.clipboard.writeText(url.toString());
showCopyFeedback();
} catch (err) {
console.error('Fehler beim Kopieren:', err);
}
}
function updateResultCounts() {
// Nur Gesamtzahl anzeigen
const generalCount = lastResults.length;
document.getElementById('generalCount').textContent =
generalCount > 0 ? `${generalCount} Treffer gefunden` : '';
document.getElementById('generalCount').classList.toggle('visible', generalCount > 0);
}
function searchCustomers() {
const query = document.getElementById('searchInput').value.trim();
const fachrichtung = document.getElementById('fachrichtungInput').value.trim();
const ort = document.getElementById('ortInput').value.trim();
const name = document.getElementById('nameInput').value.trim();
const telefon = document.getElementById('telefonInput').value.trim();
const kundennummer = document.getElementById('kundennummerInput')?.value.trim() || '';
// Sammle alle nicht-leeren Suchbegriffe
const searchTerms = [query, fachrichtung, ort, name, telefon, kundennummer]
.filter(term => term && term.length > 0);
// Prüfe, ob alle Suchfelder leer sind
if (searchTerms.length === 0) {
const resultsDiv = document.getElementById('results');
const generalCount = document.getElementById('generalCount');
resultsDiv.innerHTML = '';
generalCount.textContent = '';
generalCount.classList.remove('visible');
return;
}
const resultsDiv = document.getElementById('results');
const loadingDiv = document.getElementById('loading');
loadingDiv.style.display = 'block';
resultsDiv.innerHTML = '';
const searchParams = new URLSearchParams();
if (query) searchParams.append('q', query);
if (fachrichtung) searchParams.append('fachrichtung', fachrichtung);
if (ort) searchParams.append('ort', ort);
if (name) searchParams.append('name', name);
if (telefon) searchParams.append('telefon', telefon);
if (kundennummer) searchParams.append('kundennummer', kundennummer);
fetch(`/search?${searchParams.toString()}`)
.then(response => response.json())
.then(data => {
resultsDiv.innerHTML = '';
// Prüfe, ob data ein Objekt mit results-Array ist
if (!data || !data.results || !Array.isArray(data.results)) {
console.error('Unerwartetes Datenformat:', data);
resultsDiv.innerHTML = '<div class="error">Unerwartetes Datenformat vom Server</div>';
return;
}
const results = data.results;
if (results.length === 0) {
resultsDiv.innerHTML = '<div class="no-results">Keine Ergebnisse gefunden</div>';
} else {
results.forEach(customer => {
const card = document.createElement('div');
card.className = 'customer-card';
// Debug-Ausgabe für die Kundendaten
console.log('Kundendaten:', customer);
console.log('Alle verfügbaren Felder:', Object.keys(customer));
console.log('Telefon-bezogene Felder:', {
Telefon: customer.Telefon,
Telefonnummer: customer.Telefonnummer,
telefon: customer.telefon,
telefonnummer: customer.telefonnummer,
phone: customer.phone,
'phone.number': customer.phone?.number
});
// Erstelle die Adresse mit Hervorhebung
const address = `${customer.Strasse || ''}, ${customer.PLZ || ''} ${customer.Ort || ''}`;
const addressLink = createAddressLink(customer.Strasse, customer.PLZ, customer.Ort);
const highlightedAddress = highlightText(addressLink, searchTerms);
// Erstelle die Kundennummer mit Hervorhebung
const highlightedNumber = highlightText(customer.Nummer, searchTerms);
const customerLink = createCustomerLink(customer.Nummer);
// Erstelle die Telefonnummern mit Hervorhebung
let phoneNumber = '';
let companyPhone = '';
let mobilePhone = '';
if (typeof customer === 'object') {
phoneNumber = customer.Tel || '';
companyPhone = customer['Tele Firma'] || '';
mobilePhone = customer.Handy || '';
}
const phoneLink = createPhoneLink(phoneNumber);
const companyPhoneLink = createPhoneLink(companyPhone);
const mobilePhoneLink = createPhoneLink(mobilePhone);
const highlightedPhone = highlightText(phoneLink, searchTerms);
const highlightedCompanyPhone = highlightText(companyPhoneLink, searchTerms);
const highlightedMobilePhone = highlightText(mobilePhoneLink, searchTerms);
card.innerHTML = `
<div class="customer-info">
<strong>Kundennummer:</strong> ${customerLink}<br>
<strong>Name:</strong> ${highlightText(`${customer.Vorname || ''} ${customer.Nachname || ''}`, searchTerms)}<br>
<strong>Fachrichtung:</strong> ${highlightText(customer.Fachrichtung || '', searchTerms)}<br>
<strong>Adresse:</strong> ${highlightedAddress}<br>
<strong>Telefon:</strong> ${highlightedPhone}<br>
<strong>Firma:</strong> ${highlightedCompanyPhone}<br>
<strong>Mobil:</strong> ${highlightedMobilePhone}
</div>
<button class="share-button" onclick="copyCustomerLink('${customer.Nummer}')">
<i class="fas fa-share-alt"></i> Teilen
</button>
`;
resultsDiv.appendChild(card);
});
}
// Aktualisiere die Anzahl der Treffer
const generalCount = document.getElementById('generalCount');
generalCount.textContent = results.length > 0 ? `${results.length} Treffer gefunden` : '';
generalCount.classList.toggle('visible', results.length > 0);
})
.catch(error => {
console.error('Fehler bei der Suche:', error);
resultsDiv.innerHTML = '<div class="error">Ein Fehler ist aufgetreten</div>';
})
.finally(() => {
loadingDiv.style.display = 'none';
});
}
// Event-Listener für die Live-Suche
const searchInputs = [
document.getElementById('nameInput'),
document.getElementById('ortInput'),
document.getElementById('kundennummerInput'),
document.getElementById('fachrichtungInput'),
document.getElementById('telefonInput'),
document.getElementById('searchInput')
];
const resetIcons = [
document.getElementById('nameReset'),
document.getElementById('ortReset'),
document.getElementById('kundennummerReset'),
document.getElementById('fachrichtungReset'),
document.getElementById('telefonReset'),
document.getElementById('searchReset')
];
searchInputs.forEach((input, index) => {
input.addEventListener('input', function() {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(searchCustomers, 300);
// Reset-Icon anzeigen/verstecken
resetIcons[index].classList.toggle('visible', this.value.length > 0);
});
// Reset-Funktionalität
resetIcons[index].addEventListener('click', function() {
searchInputs[index].value = '';
searchCustomers();
});
});
// URL-Parameter beim Laden der Seite prüfen
window.addEventListener('load', function() {
const urlParams = new URLSearchParams(window.location.search);
const name = urlParams.get('name');
const ort = urlParams.get('ort');
const kundennummer = urlParams.get('kundennummer');
const fachrichtung = urlParams.get('fachrichtung');
const telefon = urlParams.get('telefon');
const query = urlParams.get('q');
if (name) document.getElementById('nameInput').value = name;
if (ort) document.getElementById('ortInput').value = ort;
if (kundennummer) document.getElementById('kundennummerInput').value = kundennummer;
if (fachrichtung) document.getElementById('fachrichtungInput').value = fachrichtung;
if (telefon) document.getElementById('telefonInput').value = telefon;
if (query) document.getElementById('searchInput').value = query;
if (name || ort || kundennummer || fachrichtung || telefon || query) {
searchCustomers();
}
});
</script>
</body> </body>
</html> </html>

60
templates/login.html Normal file
View File

@@ -0,0 +1,60 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>medisoftware Kundensuche</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
body {
min-height: 100vh;
display: flex;
flex-direction: column;
margin: 0;
padding: 0;
}
.main-content {
flex: 1 0 auto;
padding: 2rem 0;
margin-bottom: 4rem; /* Platz für die fixierte Fußzeile */
}
.footer {
flex-shrink: 0;
text-align: center;
padding: 1rem;
background-color: #f8f9fa;
border-top: 1px solid #dee2e6;
width: 100%;
position: fixed;
bottom: 0;
left: 0;
z-index: 100;
}
</style>
</head>
<body>
<div class="container mt-5">
<div class="text-center mb-4">
<img src="{{ url_for('static', filename='medisoftware_logo_rb_200.png') }}" alt="medisoftware Logo" class="img-fluid" style="max-width: 200px;">
</div>
<div class="row justify-content-center">
<div class="col-md-4">
<h2 class="text-center">Login</h2>
<form method="POST" action="/login">
<div class="mb-3">
<label for="password" class="form-label">Passwort</label>
<input type="password" class="form-control" id="password" name="password" required>
</div>
<button type="submit" class="btn btn-primary w-100">Einloggen</button>
</form>
</div>
</div>
</div>
<footer class="footer">
<div class="footer-content">
Proudly made with ❤️ and 🍺 by <a href="https://www.medisoftware.de" target="_blank" class="footer-link">medisoftware</a>
<div style="font-size: 0.8em;">Version: v1.2.3</div>
</div>
</footer>
</body>
</html>