59 Commits

Author SHA1 Message Date
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
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
163fb792a0 Reset-Icon-Anzeige korrigiert 2025-03-17 20:58:25 +01:00
733161dd46 Icon-Anzeige korrigiert 2025-03-17 20:57:33 +01:00
4e2703ba0a Reset-Icon in den Suchfeldern ganz rechts positioniert 2025-03-17 20:56:32 +01:00
02e72b8347 CSV-Datei wird jetzt in der search-Funktion geladen 2025-03-17 20:55:11 +01:00
0285f0a520 Suchfeld für Telefonnummer hinzugefügt 2025-03-17 20:54:15 +01:00
8fb10a35e8 Allgemeines Suchfeld über spezifische Suchfelder verschoben 2025-03-17 20:52:33 +01:00
6b017e641d CSV-Datei in data-Verzeichnis verschoben 2025-03-17 20:46:35 +01:00
4d110b6019 Version 1.0.1: Trefferzähler vereinfacht und Layout verbessert 2025-03-17 20:42:29 +01:00
71d811ea98 Trefferzähler auf Gesamtzahl vereinfacht 2025-03-17 20:41:16 +01:00
2de5c21442 Trefferzähler zwischen Suchfeldern und Suchergebnissen neu positioniert 2025-03-17 20:40:09 +01:00
c6f170e1fc Abstand zwischen Reset-Icon und Trefferzahl vergrößert 2025-03-17 20:38:24 +01:00
ff4f322ca4 Reset-Icons für alle Suchfelder hinzugefügt 2025-03-17 20:37:10 +01:00
5eaa79ed62 Location-Pin-Icon vergrößert 2025-03-17 20:35:31 +01:00
6657101cd6 Google Maps Link nur noch auf dem Pin-Icon 2025-03-17 20:34:15 +01:00
9f4c532d66 Google Maps Icon durch Font Awesome Pin ersetzt 2025-03-17 20:33:14 +01:00
83cedcc555 API-Beispiele und Response-Format zur Dokumentation hinzugefügt 2025-03-17 20:19:45 +01:00
2eddb3ad20 Trefferzähler für alle Suchfelder hinzugefügt 2025-03-17 20:18:17 +01:00
10 changed files with 1797 additions and 404 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

43
CHANGELOG.md Normal file
View File

@@ -0,0 +1,43 @@
# 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-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
## [v1.0.0] - 2024-03-17
### Hinzugefügt
- Erste Version mit grundlegenden Suchfunktionen
- Echtzeit-Suche über Kundendaten
- Hervorhebung von Suchbegriffen in den Ergebnissen
- Klickbare Links für Telefonnummern, E-Mail-Adressen und Adressen
- Docker-Container für einfache Installation und Deployment

245
README.md
View File

@@ -1,179 +1,106 @@
# medisoftware Kundensuche # medisoftware Kundensuche
Eine Flask-basierte Webanwendung zur Suche in Kundendaten aus einer CSV-Datei. Eine einfache Webanwendung zur Suche nach medisoftware Kunden mit IP-basierter Zugriffssteuerung.
## Features ## Features
- Live-Suche während der Eingabe - Kundensuche nach verschiedenen Kriterien (Name, Ort, Kundennummer, etc.)
- Spezifische Suchfelder für: - Direkte Links zu Kundendaten in medisoftware (für autorisierte IPs)
- Kundennummer - Telefonnummern-Links für autorisierte IPs
- Name (Vor- und Nachname) - Adress-Links mit Google Maps Integration
- Fachrichtung - IP-basierte Zugriffssteuerung
- Ort - Responsive Design
- 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:
1. Python 3.11 installieren
2. Virtuelle Umgebung erstellen und aktivieren:
```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
## Frontend-Funktionen
### Suchfunktion
- Live-Suche mit 300ms Debounce
- Spezifische Suchfelder für präzise Suche
- Allgemeine Suche für breite Suche
- Kombinierbare Suchkriterien
### 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 git clone https://gitea.elpatron.me/elpatron/medi-customers.git
cd medi-customers
``` ```
### Logging 2. Umgebungsvariablen einrichten:
- Backend-Logs werden mit Python's logging-Modul erstellt
- Log-Level: DEBUG
- Logs werden in der Konsole ausgegeben
## Wartung
### Container-Verwaltung
```bash ```bash
# Container stoppen cp .env.example .env
docker-compose down # Bearbeiten Sie die .env-Datei mit Ihren Einstellungen
```
# Container starten 3. Docker Container starten:
docker-compose up ```bash
# Container im Hintergrund starten
docker-compose up -d docker-compose up -d
# Container-Logs anzeigen
docker-compose logs -f
``` ```
### Datenaktualisierung ## Konfiguration
1. CSV-Datei aktualisieren
2. Container neu bauen und starten:
```bash
docker-compose down
docker-compose up --build
```
## Sicherheit Die Anwendung kann über folgende Umgebungsvariablen konfiguriert werden:
- Alle externen Links öffnen sich in neuen Tabs - `FLASK_APP`: Die Hauptanwendungsdatei (Standard: app.py)
- Sicherheitsattribute für externe Links (noopener, noreferrer) - `FLASK_ENV`: Die Flask-Umgebung (development/production)
- Input-Validierung im Backend - `SECRET_KEY`: Der geheime Schlüssel für Flask-Sessions
- Fehlerbehandlung für ungültige Daten - `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)
## Browser-Kompatibilität ## IP-Bereiche
Die Anwendung wurde getestet mit: Die Anwendung unterstützt CIDR-Notation für IP-Bereiche. Beispiele:
- Chrome (neueste Version) - Einzelne IP: 192.168.1.1/32
- Firefox (neueste Version) - Subnetz: 192.168.1.0/24
- Edge (neueste Version) - Größeres Netzwerk: 10.0.0.0/8
- Safari (neueste Version)
## Version
Aktuelle Version: v1.2.0
## Lizenz
Alle Rechte vorbehalten. © 2025 medisoftware
## API-Beispiele
### 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)

153
app.py
View File

@@ -1,13 +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, 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 = "1.0.6"
# Pfad zur CSV-Datei
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})
@@ -16,11 +34,18 @@ def clean_dataframe(df):
def load_data(): def load_data():
try: try:
logger.info("Versuche CSV-Datei zu laden...") logger.info("Versuche CSV-Datei zu laden...")
if not os.path.exists('spezexpo.csv'): if not os.path.exists(CSV_FILE):
logger.error("CSV-Datei 'spezexpo.csv' nicht gefunden!") logger.error(f"CSV-Datei '{CSV_FILE}' nicht gefunden!")
return None return None
df = pd.read_csv('spezexpo.csv', 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
@@ -28,68 +53,114 @@ 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():
return render_template('index.html') if not session.get('logged_in'):
return redirect(url_for('login'))
allowed_ip_ranges = os.getenv('ALLOWED_IP_RANGES', '')
client_ip = request.headers.get('X-Forwarded-For', request.remote_addr)
logger.info(f"Client-IP: {client_ip}")
logger.info(f"Erlaubte IP-Bereiche: {allowed_ip_ranges}")
return render_template('index.html', allowed_ip_ranges=allowed_ip_ranges)
@app.route('/search') @app.route('/search')
def search(): def search():
if not session.get('logged_in'):
return redirect(url_for('login'))
try: try:
# Spezifische Suchparameter # CSV-Datei laden
name = request.args.get('name', '').lower()
ort = request.args.get('ort', '').lower()
kundennummer = request.args.get('kundennummer', '').lower()
fachrichtung = request.args.get('fachrichtung', '').lower()
# Allgemeine Suche (für die alte Funktionalität)
query = request.args.get('q', '').lower()
logger.info(f"Suche nach - Name: {name}, Ort: {ort}, Kundennummer: {kundennummer}, Fachrichtung: {fachrichtung}, Query: {query}")
df = load_data() df = load_data()
if df is None: if df is None:
return jsonify({"error": "Datenbank konnte nicht geladen werden"}), 500 return jsonify({"error": "Datenbank konnte nicht geladen werden"}), 500
# Basis-Mask (immer True) # Suchparameter aus der URL holen
name = request.args.get('name', '').strip()
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
mask = pd.Series(True, index=df.index) mask = pd.Series(True, index=df.index)
# Wenn eine allgemeine Suche angegeben ist
if query:
query_mask = (
df['Vorname'].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['Nummer'].astype(str).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)
)
mask &= query_mask
# Spezifische Suchkriterien anwenden # Spezifische Suchkriterien anwenden
if name: if name:
name_mask = ( name_mask = (
df['Vorname'].str.lower().str.contains(name, na=False) | df['Vorname'].str.contains(name, case=False, na=False) |
df['Nachname'].str.lower().str.contains(name, 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.lower().str.contains(ort, na=False) ort_mask = df['Ort'].str.contains(ort, case=False, na=False)
mask &= ort_mask mask &= ort_mask
if kundennummer: if kundennummer:
kunden_mask = df['Nummer'].astype(str).str.contains(kundennummer, na=False) kundennummer_mask = df['Nummer'].astype(str).str.contains(kundennummer, case=False, na=False)
mask &= kunden_mask mask &= kundennummer_mask
if fachrichtung: if fachrichtung:
fach_mask = df['Fachrichtung'].str.lower().str.contains(fachrichtung, na=False) fachrichtung_mask = df['Fachrichtung'].str.contains(fachrichtung, case=False, na=False)
mask &= fach_mask mask &= fachrichtung_mask
# Wenn eine allgemeine Suche vorhanden ist, diese zusätzlich anwenden if telefon:
if query: telefon_mask = df['Tel'].astype(str).str.contains(telefon, case=False, na=False)
query_mask = ( mask &= telefon_mask
df['Vorname'].str.lower().str.contains(query, na=False) |
df['Nachname'].str.lower().str.contains(query, na=False) |
df['Fachrichtung'].str.lower().str.contains(query, na=False) |
df['Ort'].str.lower().str.contains(query, na=False) |
df['Nummer'].astype(str).str.contains(query, na=False)
)
mask &= query_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)

1172
data/customers.csv Normal file

File diff suppressed because it is too large Load Diff

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

@@ -6,137 +6,66 @@
<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">
<style> <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" rel="stylesheet">
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;
}
.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 {
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
color: #6c757d;
}
.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%;
}
.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;
}
.search-fields {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
margin-bottom: 1rem;
}
.search-field {
position: relative;
}
</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="search-fields">
<div class="search-field">
<input type="text" id="nameInput" class="form-control"
placeholder="Name...">
</div>
<div class="search-field">
<input type="text" id="ortInput" class="form-control"
placeholder="Ort...">
</div>
<div class="search-field">
<input type="text" id="kundennummerInput" class="form-control"
placeholder="Kundennummer...">
</div>
<div class="search-field">
<input type="text" id="fachrichtungInput" class="form-control"
placeholder="Fachrichtung...">
</div>
</div> </div>
<div class="search-container">
<div class="input-group mb-4 position-relative"> <h1 class="text-center mb-4">medisoftware Kundensuche</h1>
<input type="text" id="searchInput" class="form-control form-control-lg"
placeholder="Allgemeine Suche..."> <div class="input-group mb-4 position-relative">
<span class="search-icon">🔍</span> <input type="text" id="searchInput" class="form-control form-control-lg"
</div> placeholder="Allgemeine Suche...">
<i class="fa-solid fa-xmark reset-icon" id="searchReset"></i>
<div id="loading" class="loading"> <span class="search-icon">🔍</span>
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Laden...</span>
</div> </div>
</div>
<div id="results" class="mt-4"> <div class="search-fields">
<!-- Hier werden die Suchergebnisse angezeigt --> <div class="search-field">
<input type="text" id="nameInput" class="form-control"
placeholder="Name...">
<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 class="result-counts">
<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 id="results" class="mt-4">
<!-- Hier werden die Suchergebnisse angezeigt -->
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -146,16 +75,26 @@
</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>
let searchTimeout; let searchTimeout;
let lastResults = [];
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 clientIP = '{{ request.headers.get("X-Forwarded-For", request.remote_addr) }}';
const allowedIPRanges = '{{ allowed_ip_ranges }}'.split(',');
// Überprüfen, ob die Client-IP in einem der erlaubten Bereiche liegt
const isAllowed = allowedIPRanges.some(range => clientIP.startsWith(range.trim()));
const telLink = cleaned.startsWith('+') ? cleaned : (isAllowed ? '0' + cleaned.replace(/\s/g, '') : cleaned.replace(/\s/g, ''));
return `<a href="tel:${telLink}" class="phone-link">${phone}</a>`; return `<a href="tel:${telLink}" class="phone-link">${phone}</a>`;
} }
@@ -164,23 +103,112 @@
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 `<a href="https://www.google.com/maps/search/?api=1&query=${searchQuery}" const routeQuery = encodeURIComponent(address);
const clientIP = '{{ request.headers.get("X-Forwarded-For", request.remote_addr) }}';
console.log('Client IP in createAddressLink:', clientIP);
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"> class="address-link" target="_blank" rel="noopener noreferrer">
${address} <i class="fa-solid fa-location-pin location-pin"></i>
<span class="ms-1">🗺️</span> </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-car route-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 isIPInSubnet(ip, subnet) {
</a>`; // 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 = '{{ request.headers.get("X-Forwarded-For", request.remote_addr) }}';
const allowedIPRanges = '{{ allowed_ip_ranges }}'.split(',');
// Debug-Ausgabe für die IP-Bereiche
console.log('Client IP in createCustomerLink:', clientIP);
console.log('Allowed IP Ranges:', allowedIPRanges);
// Überprüfe, ob die Client-IP in einem der erlaubten Bereiche liegt
const isAllowed = allowedIPRanges.some(range => {
const trimmedRange = range.trim();
console.log('Checking range:', trimmedRange);
return isIPInSubnet(clientIP, trimmedRange);
});
console.log('isAllowed in createCustomerLink:', isAllowed);
const adjustedNumber = adjustCustomerNumber(nummer);
if (isAllowed) {
return `<a href="medisw:openkkbefe/P${adjustedNumber}?NetGrp=4" class="customer-link">${nummer}</a>`;
} else {
return nummer;
}
} }
function showCopyFeedback() { function showCopyFeedback() {
@@ -205,81 +233,137 @@
} }
} }
function searchCustomers() { function updateResultCounts() {
const name = document.getElementById('nameInput').value; // Nur Gesamtzahl anzeigen
const ort = document.getElementById('ortInput').value; const generalCount = lastResults.length;
const kundennummer = document.getElementById('kundennummerInput').value; document.getElementById('generalCount').textContent =
const fachrichtung = document.getElementById('fachrichtungInput').value; generalCount > 0 ? `${generalCount} Treffer gefunden` : '';
const query = document.getElementById('searchInput').value; document.getElementById('generalCount').classList.toggle('visible', generalCount > 0);
}
// Prüfe, ob mindestens ein Suchfeld ausgefüllt ist function searchCustomers() {
if (!name && !ort && !kundennummer && !fachrichtung && !query) { const query = document.getElementById('searchInput').value.trim();
document.getElementById('results').innerHTML = ''; 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; 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 (query) params.append('q', query); if (telefon) searchParams.append('telefon', telefon);
if (kundennummer) searchParams.append('kundennummer', kundennummer);
fetch(`/search?${params.toString()}`)
.then(response => { fetch(`/search?${searchParams.toString()}`)
if (!response.ok) { .then(response => response.json())
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)) {
console.error('Unerwartetes Datenformat:', data);
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('${adjustCustomerNumber(customer.Nummer)}')">
`; <i class="fas fa-share-alt"></i> Teilen
resultsDiv.appendChild(card); </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 => { .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>`;
}) })
.finally(() => { .finally(() => {
document.getElementById('loading').style.display = 'none'; loadingDiv.style.display = 'none';
}); });
} }
@@ -289,13 +373,32 @@
document.getElementById('ortInput'), document.getElementById('ortInput'),
document.getElementById('kundennummerInput'), document.getElementById('kundennummerInput'),
document.getElementById('fachrichtungInput'), document.getElementById('fachrichtungInput'),
document.getElementById('telefonInput'),
document.getElementById('searchInput') document.getElementById('searchInput')
]; ];
searchInputs.forEach(input => { 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() { input.addEventListener('input', function() {
clearTimeout(searchTimeout); clearTimeout(searchTimeout);
searchTimeout = setTimeout(searchCustomers, 300); 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();
}); });
}); });
@@ -306,15 +409,17 @@
const ort = urlParams.get('ort'); const ort = urlParams.get('ort');
const kundennummer = urlParams.get('kundennummer'); const kundennummer = urlParams.get('kundennummer');
const fachrichtung = urlParams.get('fachrichtung'); const fachrichtung = urlParams.get('fachrichtung');
const telefon = urlParams.get('telefon');
const query = urlParams.get('q'); const query = urlParams.get('q');
if (name) document.getElementById('nameInput').value = name; if (name) document.getElementById('nameInput').value = name;
if (ort) document.getElementById('ortInput').value = ort; if (ort) document.getElementById('ortInput').value = ort;
if (kundennummer) document.getElementById('kundennummerInput').value = kundennummer; if (kundennummer) document.getElementById('kundennummerInput').value = kundennummer;
if (fachrichtung) document.getElementById('fachrichtungInput').value = fachrichtung; if (fachrichtung) document.getElementById('fachrichtungInput').value = fachrichtung;
if (telefon) document.getElementById('telefonInput').value = telefon;
if (query) document.getElementById('searchInput').value = query; if (query) document.getElementById('searchInput').value = query;
if (name || ort || kundennummer || fachrichtung || query) { if (name || ort || kundennummer || fachrichtung || telefon || query) {
searchCustomers(); searchCustomers();
} }
}); });

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>