44 Commits

Author SHA1 Message Date
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
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
10 changed files with 900 additions and 531 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

293
README.md
View File

@@ -1,229 +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:
```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. Umgebungsvariablen einrichten:
### 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 cp .env.example .env
# Bearbeiten Sie die .env-Datei mit Ihren Einstellungen
``` ```
### Logging 3. Docker Container starten:
- 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
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 ## 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)

275
app.py
View File

@@ -1,20 +1,151 @@
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
import csv
import sqlite3
from functools import wraps
app = Flask(__name__, static_folder='static') app = Flask(__name__, static_folder='static')
logging.basicConfig(level=logging.DEBUG) app.secret_key = os.getenv('SECRET_KEY', 'default-secret-key')
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Version der Anwendung # Version der Anwendung
VERSION = "1.0.1" VERSION = "1.2.1"
# 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', 'default-password')
ALLOWED_IP_RANGES = os.getenv('ALLOWED_IP_RANGES', '').split(',')
def init_db():
"""Initialisiert die SQLite-Datenbank und erstellt die notwendigen Tabellen."""
conn = sqlite3.connect('customers.db')
c = conn.cursor()
# Erstelle die Kunden-Tabelle
c.execute('''
CREATE TABLE IF NOT EXISTS customers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
nummer TEXT,
name TEXT,
strasse TEXT,
plz TEXT,
ort TEXT,
telefon TEXT,
mobil TEXT,
email TEXT,
bemerkung TEXT
)
''')
conn.commit()
conn.close()
def import_csv():
"""Importiert die Daten aus der CSV-Datei in die SQLite-Datenbank."""
conn = sqlite3.connect('customers.db')
c = conn.cursor()
# Lösche bestehende Daten
c.execute('DELETE FROM customers')
try:
# Lese die CSV-Datei mit pandas
df = pd.read_csv('data/customers.csv', 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('"')
# Kombiniere Vorname und Nachname
df['name'] = df['Vorname'] + ' ' + df['Nachname']
# Importiere die Daten
for _, row in df.iterrows():
c.execute('''
INSERT INTO customers (nummer, name, strasse, plz, ort, telefon, mobil, email, bemerkung)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
''', (
row['Nummer'],
row['name'],
row['Strasse'],
row['PLZ'],
row['Ort'],
row['Tel'],
row['Handy'],
row['mail'],
f"Fachrichtung: {row['Fachrichtung']}"
))
conn.commit()
logger.info('CSV-Daten erfolgreich in die Datenbank importiert')
except Exception as e:
logger.error(f'Fehler beim Import der CSV-Daten: {str(e)}')
raise
finally:
conn.close()
def search_customers(search_params):
"""Sucht Kunden in der Datenbank basierend auf den Suchparametern."""
conn = sqlite3.connect('customers.db')
c = conn.cursor()
# Erstelle die WHERE-Bedingungen basierend auf den Suchparametern
conditions = []
params = []
if search_params.get('name'):
conditions.append('name LIKE ?')
params.append(f'%{search_params["name"]}%')
if search_params.get('ort'):
conditions.append('ort LIKE ?')
params.append(f'%{search_params["ort"]}%')
if search_params.get('nummer'):
conditions.append('nummer LIKE ?')
params.append(f'%{search_params["nummer"]}%')
if search_params.get('plz'):
conditions.append('plz LIKE ?')
params.append(f'%{search_params["plz"]}%')
# Erstelle die SQL-Abfrage
sql = 'SELECT * FROM customers'
if conditions:
sql += ' WHERE ' + ' AND '.join(conditions)
# Führe die Abfrage aus
c.execute(sql, params)
results = c.fetchall()
# Konvertiere die Ergebnisse in ein Dictionary
columns = ['id', 'nummer', 'name', 'strasse', 'plz', 'ort', 'telefon', 'mobil', 'email', 'bemerkung']
customers = []
for row in results:
customer = dict(zip(columns, row))
customers.append(customer)
conn.close()
return customers
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 +158,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,70 +173,91 @@ 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}")
logger.info(f"Session Status: {session}")
# Ü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
session.permanent = True # Session bleibt bestehen
return redirect(url_for('index'))
except ValueError:
logger.error(f"Ungültiges Netzwerkformat: {ip_range}")
if request.method == 'POST':
password = request.form.get('password')
logger.info(f"Login-Versuch mit Passwort: {'*' * len(password) if password else 'None'}")
if password == STATIC_PASSWORD:
session['logged_in'] = True
session.permanent = True # Session bleibt bestehen
logger.info("Login erfolgreich, Session gesetzt")
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'))
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():
try: try:
# CSV-Datei laden # Hole die Suchparameter aus der Anfrage
df = load_data() search_params = {
if df is None: 'name': request.args.get('name', ''),
return jsonify({"error": "Datenbank konnte nicht geladen werden"}), 500 'ort': request.args.get('ort', ''),
'nummer': request.args.get('nummer', ''),
'plz': request.args.get('plz', '')
}
# Suchparameter aus der URL holen # Führe die Suche in der Datenbank durch
name = request.args.get('name', '').strip() results = search_customers(search_params)
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()
# Wenn keine spezifischen Suchkriterien angegeben sind, aber eine allgemeine Suche # Protokolliere die Anzahl der gefundenen Ergebnisse
if not any([name, ort, kundennummer, fachrichtung, telefon]) and query: logger.info(f'Suchergebnisse gefunden: {len(results)}')
# Suche in allen relevanten Feldern
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)
)
else:
# Spezifische Suche
mask = pd.Series(True, index=df.index)
if name:
name_mask = (
df['Vorname'].str.contains(name, case=False, na=False) |
df['Nachname'].str.contains(name, case=False, na=False)
)
mask &= name_mask
if ort:
ort_mask = df['Ort'].str.contains(ort, case=False, na=False)
mask &= ort_mask
if kundennummer:
kundennummer_mask = df['Nummer'].astype(str).str.contains(kundennummer, case=False, na=False)
mask &= kundennummer_mask
if fachrichtung:
fachrichtung_mask = df['Fachrichtung'].str.contains(fachrichtung, case=False, na=False)
mask &= fachrichtung_mask
if telefon:
telefon_mask = df['Tel'].astype(str).str.contains(telefon, case=False, na=False)
mask &= telefon_mask
results = df[mask].to_dict('records')
logger.info(f"{len(results)} Ergebnisse gefunden")
return jsonify(results) return jsonify(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
def init_app(app):
"""Initialisiert die Anwendung mit allen notwendigen Einstellungen."""
with app.app_context():
# Initialisiere die Datenbank
init_db()
# Importiere die CSV-Daten
import_csv()
logger.info("Anwendung erfolgreich initialisiert")
# Initialisiere die App
init_app(app)
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

@@ -2,3 +2,4 @@ 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

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

@@ -0,0 +1,207 @@
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;
}
.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;
}
.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;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

View File

@@ -7,143 +7,15 @@
<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 href="{{ url_for('static', filename='css/styles.css') }}" rel="stylesheet">
body {
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, .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%;
}
.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;
}
.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;
}
</style>
</head> </head>
<body> <body>
<div class="main-content"> <div class="main-content">
<div class="container search-container"> <div class="container">
<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="search-container">
<h1 class="text-center mb-4">medisoftware Kundensuche</h1> <h1 class="text-center mb-4">medisoftware Kundensuche</h1>
<div class="input-group mb-4 position-relative"> <div class="input-group mb-4 position-relative">
@@ -196,13 +68,17 @@
</div> </div>
</div> </div>
</div> </div>
</div>
<div id="shareFeedback" class="share-feedback"> <div id="shareFeedback" class="share-feedback">
Link kopiert! Link 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">
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 +87,14 @@
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>`;
} }
@@ -221,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 `${address} 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}" <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 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() {
@@ -271,91 +242,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
document.getElementById('loading').style.display = 'block';
document.getElementById('results').innerHTML = '';
// URL-Parameter erstellen
const params = new URLSearchParams();
if (name) params.append('name', name);
if (ort) params.append('ort', ort);
if (kundennummer) params.append('kundennummer', kundennummer);
if (fachrichtung) params.append('fachrichtung', fachrichtung);
if (telefon) params.append('telefon', telefon);
if (query) params.append('q', query);
fetch(`/search?${params.toString()}`)
.then(response => {
if (!response.ok) {
return response.json().then(data => {
throw new Error(data.error || 'Ein Fehler ist aufgetreten');
});
}
return response.json();
})
.then(data => {
const resultsDiv = document.getElementById('results'); const resultsDiv = document.getElementById('results');
const loadingDiv = document.getElementById('loading');
loadingDiv.style.display = 'block';
resultsDiv.innerHTML = ''; resultsDiv.innerHTML = '';
if (data.length === 0) { const searchParams = new URLSearchParams();
resultsDiv.innerHTML = '<div class="alert alert-info">Keine Ergebnisse gefunden.</div>'; if (query) searchParams.append('q', query);
lastResults = []; if (fachrichtung) searchParams.append('fachrichtung', fachrichtung);
updateResultCounts(); 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; return;
} }
data.forEach(customer => { 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'); const card = document.createElement('div');
card.className = 'card result-card'; 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 = ` card.innerHTML = `
<div class="card-body"> <div class="customer-info">
<h5 class="card-title"> <strong>Kundennummer:</strong> ${customerLink}<br>
${customer.Vorname} ${customer.Nachname} <strong>Name:</strong> ${highlightText(`${customer.Vorname || ''} ${customer.Nachname || ''}`, searchTerms)}<br>
<span class="customer-number ms-2">(Kunde: ${createCustomerLink(customer.Nummer)})</span> <strong>Fachrichtung:</strong> ${highlightText(customer.Fachrichtung || '', searchTerms)}<br>
</h5> <strong>Adresse:</strong> ${highlightedAddress}<br>
<p class="card-text"> <strong>Telefon:</strong> ${highlightedPhone}<br>
<strong>Fachrichtung:</strong> ${customer.Fachrichtung || 'N/A'}<br> <strong>Firma:</strong> ${highlightedCompanyPhone}<br>
<strong>Adresse:</strong> ${createAddressLink(customer.Strasse, customer.PLZ, customer.Ort)}<br> <strong>Mobil:</strong> ${highlightedMobilePhone}
<strong>Telefon:</strong> ${createPhoneLink(customer.Tel)}<br> </div>
<strong>E-Mail:</strong> ${createEmailLink(customer.mail)} <button class="share-button" onclick="copyCustomerLink('${adjustCustomerNumber(customer.Nummer)}')">
</p> <i class="fas fa-share-alt"></i> Teilen
<div class="card-actions">
<button class="btn btn-outline-primary share-button" onclick="copyCustomerLink('${customer.Nummer}')">
🔗 Teilen
</button> </button>
</div>
</div>
`; `;
resultsDiv.appendChild(card); resultsDiv.appendChild(card);
}); });
}
lastResults = data; // Aktualisiere die Anzahl der Treffer
updateResultCounts(); 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>