35 Commits

Author SHA1 Message Date
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
3f69ba6198 Merge pull request 'sophisticated-search' (#1) from sophisticated-search into main
Reviewed-on: #1
2025-03-18 00:24:21 +01:00
7e812eb835 Verbesserte Darstellung der Telefonnummern: Separate Felder für Telefon, Firma und Mobilfunk, Faxnummer entfernt 2025-03-18 00:19:16 +01:00
ece7f984f7 UI-Verbesserungen: - Größere Abstände zwischen Suchergebnissen - Dünne Trennlinien zwischen den Karten - Verbesserte visuelle Hierarchie 2025-03-17 23:41:40 +01:00
6cfc2e0162 Version 1.0.6: Kombinierte Suche über mehrere Felder 2025-03-17 22:49:27 +01:00
bbcd04fd21 Dokumentation: Verlinkung der Version mit dem Changelog 2025-03-17 22:24:44 +01:00
89a5152ada Dokumentation: Aktualisierung der Version in der README 2025-03-17 22:23:34 +01:00
e6f43ca10a Dokumentation: Aktualisierung der README und des Changelogs für Version 1.0.5 2025-03-17 22:15:01 +01:00
da3ef358d2 Dokumentation: Aktualisierung der README und des Changelogs für Version 1.0.5 2025-03-17 22:10:53 +01:00
d77ee8ef04 Version 1.0.5: Verbesserte Suchfunktion - Leere Ergebnisliste bei leeren Suchfeldern 2025-03-17 22:05:22 +01:00
cbc95c0d29 Version 1.0.4: Verbesserte Adressanzeige mit Location-Icon 2025-03-17 22:01:27 +01:00
3639344a13 Version 1.0.3: Verbesserte Adressanzeige mit Location-Icon 2025-03-17 22:00:58 +01:00
84ba72ab72 Frontend-Logik an neue API-Antwortstruktur angepasst 2025-03-17 21:29:37 +01:00
2f4671cbc4 CSV-Datei-Verarbeitung korrigiert: Komma als Trennzeichen 2025-03-17 21:27:50 +01:00
df87868ab5 CSV-Datei-Verarbeitung verbessert: Anführungszeichen entfernt 2025-03-17 21:26:06 +01:00
d1c4f6a1d0 Spaltennamen an CSV-Datei angepasst 2025-03-17 21:24:38 +01:00
88d33b1a30 CSV-Dateipfad korrigiert 2025-03-17 21:22:41 +01:00
0e9a1156e2 requests Modul hinzugefügt 2025-03-17 21:20:32 +01:00
cdf0bc31d9 Wetter-API Integration hinzugefügt 2025-03-17 21:18:55 +01:00
f1c2e9227e CHANGELOG.md hinzugefügt 2025-03-17 21:01:21 +01:00
a5383ccce8 README.md aktualisiert: Neue Features und Version 1.0.1 dokumentiert 2025-03-17 21:00:28 +01:00
8 changed files with 540 additions and 376 deletions

58
CHANGELOG.md Normal file
View File

@@ -0,0 +1,58 @@
# Changelog
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/),
und dieses Projekt adhäriert zu [Semantic Versioning](https://semver.org/lang/de/).
## [v1.2.0] - 2024-03-17
### Hinzugefügt
- Benutzer-Login-Funktionalität
- Login-Seite mit Passwortüberprüfung
- Umgebungsvariable für Login-Passwort
## [v1.1.0] - 2024-03-17
### Geändert
- Verbesserte Darstellung der Telefonnummern:
- Separate Felder für private Telefonnummer, Firmennummer und Mobilfunknummer
- Entfernung der Faxnummer aus der Anzeige
- Übersichtlichere Darstellung der Kontaktinformationen
## [1.0.6] - 2024-03-17
### Geändert
- Verbesserte Suchfunktion: Kombinierte Suche über mehrere Felder möglich
- Dokumentation: Beispiele für kombinierte Suche hinzugefügt
## [1.0.5] - 2024-03-17
### Geändert
- Verbesserte Suchfunktion: Ergebnisliste wird gelöscht, wenn alle Suchfelder leer sind
## [1.0.4] - 2024-03-17
### Geändert
- Verbesserte Adressanzeige: Location-Icon neben der Adresse
- Entfernung des Google Maps Links aus dem Adresstext
## [1.0.3] - 2024-03-17
### Geändert
- Hervorhebung der Suchbegriffe in den Ergebnissen
- Verbesserte Benutzeroberfläche
## [1.0.2] - 2024-03-17
### Geändert
- Entfernung der Wetterinformationen
- Optimierung der Suchfunktion
## [1.0.1] - 2024-03-17
### Hinzugefügt
- Wetterinformationen für Kundensitz
- Caching für Wetterdaten
## [v1.0.0] - 2024-03-17
### Hinzugefügt
- Erste Version der Kundensuche
- Grundlegende Suchfunktionen
- Responsive Design
- Docker-Integration

277
README.md
View File

@@ -1,229 +1,84 @@
# medisoftware Kundensuche # medisoftware Kundensuche
Eine Flask-basierte Webanwendung zur Suche in Kundendaten aus einer CSV-Datei. Eine webbasierte Suchanwendung für medisoftware Kunden, die eine schnelle und effiziente Suche nach Kundendaten ermöglicht.
## Features ## Features
- Live-Suche während der Eingabe - Echtzeit-Suche über Kundendaten
- Spezifische Suchfelder für: - Hervorhebung von Suchbegriffen in den Ergebnissen
- Kundennummer - Klickbare Links für Telefonnummern, E-Mail-Adressen und Adressen
- Name (Vor- und Nachname) - Responsive Design für mobile Geräte
- Fachrichtung - Docker-Container für einfache Installation und Deployment
- Ort
- Allgemeine Suche über alle Felder
- Klickbare Links für:
- Telefonnummern (tel:)
- E-Mail-Adressen (mailto:)
- Adressen (Google Maps)
- Kundennummern (KKBefe-System)
- Teilen-Funktion für einzelne Suchergebnisse
- Responsive Design mit Bootstrap
- Docker-Container-Unterstützung
## Technische Details
### Technologie-Stack
- **Backend**: Python 3.11 mit Flask
- **Frontend**: HTML, CSS, JavaScript, Bootstrap 5
- **Datenverarbeitung**: pandas, numpy
- **Container**: Docker
### Projektstruktur
```
medi-customers/
├── app.py # Flask-Anwendung
├── templates/ # HTML-Templates
│ └── index.html # Hauptseite
├── spezexpo.csv # Kundendaten
├── requirements.txt # Python-Abhängigkeiten
├── Dockerfile # Docker-Konfiguration
├── docker-compose.yml # Docker Compose Konfiguration
└── .dockerignore # Docker-Ignore-Datei
```
### Datenformat
Die Anwendung erwartet eine CSV-Datei (`spezexpo.csv`) mit folgenden Spalten:
- Nummer (Kundennummer)
- Vorname
- Nachname
- Fachrichtung
- Strasse
- PLZ
- Ort
- Tel
- mail
## Installation ## Installation
### Lokale Entwicklung 1. Repository klonen:
```bash
1. Python 3.11 installieren git clone https://gitea.elpatron.me/elpatron/medi-customers.git
2. Virtuelle Umgebung erstellen und aktivieren: cd medi-customers
```bash
python -m venv venv
source venv/bin/activate # Linux/Mac
venv\Scripts\activate # Windows
```
3. Abhängigkeiten installieren:
```bash
pip install -r requirements.txt
```
4. Anwendung starten:
```bash
python app.py
```
### Docker-Container
1. Docker installieren
2. Container mit Docker Compose starten:
```bash
docker-compose up --build
```
Die Anwendung ist dann unter `http://localhost:5001` erreichbar.
## API-Endpunkte
### GET /
- Rendert die Hauptseite
### GET /search
- Sucht nach Kunden basierend auf verschiedenen Parametern
- Parameter:
- `name`: Suche nach Vor- oder Nachname
- `ort`: Suche nach Ort
- `kundennummer`: Suche nach Kundennummer
- `fachrichtung`: Suche nach Fachrichtung
- `q`: Allgemeine Suche über alle Felder
- Returns: JSON-Array mit gefundenen Kunden
#### API-Beispiele
1. Suche nach Namen:
```bash
curl "http://localhost:5001/search?name=Schmidt"
```
2. Suche nach Ort:
```bash
curl "http://localhost:5001/search?ort=Berlin"
```
3. Suche nach Kundennummer:
```bash
curl "http://localhost:5001/search?kundennummer=12345"
```
4. Suche nach Fachrichtung:
```bash
curl "http://localhost:5001/search?fachrichtung=Allgemeinmedizin"
```
5. Kombinierte Suche:
```bash
curl "http://localhost:5001/search?name=Schmidt&ort=Berlin&fachrichtung=Allgemeinmedizin"
```
6. Allgemeine Suche:
```bash
curl "http://localhost:5001/search?q=Schmidt"
```
#### Beispiel-Response
```json
[
{
"Nummer": "12345",
"Vorname": "Max",
"Nachname": "Schmidt",
"Fachrichtung": "Allgemeinmedizin",
"Strasse": "Hauptstraße 1",
"PLZ": "10115",
"Ort": "Berlin",
"Tel": "030-123456",
"mail": "max.schmidt@example.com"
}
]
``` ```
## Frontend-Funktionen 2. Docker Container starten:
### Suchfunktion
- Live-Suche mit 300ms Debounce
- Spezifische Suchfelder für präzise Suche
- Allgemeine Suche für breite Suche
- Kombinierbare Suchkriterien
- Trefferzähler für jedes Suchfeld
### Link-Generierung
- `createPhoneLink()`: Erstellt tel:-Links mit führender 0
- `createEmailLink()`: Erstellt mailto:-Links
- `createAddressLink()`: Erstellt Google Maps-Links
- `createCustomerLink()`: Erstellt KKBefe-System-Links
### Teilen-Funktion
- Individueller Teilen-Button für jedes Suchergebnis
- Kopiert einen direkten Link zum spezifischen Kunden
- Visuelles Feedback beim Kopieren
## Fehlerbehandlung
- Logging für Backend-Fehler
- Benutzerfreundliche Fehlermeldungen im Frontend
- Graceful Degradation bei fehlenden Daten
## Entwicklung
### Debug-Modus
Die Anwendung läuft standardmäßig im Debug-Modus:
```bash ```bash
python app.py
```
### Logging
- Backend-Logs werden mit Python's logging-Modul erstellt
- Log-Level: DEBUG
- Logs werden in der Konsole ausgegeben
## Wartung
### Container-Verwaltung
```bash
# Container stoppen
docker-compose down
# Container starten
docker-compose up
# Container im Hintergrund starten
docker-compose up -d docker-compose up -d
# Container-Logs anzeigen
docker-compose logs -f
``` ```
### Datenaktualisierung Die Anwendung ist dann unter `http://localhost:5000` erreichbar.
1. CSV-Datei aktualisieren
2. Container neu bauen und starten:
```bash
docker-compose down
docker-compose up --build
```
## Sicherheit ## Versionen
- Alle externen Links öffnen sich in neuen Tabs - v1.2.0 (2024-03-17): Benutzer-Login hinzugefügt
- Sicherheitsattribute für externe Links (noopener, noreferrer) - v1.1.0 (2024-03-17): Verbesserte Darstellung der Telefonnummern
- Input-Validierung im Backend - v1.0.0 (2024-03-17): Erste Version mit grundlegenden Suchfunktionen
- Fehlerbehandlung für ungültige Daten
## Browser-Kompatibilität ## Lizenz
Die Anwendung wurde getestet mit: Alle Rechte vorbehalten. © 2025 medisoftware
- Chrome (neueste Version)
- Firefox (neueste Version) ## API-Beispiele
- Edge (neueste Version)
- Safari (neueste Version) ### Suche nach Name
```bash
curl "http://localhost:5001/search?name=Mustermann"
```
### Suche nach Ort
```bash
curl "http://localhost:5001/search?ort=Berlin"
```
### Suche nach Kundennummer
```bash
curl "http://localhost:5001/search?kundennummer=12345"
```
### Suche nach Fachrichtung
```bash
curl "http://localhost:5001/search?fachrichtung=Zahnarzt"
```
### Suche nach Telefon
```bash
curl "http://localhost:5001/search?telefon=030"
```
### Allgemeine Suche
```bash
curl "http://localhost:5001/search?q=Suchbegriff"
```
### Kombinierte Suche
```bash
# Suche nach Fachrichtung und Ort
curl "http://localhost:5001/search?fachrichtung=Zahnarzt&ort=Berlin"
# Suche nach Name und Telefon
curl "http://localhost:5001/search?name=Mustermann&telefon=030"
# Suche nach mehreren Kriterien
curl "http://localhost:5001/search?fachrichtung=Zahnarzt&ort=Berlin&name=Schmidt"
```
## Version
Aktuelle Version: [v1.2.0](CHANGELOG.md#v120---2024-03-17)

128
app.py
View File

@@ -1,20 +1,31 @@
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 from datetime import datetime, timedelta
from dotenv import load_dotenv
import requests
from collections import defaultdict
import ipaddress
app = Flask(__name__, static_folder='static') app = Flask(__name__, static_folder='static')
app.secret_key = 'your_secret_key' # Setzen Sie einen sicheren geheimen Schlüssel für die Session
logging.basicConfig(level=logging.DEBUG) logging.basicConfig(level=logging.DEBUG)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Version der Anwendung # Version der Anwendung
VERSION = "1.0.1" VERSION = "1.0.6"
# Pfad zur CSV-Datei # Pfad zur CSV-Datei
CSV_FILE = "data/customers.csv" CSV_FILE = "data/customers.csv"
# Lade Umgebungsvariablen
load_dotenv()
# Statisches Passwort aus der .env Datei
STATIC_PASSWORD = os.getenv('LOGIN_PASSWORD', 'changeme')
def clean_dataframe(df): def clean_dataframe(df):
"""Konvertiert NaN-Werte in None für JSON-Kompatibilität""" """Konvertiert NaN-Werte in None für JSON-Kompatibilität"""
return df.replace({np.nan: None}) return df.replace({np.nan: None})
@@ -27,7 +38,14 @@ def load_data():
logger.error(f"CSV-Datei '{CSV_FILE}' nicht gefunden!") logger.error(f"CSV-Datei '{CSV_FILE}' nicht gefunden!")
return None return None
df = pd.read_csv(CSV_FILE, encoding='utf-8') # Lade CSV mit Komma als Trennzeichen
df = pd.read_csv(CSV_FILE, sep=',', encoding='utf-8', quotechar='"')
# Entferne Anführungszeichen aus den Spaltennamen
df.columns = df.columns.str.strip('"')
# Entferne Anführungszeichen aus den Werten
for col in df.columns:
if df[col].dtype == 'object':
df[col] = df[col].str.strip('"')
df = clean_dataframe(df) df = clean_dataframe(df)
logger.info(f"CSV-Datei erfolgreich geladen. {len(df)} Einträge gefunden.") logger.info(f"CSV-Datei erfolgreich geladen. {len(df)} Einträge gefunden.")
return df return df
@@ -35,12 +53,47 @@ def load_data():
logger.error(f"Fehler beim Laden der CSV-Datei: {str(e)}") logger.error(f"Fehler beim Laden der CSV-Datei: {str(e)}")
return None return None
@app.route('/login', methods=['GET', 'POST'])
def login():
# Versuche, die tatsächliche Client-IP aus dem X-Forwarded-For-Header zu erhalten
client_ip = request.headers.get('X-Forwarded-For', request.remote_addr)
allowed_ip_ranges = os.getenv('ALLOWED_IP_RANGES', '').split(',')
logger.info(f"Client-IP: {client_ip}")
logger.info(f"Erlaubte IP-Bereiche: {allowed_ip_ranges}")
# Überprüfen, ob die IP-Adresse in einem der erlaubten Subnetze liegt
client_ip_obj = ipaddress.ip_address(client_ip)
for ip_range in allowed_ip_ranges:
try:
network = ipaddress.ip_network(ip_range.strip(), strict=False)
logger.info(f"Überprüfe Netzwerk: {network}")
if client_ip_obj in network:
logger.info("Client-IP ist im erlaubten Bereich.")
session['logged_in'] = True
return redirect(url_for('index'))
except ValueError:
logger.error(f"Ungültiges Netzwerkformat: {ip_range}")
if request.method == 'POST':
password = request.form.get('password')
if password == STATIC_PASSWORD:
session['logged_in'] = True
return redirect(url_for('index'))
else:
return render_template('login.html', error="Falsches Passwort")
return render_template('login.html')
@app.route('/') @app.route('/')
def index(): def index():
if not session.get('logged_in'):
return redirect(url_for('login'))
return render_template('index.html') return render_template('index.html')
@app.route('/search') @app.route('/search')
def search(): def search():
if not session.get('logged_in'):
return redirect(url_for('login'))
try: try:
# CSV-Datei laden # CSV-Datei laden
df = load_data() df = load_data()
@@ -55,10 +108,12 @@ def search():
telefon = request.args.get('telefon', '').strip() telefon = request.args.get('telefon', '').strip()
query = request.args.get('q', '').strip() query = request.args.get('q', '').strip()
# Wenn keine spezifischen Suchkriterien angegeben sind, aber eine allgemeine Suche # Initialisiere die Maske für die Filterung
if not any([name, ort, kundennummer, fachrichtung, telefon]) and query: mask = pd.Series(True, index=df.index)
# Suche in allen relevanten Feldern
mask = ( # Wenn eine allgemeine Suche angegeben ist
if query:
query_mask = (
df['Vorname'].str.contains(query, case=False, na=False) | df['Vorname'].str.contains(query, case=False, na=False) |
df['Nachname'].str.contains(query, case=False, na=False) | df['Nachname'].str.contains(query, case=False, na=False) |
df['Ort'].str.contains(query, case=False, na=False) | df['Ort'].str.contains(query, case=False, na=False) |
@@ -66,39 +121,42 @@ def search():
df['Fachrichtung'].str.contains(query, case=False, na=False) | df['Fachrichtung'].str.contains(query, case=False, na=False) |
df['Tel'].astype(str).str.contains(query, case=False, na=False) df['Tel'].astype(str).str.contains(query, case=False, na=False)
) )
else: mask &= query_mask
# Spezifische Suche
mask = pd.Series(True, index=df.index) # Spezifische Suchkriterien anwenden
if name:
if name: name_mask = (
name_mask = ( df['Vorname'].str.contains(name, case=False, na=False) |
df['Vorname'].str.contains(name, case=False, na=False) | df['Nachname'].str.contains(name, case=False, na=False)
df['Nachname'].str.contains(name, case=False, na=False) )
) mask &= name_mask
mask &= name_mask
if ort:
if ort: ort_mask = df['Ort'].str.contains(ort, case=False, na=False)
ort_mask = df['Ort'].str.contains(ort, case=False, na=False) mask &= ort_mask
mask &= ort_mask
if kundennummer:
if kundennummer: kundennummer_mask = df['Nummer'].astype(str).str.contains(kundennummer, case=False, na=False)
kundennummer_mask = df['Nummer'].astype(str).str.contains(kundennummer, case=False, na=False) mask &= kundennummer_mask
mask &= kundennummer_mask
if fachrichtung:
if fachrichtung: fachrichtung_mask = df['Fachrichtung'].str.contains(fachrichtung, case=False, na=False)
fachrichtung_mask = df['Fachrichtung'].str.contains(fachrichtung, case=False, na=False) mask &= fachrichtung_mask
mask &= fachrichtung_mask
if telefon: if telefon:
telefon_mask = df['Tel'].astype(str).str.contains(telefon, case=False, na=False) telefon_mask = df['Tel'].astype(str).str.contains(telefon, case=False, na=False)
mask &= telefon_mask mask &= telefon_mask
results = df[mask].to_dict('records') results = df[mask].to_dict('records')
logger.info(f"{len(results)} Ergebnisse gefunden") logger.info(f"{len(results)} Ergebnisse gefunden")
return jsonify(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
if __name__ == '__main__': if __name__ == '__main__':
app.run(debug=True) app.run(debug=True, port=5001)

View File

@@ -1,5 +1,3 @@
version: '3.8'
services: services:
web: web:
build: . build: .

View File

@@ -1,4 +1,5 @@
flask==3.0.2 flask==3.0.2
pandas==2.2.1 pandas==2.2.1
numpy==1.26.4 numpy==1.26.4
python-dotenv==1.0.1 python-dotenv==1.0.1
requests==2.32.3

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

View File

@@ -18,6 +18,7 @@
.main-content { .main-content {
flex: 1 0 auto; flex: 1 0 auto;
padding: 2rem 0; padding: 2rem 0;
margin-bottom: 4rem; /* Platz für die fixierte Fußzeile */
} }
.search-container { .search-container {
max-width: 800px; max-width: 800px;
@@ -75,6 +76,10 @@
background-color: #f8f9fa; background-color: #f8f9fa;
border-top: 1px solid #dee2e6; border-top: 1px solid #dee2e6;
width: 100%; width: 100%;
position: fixed;
bottom: 0;
left: 0;
z-index: 100;
} }
.share-feedback { .share-feedback {
position: fixed; position: fixed;
@@ -102,6 +107,14 @@
padding: 5px 10px; padding: 5px 10px;
border-radius: 15px; border-radius: 15px;
font-size: 0.9em; 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 { .search-fields {
display: grid; display: grid;
@@ -139,60 +152,96 @@
margin-left: 4px; margin-left: 4px;
font-size: 1.2em; 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;
}
.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;
}
</style> </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">
<img src="{{ url_for('static', filename='medisoftware_logo_rb_200.png') }}" alt="medisoftware Logo" class="img-fluid" style="max-width: 200px;">
<div class="input-group mb-4 position-relative">
<input type="text" id="searchInput" class="form-control form-control-lg"
placeholder="Allgemeine Suche...">
<i class="fa-solid fa-xmark reset-icon" id="searchReset"></i>
<span class="search-icon">🔍</span>
</div> </div>
<div class="search-container">
<h1 class="text-center mb-4">medisoftware Kundensuche</h1>
<div class="input-group mb-4 position-relative">
<input type="text" id="searchInput" class="form-control form-control-lg"
placeholder="Allgemeine Suche...">
<i class="fa-solid fa-xmark reset-icon" id="searchReset"></i>
<span class="search-icon">🔍</span>
</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" <input type="text" id="nameInput" class="form-control"
placeholder="Name..."> placeholder="Name...">
<i class="fa-solid fa-xmark reset-icon" id="nameReset"></i> <i class="fa-solid fa-xmark reset-icon" id="nameReset"></i>
</div>
<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 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 class="result-counts"> <div class="result-counts">
<span id="generalCount" class="result-count"></span> <span id="generalCount" class="result-count"></span>
</div>
<div id="loading" class="loading">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Laden...</span>
</div> </div>
</div>
<div id="results" class="mt-4"> <div id="loading" class="loading">
<!-- Hier werden die Suchergebnisse angezeigt --> <div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Laden...</span>
</div>
</div>
<div id="results" class="mt-4">
<!-- Hier werden die Suchergebnisse angezeigt -->
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -202,7 +251,10 @@
</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">
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.0</div>
</div>
</footer> </footer>
<script> <script>
@@ -211,8 +263,8 @@
function createPhoneLink(phone) { function createPhoneLink(phone) {
if (!phone) return 'N/A'; if (!phone) return 'N/A';
const cleaned = phone.replace(/\D/g, ''); const cleaned = phone.replace(/[^\d+\s]/g, '');
const telLink = '0' + cleaned; const telLink = cleaned.startsWith('+') ? cleaned : '0' + cleaned.replace(/\s/g, '');
return `<a href="tel:${telLink}" class="phone-link">${phone}</a>`; return `<a href="tel:${telLink}" class="phone-link">${phone}</a>`;
} }
@@ -221,23 +273,68 @@
return `<a href="mailto:${email}" class="email-link">${email}</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) { function createAddressLink(street, plz, city) {
if (!street || !plz || !city) return 'N/A'; if (!street || !plz || !city) return 'N/A';
const address = `${street}, ${plz} ${city}`; const address = `${street}, ${plz} ${city}`;
const searchQuery = encodeURIComponent(address); const searchQuery = encodeURIComponent(address);
return `${address} return `<span class="address-text">${address}</span>
<a href="https://www.google.com/maps/search/?api=1&query=${searchQuery}" <a href="https://www.google.com/maps/search/?api=1&query=${searchQuery}"
class="address-link" target="_blank" rel="noopener noreferrer"> class="address-link" target="_blank" rel="noopener noreferrer">
<i class="fa-solid fa-location-pin location-pin"></i> <i class="fa-solid fa-location-pin location-pin"></i>
</a>`; </a>`;
} }
function createCustomerLink(customerNumber) { function adjustCustomerNumber(number) {
if (!customerNumber) return 'N/A'; return number - 12000;
return `<a href="medisw:openkkbefe/P${customerNumber}?NetGrp=4" }
class="customer-link" target="_blank" rel="noopener noreferrer">
${customerNumber} function createCustomerLink(number) {
</a>`; const adjustedNumber = adjustCustomerNumber(number);
return `<a href="medisw:openkkbefe/P${adjustedNumber}?NetGrp=4" class="customer-link">${adjustedNumber}</a>`;
} }
function showCopyFeedback() { function showCopyFeedback() {
@@ -271,91 +368,128 @@
} }
function searchCustomers() { function searchCustomers() {
const name = document.getElementById('nameInput').value; const query = document.getElementById('searchInput').value.trim();
const ort = document.getElementById('ortInput').value; const fachrichtung = document.getElementById('fachrichtungInput').value.trim();
const kundennummer = document.getElementById('kundennummerInput').value; const ort = document.getElementById('ortInput').value.trim();
const fachrichtung = document.getElementById('fachrichtungInput').value; const name = document.getElementById('nameInput').value.trim();
const telefon = document.getElementById('telefonInput').value; const telefon = document.getElementById('telefonInput').value.trim();
const query = document.getElementById('searchInput').value; const kundennummer = document.getElementById('kundennummerInput')?.value.trim() || '';
// Prüfe, ob mindestens ein Suchfeld ausgefüllt ist // Sammle alle nicht-leeren Suchbegriffe
if (!name && !ort && !kundennummer && !fachrichtung && !telefon && !query) { const searchTerms = [query, fachrichtung, ort, name, telefon, kundennummer]
document.getElementById('results').innerHTML = ''; .filter(term => term && term.length > 0);
lastResults = [];
updateResultCounts(); // 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; return;
} }
// Lade-Animation anzeigen const resultsDiv = document.getElementById('results');
document.getElementById('loading').style.display = 'block'; const loadingDiv = document.getElementById('loading');
document.getElementById('results').innerHTML = ''; loadingDiv.style.display = 'block';
resultsDiv.innerHTML = '';
// URL-Parameter erstellen
const params = new URLSearchParams(); const searchParams = new URLSearchParams();
if (name) params.append('name', name); if (query) searchParams.append('q', query);
if (ort) params.append('ort', ort); if (fachrichtung) searchParams.append('fachrichtung', fachrichtung);
if (kundennummer) params.append('kundennummer', kundennummer); if (ort) searchParams.append('ort', ort);
if (fachrichtung) params.append('fachrichtung', fachrichtung); if (name) searchParams.append('name', name);
if (telefon) params.append('telefon', telefon); if (telefon) searchParams.append('telefon', telefon);
if (query) params.append('q', query); if (kundennummer) searchParams.append('kundennummer', kundennummer);
fetch(`/search?${params.toString()}`) fetch(`/search?${searchParams.toString()}`)
.then(response => { .then(response => response.json())
if (!response.ok) {
return response.json().then(data => {
throw new Error(data.error || 'Ein Fehler ist aufgetreten');
});
}
return response.json();
})
.then(data => { .then(data => {
const resultsDiv = document.getElementById('results');
resultsDiv.innerHTML = ''; resultsDiv.innerHTML = '';
if (data.length === 0) { // Prüfe, ob data ein Objekt mit results-Array ist
resultsDiv.innerHTML = '<div class="alert alert-info">Keine Ergebnisse gefunden.</div>'; if (!data || !data.results || !Array.isArray(data.results)) {
lastResults = []; console.error('Unerwartetes Datenformat:', data);
updateResultCounts(); resultsDiv.innerHTML = '<div class="error">Unerwartetes Datenformat vom Server</div>';
return; return;
} }
data.forEach(customer => { const results = data.results;
const card = document.createElement('div'); if (results.length === 0) {
card.className = 'card result-card'; resultsDiv.innerHTML = '<div class="no-results">Keine Ergebnisse gefunden</div>';
card.innerHTML = ` } else {
<div class="card-body"> results.forEach(customer => {
<h5 class="card-title"> const card = document.createElement('div');
${customer.Vorname} ${customer.Nachname} card.className = 'customer-card';
<span class="customer-number ms-2">(Kunde: ${createCustomerLink(customer.Nummer)})</span>
</h5> // Debug-Ausgabe für die Kundendaten
<p class="card-text"> console.log('Kundendaten:', customer);
<strong>Fachrichtung:</strong> ${customer.Fachrichtung || 'N/A'}<br> console.log('Alle verfügbaren Felder:', Object.keys(customer));
<strong>Adresse:</strong> ${createAddressLink(customer.Strasse, customer.PLZ, customer.Ort)}<br> console.log('Telefon-bezogene Felder:', {
<strong>Telefon:</strong> ${createPhoneLink(customer.Tel)}<br> Telefon: customer.Telefon,
<strong>E-Mail:</strong> ${createEmailLink(customer.mail)} Telefonnummer: customer.Telefonnummer,
</p> telefon: customer.telefon,
<div class="card-actions"> telefonnummer: customer.telefonnummer,
<button class="btn btn-outline-primary share-button" onclick="copyCustomerLink('${customer.Nummer}')"> phone: customer.phone,
🔗 Teilen 'phone.number': customer.phone?.number
</button> });
// 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> </div>
</div> <button class="share-button" onclick="copyCustomerLink('${customer.Nummer}')">
`; <i class="fas fa-share-alt"></i> Teilen
resultsDiv.appendChild(card); </button>
}); `;
resultsDiv.appendChild(card);
lastResults = data; });
updateResultCounts(); }
// 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 => { .catch(error => {
console.error('Fehler:', error); console.error('Fehler bei der Suche:', error);
document.getElementById('results').innerHTML = resultsDiv.innerHTML = '<div class="error">Ein Fehler ist aufgetreten</div>';
`<div class="alert alert-danger">${error.message}</div>`;
lastResults = [];
updateResultCounts();
}) })
.finally(() => { .finally(() => {
document.getElementById('loading').style.display = 'none'; loadingDiv.style.display = 'none';
}); });
} }

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">
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.0</div>
</div>
</footer>
</body>
</html>