70 Commits

Author SHA1 Message Date
aabb4540c9 Version 1.2.3: Performance-Optimierung durch Entfernung von Debug-Ausgaben 2025-03-18 13:57:32 +01:00
ffde078238 Git: Füge data/customers.db zur .gitignore hinzu 2025-03-18 13:55:17 +01:00
9e320c4eb2 Performance: Entferne alle console.log Anweisungen 2025-03-18 13:53:14 +01:00
58ed5fe867 Performance: Entferne Debug-Logging aus createPhoneLink Funktion 2025-03-18 13:50:32 +01:00
72676edc10 Git: Füge data/customers.db zur .gitignore hinzu 2025-03-18 13:48:55 +01:00
d5954eac89 Version 1.2.2: Verbesserte Telefonnummern-Formatierung und Dokumentation 2025-03-18 13:47:41 +01:00
68a2db28a1 Dokumentation: Aktualisierte README.md und CHANGELOG.md für Version 1.2.2 2025-03-18 13:46:33 +01:00
13709de515 Version 1.2.2: Verbesserte Telefonnummern-Formatierung 2025-03-18 13:43:07 +01:00
2c65d5f651 Dokumentation: Aktualisierung für Version 1.2.1 2025-03-18 12:56:15 +01:00
33ecb79e0b Version 1.2.1: Verbesserte CSV-Import-Funktionalität und Login-Fix 2025-03-18 12:54:31 +01:00
00bb197620 CSS in separate Datei ausgelagert 2025-03-18 12:34:14 +01:00
8e7d3da07f Release v1.2.0: IP-Subnetz-Prüfung und Debug-Logging 2025-03-18 12:32:06 +01:00
35e0d0a783 Beispiel-Umgebungsvariablen hinzugefügt 2025-03-18 12:28:46 +01:00
40196fa28f Debug-Logging für IP-Adressen hinzugefügt 2025-03-18 12:26:46 +01:00
7b5e90e3bd IP-Subnetz-Prüfung korrigiert und Debug-Logging hinzugefügt 2025-03-18 12:25:53 +01:00
3c48988e88 Routenplanung für Adressen hinzugefügt 2025-03-18 11:35:46 +01:00
869acdcb18 IP-Überprüfung für Telefonnummern-Links implementiert 2025-03-18 11:28:27 +01:00
91af1dfca0 README und CHANGELOG für v1.0.7 aktualisiert 2025-03-18 11:15:36 +01:00
c271bc1f60 IP-Überprüfung mit X-Forwarded-For-Header hinzugefügt 2025-03-18 11:10:24 +01:00
9c04bb973e Version im Footer hinzugefügt 2025-03-18 10:24:18 +01:00
6d7298548b Dokumentation für Release v1.2.0 aktualisiert 2025-03-18 10:19:21 +01:00
42a11abe61 Login 2025-03-18 10:10:17 +01:00
3c6d2f7c45 Titel von login.html auf 'medisoftware Kundensuche' geändert 2025-03-18 09:44:45 +01:00
d143d6c7b6 Logo hinzugefügt 2025-03-18 09:10:57 +01:00
e6638b737d Logo im oberen Bereich der App zentriert hinzugefügt 2025-03-18 09:08:53 +01:00
1f493e0a37 Korrektur: medisw: Link-Parameter wiederhergestellt 2025-03-18 08:58:12 +01:00
469ad0ce05 Kundennummer wird um 12000 reduziert in Anzeige und medisw: Link 2025-03-18 08:57:40 +01:00
8a8c13e407 Port 2025-03-18 00:45:45 +01:00
18974eb69b Footer: Nur das Wort 'medisoftware' ist jetzt verlinkt 2025-03-18 00:41:07 +01:00
9e406ed7a3 Footer: Nur das Wort 'medisoftware' ist jetzt verlinkt 2025-03-18 00:38:57 +01:00
8b82a44ad8 Footer-Text aktualisiert: Made with ❤️ and 🍺 by medisoftware 2025-03-18 00:37:39 +01:00
528baff7b5 Aktuelle Version in README.md auf v1.1.0 aktualisiert 2025-03-18 00:33:54 +01:00
e105dc4663 Dokumentation für Release v1.1.0 aktualisiert 2025-03-18 00:32:05 +01:00
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
13 changed files with 2166 additions and 427 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

4
.gitignore vendored
View File

@@ -46,3 +46,7 @@ coverage.xml
# Daten # Daten
spezexpo.csv spezexpo.csv
# Database
*.db
data/customers.db

69
CHANGELOG.md Normal file
View File

@@ -0,0 +1,69 @@
# 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/).
## [1.2.3] - 2024-03-19
### Geändert
- Performance-Optimierung: Entfernung aller console.log Anweisungen
- Verbesserte Code-Qualität durch Entfernung von Debug-Ausgaben
## [1.2.2] - 2024-03-19
### Geändert
- Verbesserte Telefonnummern-Formatierung: Führende "0" wird immer hinzugefügt, wenn der Benutzer von einer erlaubten IP-Adresse zugreift
- Debug-Logging für Telefonnummern-Formatierung hinzugefügt
- Verbesserte Benutzerfreundlichkeit bei der Anzeige von Telefonnummern
### Behoben
- Problem mit fehlender führender "0" bei Telefonnummern für autorisierte Benutzer
## [1.2.1] - 2024-03-18
### Geändert
- Verbesserte CSV-Import-Funktionalität mit pandas
- Korrektur des Login-Prozesses
- Verbesserte Fehlerbehandlung und Logging
- Anpassung der Spaltennamen für den CSV-Import
### Behoben
- Login-Prozess funktioniert jetzt korrekt mit dem Passwort aus der .env Datei
- CSV-Import verarbeitet Anführungszeichen korrekt
- Verbesserte Fehlerbehandlung beim Datenbankimport
## [1.2.0] - 2024-03-18
### Geändert
- IP-Subnetz-Prüfung korrigiert (CIDR-Notation wird jetzt korrekt ausgewertet)
- Debug-Logging für IP-Adressen hinzugefügt
- Beispiel-Umgebungsvariablen (.env.example) hinzugefügt
### Hinzugefügt
- IP-basierte Zugriffssteuerung für medisoftware-Links
- IP-basierte Zugriffssteuerung für Telefonnummern-Links
- Google Maps Integration für Adressen
- Version im Footer angezeigt
### Verbessert
- Verbesserte Fehlerbehandlung bei der IP-Überprüfung
- Bessere Dokumentation der Konfigurationsmöglichkeiten
## [v1.1.0] - 2024-03-17
### Hinzugefügt
- Benutzer-Login für nicht-autorisierte IPs
- Verbesserte Darstellung der Telefonnummern
- Responsive Design für mobile Geräte
### Verbessert
- Optimierte Suchfunktion
- Verbesserte Benutzeroberfläche
## [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

View File

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

239
README.md
View File

@@ -1,179 +1,106 @@
# medisoftware Kundensuche # Medi-Customers
Eine Flask-basierte Webanwendung zur Suche in Kundendaten aus einer CSV-Datei. Eine Flask-basierte Webanwendung zur Verwaltung von Kundenkontakten für medizinische Einrichtungen.
## 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 ```bash
python -m venv venv git clone https://gitea.elpatron.me/elpatron/medi-customers.git
source venv/bin/activate # Linux/Mac cd medi-customers
venv\Scripts\activate # Windows
```
3. Abhängigkeiten installieren:
```bash
pip install -r requirements.txt
```
4. Anwendung starten:
```bash
python app.py
``` ```
### Docker-Container 2. Umgebungsvariablen einrichten:
1. Docker installieren
2. Container mit Docker Compose starten:
```bash ```bash
docker-compose up --build cp .env.example .env
``` # Bearbeiten Sie die .env-Datei mit Ihren Einstellungen
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
python app.py
``` ```
### 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: Die Anwendung kann über folgende Umgebungsvariablen konfiguriert werden:
- `FLASK_APP`: Die Hauptanwendungsdatei (Standard: app.py)
- `FLASK_ENV`: Die Flask-Umgebung (development/production)
- `SECRET_KEY`: Der geheime Schlüssel für Flask-Sessions
- `DATABASE_URL`: Die URL zur SQLite-Datenbank
- `STATIC_PASSWORD`: Das Passwort für die Login-Seite
- `ALLOWED_IP_RANGES`: Komma-getrennte Liste von IP-Bereichen, die direkten Zugriff haben
- `LOG_LEVEL`: Das Logging-Level (INFO/DEBUG)
## IP-Bereiche
Die Anwendung unterstützt CIDR-Notation für IP-Bereiche. Beispiele:
- Einzelne IP: 192.168.1.1/32
- Subnetz: 192.168.1.0/24
- Größeres Netzwerk: 10.0.0.0/8
## Version
Aktuelle Version: 1.2.1
## Lizenz
Alle Rechte vorbehalten. © 2025 medisoftware
## API-Beispiele
### Suche nach Name
```bash ```bash
docker-compose down curl "http://localhost:5001/search?name=Mustermann"
docker-compose up --build
``` ```
## Sicherheit ### Suche nach Ort
```bash
curl "http://localhost:5001/search?ort=Berlin"
```
- Alle externen Links öffnen sich in neuen Tabs ### Suche nach Kundennummer
- Sicherheitsattribute für externe Links (noopener, noreferrer) ```bash
- Input-Validierung im Backend curl "http://localhost:5001/search?kundennummer=12345"
- Fehlerbehandlung für ungültige Daten ```
## Browser-Kompatibilität ### Suche nach Fachrichtung
```bash
curl "http://localhost:5001/search?fachrichtung=Zahnarzt"
```
Die Anwendung wurde getestet mit: ### Suche nach Telefon
- Chrome (neueste Version) ```bash
- Firefox (neueste Version) curl "http://localhost:5001/search?telefon=030"
- Edge (neueste Version) ```
- Safari (neueste Version)
### 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)

311
app.py
View File

@@ -1,13 +1,179 @@
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
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 = "1.2.1"
# Pfad zur CSV-Datei
CSV_FILE = 'data/customers.csv'
# Pfad zur Datenbank
DB_FILE = 'data/customers.db'
# 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 mit der notwendigen Tabelle."""
conn = sqlite3.connect(DB_FILE)
c = conn.cursor()
# Erstelle die Tabelle mit Indizes
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
)
''')
# Erstelle Indizes für häufig durchsuchte Spalten
c.execute('CREATE INDEX IF NOT EXISTS idx_name ON customers(name)')
c.execute('CREATE INDEX IF NOT EXISTS idx_ort ON customers(ort)')
c.execute('CREATE INDEX IF NOT EXISTS idx_nummer ON customers(nummer)')
c.execute('CREATE INDEX IF NOT EXISTS idx_telefon ON customers(telefon)')
c.execute('CREATE INDEX IF NOT EXISTS idx_email ON customers(email)')
conn.commit()
conn.close()
logger.info('Datenbank initialisiert')
def import_csv():
"""Importiert die Daten aus der CSV-Datei in die SQLite-Datenbank."""
conn = sqlite3.connect(DB_FILE)
c = conn.cursor()
# Lösche bestehende Daten
c.execute('DELETE FROM customers')
try:
# Lese die CSV-Datei mit pandas
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('"')
# 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 nach Kunden basierend auf den Suchparametern."""
conn = sqlite3.connect(DB_FILE)
c = conn.cursor()
try:
# Baue die SQL-Abfrage dynamisch auf
query = "SELECT * FROM customers WHERE 1=1"
params = []
# Allgemeine Suche über alle Felder
if search_params.get('q'):
search_term = f"%{search_params['q']}%"
query += " AND (name LIKE ? OR ort LIKE ? OR nummer LIKE ? OR telefon LIKE ? OR mobil LIKE ? OR email LIKE ? OR bemerkung LIKE ?)"
params.extend([search_term] * 7)
# Spezifische Suche für einzelne Felder
if search_params.get('name'):
query += " AND name LIKE ?"
params.append(f"%{search_params['name']}%")
if search_params.get('ort'):
query += " AND ort LIKE ?"
params.append(f"%{search_params['ort']}%")
if search_params.get('nummer'):
query += " AND nummer LIKE ?"
params.append(f"%{search_params['nummer']}%")
if search_params.get('plz'):
query += " AND plz LIKE ?"
params.append(f"%{search_params['plz']}%")
# Führe die Abfrage aus
c.execute(query, params)
results = c.fetchall()
# Formatiere die Ergebnisse
customers = []
for row in results:
customer = {
'id': row[0],
'nummer': row[1],
'name': row[2],
'strasse': row[3],
'plz': row[4],
'ort': row[5],
'telefon': row[6],
'mobil': row[7],
'email': row[8],
'bemerkung': row[9]
}
customers.append(customer)
return customers
except Exception as e:
logger.error(f"Fehler bei der Kundensuche: {str(e)}")
raise
finally:
conn.close()
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 +182,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 +201,94 @@ 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:
# Spezifische Suchparameter # Hole die Suchparameter aus der Anfrage
name = request.args.get('name', '').lower() search_params = {
ort = request.args.get('ort', '').lower() 'name': request.args.get('name', ''),
kundennummer = request.args.get('kundennummer', '').lower() 'ort': request.args.get('ort', ''),
fachrichtung = request.args.get('fachrichtung', '').lower() 'nummer': request.args.get('nummer', ''),
'plz': request.args.get('plz', ''),
'telefon': request.args.get('telefon', ''),
'email': request.args.get('email', ''),
'q': request.args.get('q', '')
}
# Allgemeine Suche (für die alte Funktionalität) # Führe die Suche in der Datenbank durch
query = request.args.get('q', '').lower() results = search_customers(search_params)
logger.info(f"Suche nach - Name: {name}, Ort: {ort}, Kundennummer: {kundennummer}, Fachrichtung: {fachrichtung}, Query: {query}") # Protokolliere die Anzahl der gefundenen Ergebnisse
logger.info(f'Suchergebnisse gefunden: {len(results)}')
df = load_data()
if df is None:
return jsonify({"error": "Datenbank konnte nicht geladen werden"}), 500
# Basis-Mask (immer True)
mask = pd.Series(True, index=df.index)
# Spezifische Suchkriterien anwenden
if name:
name_mask = (
df['Vorname'].str.lower().str.contains(name, na=False) |
df['Nachname'].str.lower().str.contains(name, na=False)
)
mask &= name_mask
if ort:
ort_mask = df['Ort'].str.lower().str.contains(ort, na=False)
mask &= ort_mask
if kundennummer:
kunden_mask = df['Nummer'].astype(str).str.contains(kundennummer, na=False)
mask &= kunden_mask
if fachrichtung:
fach_mask = df['Fachrichtung'].str.lower().str.contains(fachrichtung, na=False)
mask &= fach_mask
# Wenn eine allgemeine Suche vorhanden ist, diese zusätzlich anwenden
if query:
query_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')
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)

1172
data/customers.csv Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,14 +1,14 @@
version: '3.8'
services: services:
web: web:
build: . build: .
ports: ports:
- "5001:5000" - "5001:5000"
volumes: volumes:
- .:/app - ./data:/app/data
environment: environment:
- FLASK_APP=app.py - FLASK_APP=app.py
- FLASK_ENV=development - FLASK_ENV=production
- FLASK_DEBUG=1 - SECRET_KEY=your-super-secret-key-here
- LOGIN_PASSWORD=changeme
- ALLOWED_IP_RANGES=213.178.68.218/29,192.168.0.0/24,192.168.177.0/24
command: flask run --host=0.0.0.0 command: flask run --host=0.0.0.0

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

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

@@ -0,0 +1,238 @@
body {
min-height: 100vh;
display: flex;
flex-direction: column;
margin: 0;
padding: 0;
}
.main-content {
flex: 1 0 auto;
padding: 2rem 0;
margin-bottom: 4rem; /* Platz für die fixierte Fußzeile */
}
.search-container {
max-width: 800px;
margin: 0 auto;
}
.result-card {
margin-bottom: 1rem;
transition: all 0.3s ease;
}
.result-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
}
.loading {
display: none;
text-align: center;
margin: 2rem 0;
}
.phone-link, .email-link, .address-link, .customer-link {
text-decoration: none;
color: #0d6efd;
}
.phone-link:hover, .email-link:hover, .address-link:hover, .customer-link:hover {
text-decoration: underline;
}
.search-icon, .reset-icon {
position: absolute;
top: 50%;
transform: translateY(-50%);
color: #6c757d;
cursor: pointer;
display: none;
z-index: 10;
}
.reset-icon {
right: 10px;
}
.search-icon {
right: 35px;
}
.reset-icon.visible {
display: block;
}
.search-icon.visible {
display: block;
}
.customer-number {
color: #6c757d;
font-size: 0.9em;
}
.footer {
flex-shrink: 0;
text-align: center;
padding: 1rem;
background-color: #f8f9fa;
border-top: 1px solid #dee2e6;
width: 100%;
position: fixed;
bottom: 0;
left: 0;
z-index: 100;
}
.share-feedback {
position: fixed;
bottom: 20px;
right: 20px;
background: #28a745;
color: white;
padding: 8px 16px;
border-radius: 4px;
display: none;
animation: fadeOut 2s forwards;
z-index: 1000;
}
@keyframes fadeOut {
0% { opacity: 1; }
70% { opacity: 1; }
100% { opacity: 0; }
}
.card-actions {
display: flex;
justify-content: flex-end;
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid #e9ecef;
}
.share-button {
padding: 0.5rem 1rem;
border-radius: 20px;
font-size: 0.9rem;
background-color: #0d6efd;
color: white;
border: none;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
gap: 0.5rem;
}
.share-button:hover {
background-color: #0b5ed7;
transform: translateY(-1px);
}
.share-button i {
font-size: 1rem;
}
.search-fields {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
margin-bottom: 1rem;
}
.search-field {
position: relative;
}
.input-group {
position: relative;
}
.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;
}
.general-search {
max-width: 800px;
margin: 0 auto;
}
.general-search .input-group {
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.general-search .form-control {
height: 3.5rem;
font-size: 1.2rem;
padding: 0.75rem 1rem;
}
.general-search .search-icon,
.general-search .reset-icon {
font-size: 1.2rem;
padding: 0 1rem;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

View File

@@ -6,127 +6,62 @@
<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>
<div class="search-container">
<h1 class="text-center mb-4">Kundensuche</h1>
<div class="general-search mb-4">
<div class="input-group">
<input type="text" id="q" class="form-control form-control-lg" placeholder="Allgemeine Suche" oninput="searchCustomers()">
<i class="fas fa-times reset-icon" onclick="clearInput('q')"></i>
<i class="fas fa-search search-icon"></i>
</div>
</div>
<div class="search-fields"> <div class="search-fields">
<div class="search-field"> <div class="search-field">
<input type="text" id="nameInput" class="form-control" <div class="input-group">
placeholder="Name..."> <input type="text" id="nameInput" class="form-control" placeholder="Name" oninput="searchCustomers()">
</div> <i class="fas fa-times reset-icon" onclick="clearInput('nameInput')"></i>
<div class="search-field"> <i class="fas fa-search search-icon"></i>
<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>
<div class="input-group mb-4 position-relative"> <div class="search-field">
<input type="text" id="searchInput" class="form-control form-control-lg" <div class="input-group">
placeholder="Allgemeine Suche..."> <input type="text" id="ortInput" class="form-control" placeholder="Ort" oninput="searchCustomers()">
<span class="search-icon">🔍</span> <i class="fas fa-times reset-icon" onclick="clearInput('ortInput')"></i>
<i class="fas fa-search search-icon"></i>
</div>
</div>
<div class="search-field">
<div class="input-group">
<input type="text" id="nummerInput" class="form-control" placeholder="Kundennummer" oninput="searchCustomers()">
<i class="fas fa-times reset-icon" onclick="clearInput('nummerInput')"></i>
<i class="fas fa-search search-icon"></i>
</div>
</div>
<div class="search-field">
<div class="input-group">
<input type="text" id="plzInput" class="form-control" placeholder="PLZ" oninput="searchCustomers()">
<i class="fas fa-times reset-icon" onclick="clearInput('plzInput')"></i>
<i class="fas fa-search search-icon"></i>
</div>
</div>
</div>
<div class="result-counts">
<span id="resultCount" class="result-count"></span>
</div> </div>
<div id="loading" class="loading"> <div id="loading" class="loading">
@@ -135,28 +70,53 @@
</div> </div>
</div> </div>
<div id="results" class="mt-4"> <div id="results"></div>
<!-- Hier werden die Suchergebnisse angezeigt -->
</div> </div>
</div> </div>
</div> </div>
<div id="shareFeedback" class="share-feedback"> <div id="shareFeedback" class="share-feedback">
Link kopiert! Link in die Zwischenablage kopiert!
</div> </div>
<footer class="footer"> <footer class="footer">
<p class="mb-0">(c) 2025 <a href="https://medisoftware.de" target="_blank" rel="noopener noreferrer" class="text-decoration-none">medisoftware</a></p> <div class="footer-content">
Made with ❤️ and 🍺 by <a href="https://www.medisoftware.de" target="_blank" class="footer-link">medisoftware</a>
<div style="font-size: 0.8em;">Version: v1.2.3</div>
</div>
</footer> </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 telLink = '0' + cleaned; const clientIP = '{{ request.headers.get("X-Forwarded-For", request.remote_addr) }}';
return `<a href="tel:${telLink}" class="phone-link">${phone}</a>`; const allowedIPRanges = '{{ allowed_ip_ranges }}'.split(',');
// Überprüfen, ob die Client-IP in einem der erlaubten Bereiche liegt
const isAllowed = allowedIPRanges.some(range => isIPInSubnet(clientIP, range.trim()));
// Entferne alle nicht-numerischen Zeichen
let cleanNumber = phone.replace(/\D/g, '');
// Füge eine führende 0 hinzu, wenn isAllowed true ist
if (isAllowed) {
cleanNumber = '0' + cleanNumber;
}
// Formatiere die Nummer
let formattedNumber = cleanNumber;
if (cleanNumber.length === 11) {
formattedNumber = cleanNumber.replace(/(\d{4})(\d{7})/, '$1-$2');
} else if (cleanNumber.length === 10) {
formattedNumber = cleanNumber.replace(/(\d{3})(\d{7})/, '$1-$2');
}
// Erstelle den Link
return `<a href="tel:${cleanNumber}" class="phone-link">${formattedNumber}</a>`;
} }
function createEmailLink(email) { function createEmailLink(email) {
@@ -164,23 +124,66 @@
return `<a href="mailto:${email}" class="email-link">${email}</a>`; return `<a href="mailto:${email}" class="email-link">${email}</a>`;
} }
function highlightText(text, searchTerm) {
if (!searchTerm) return text;
const regex = new RegExp(`(${searchTerm})`, 'gi');
return text.replace(regex, '<mark>$1</mark>');
}
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) }}';
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(',');
// Überprüfe, ob die Client-IP in einem der erlaubten Bereiche liegt
const isAllowed = allowedIPRanges.some(range => {
const trimmedRange = range.trim();
return isIPInSubnet(clientIP, trimmedRange);
});
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() {
@@ -201,101 +204,149 @@
await navigator.clipboard.writeText(url.toString()); await navigator.clipboard.writeText(url.toString());
showCopyFeedback(); showCopyFeedback();
} catch (err) { } catch (err) {
console.error('Fehler beim Kopieren:', err); // Fehlerbehandlung ohne console.log
} }
} }
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('resultCount').textContent =
const fachrichtung = document.getElementById('fachrichtungInput').value; generalCount > 0 ? `${generalCount} Treffer gefunden` : '';
const query = document.getElementById('searchInput').value; document.getElementById('resultCount').classList.toggle('visible', generalCount > 0);
}
// Prüfe, ob mindestens ein Suchfeld ausgefüllt ist function displayResults(results) {
if (!name && !ort && !kundennummer && !fachrichtung && !query) { const resultsDiv = document.getElementById('results');
document.getElementById('results').innerHTML = ''; resultsDiv.innerHTML = '';
if (results.length === 0) {
resultsDiv.innerHTML = '<p>Keine Ergebnisse gefunden.</p>';
return; return;
} }
// Lade-Animation anzeigen const searchTerm = document.getElementById('q').value;
document.getElementById('loading').style.display = 'block';
document.getElementById('results').innerHTML = '';
// URL-Parameter erstellen results.forEach(customer => {
const params = new URLSearchParams(); const card = document.createElement('div');
if (name) params.append('name', name); card.className = 'customer-card';
if (ort) params.append('ort', ort); card.innerHTML = `
if (kundennummer) params.append('kundennummer', kundennummer); <div class="customer-info">
if (fachrichtung) params.append('fachrichtung', fachrichtung); <h5 class="mb-1">${highlightText(customer.name, searchTerm)}</h5>
if (query) params.append('q', query); <p class="mb-1 customer-number">${createCustomerLink(customer.nummer)}</p>
<p class="mb-1">${createAddressLink(customer.strasse, customer.plz, customer.ort)}</p>
fetch(`/search?${params.toString()}`) <p class="mb-1">Tel: ${createPhoneLink(customer.telefon)}</p>
.then(response => { ${customer.mobil ? `<p class="mb-1">Mobil: ${createPhoneLink(customer.mobil)}</p>` : ''}
if (!response.ok) { ${customer.email ? `<p class="mb-1">E-Mail: ${createEmailLink(customer.email)}</p>` : ''}
return response.json().then(data => { ${customer.bemerkung ? `<p class="mb-1">Bemerkung: ${customer.bemerkung}</p>` : ''}
throw new Error(data.error || 'Ein Fehler ist aufgetreten'); </div>
<div class="card-actions">
<button class="share-button" onclick="copyCustomerLink('${customer.nummer}')">
<i class="fas fa-share-alt"></i> Teilen
</button>
</div>
`;
resultsDiv.appendChild(card);
}); });
} }
return response.json();
}) function searchCustomers() {
const q = document.getElementById('q').value;
const name = document.getElementById('nameInput').value;
const ort = document.getElementById('ortInput').value;
const nummer = document.getElementById('nummerInput').value;
const plz = document.getElementById('plzInput').value;
// Zeige das Lade-Icon
document.getElementById('loading').style.display = 'block';
// Baue die Suchanfrage
const params = new URLSearchParams();
if (q) params.append('q', q);
if (name) params.append('name', name);
if (ort) params.append('ort', ort);
if (nummer) params.append('nummer', nummer);
if (plz) params.append('plz', plz);
// Führe die Suche durch
fetch('/search?' + params.toString())
.then(response => response.json())
.then(data => { .then(data => {
// Verstecke das Lade-Icon
document.getElementById('loading').style.display = 'none';
if (data.error) {
return;
}
lastResults = data;
updateResultCounts();
const resultsDiv = document.getElementById('results'); const resultsDiv = document.getElementById('results');
resultsDiv.innerHTML = ''; resultsDiv.innerHTML = '';
if (data.length === 0) { if (data.length === 0) {
resultsDiv.innerHTML = '<div class="alert alert-info">Keine Ergebnisse gefunden.</div>'; resultsDiv.innerHTML = '<p class="text-center text-muted">Keine Ergebnisse gefunden</p>';
return; return;
} }
data.forEach(customer => { data.forEach(customer => {
const card = document.createElement('div'); const card = document.createElement('div');
card.className = 'card result-card'; card.className = 'customer-card';
card.innerHTML = ` card.innerHTML = `
<div class="card-body"> <div class="customer-info">
<h5 class="card-title"> <h5 class="mb-1">${highlightText(customer.name, q || name)}</h5>
${customer.Vorname} ${customer.Nachname} <p class="mb-1 customer-number">${createCustomerLink(customer.nummer)}</p>
<span class="customer-number ms-2">(Kunde: ${createCustomerLink(customer.Nummer)})</span> <p class="mb-1">${createAddressLink(customer.strasse, customer.plz, customer.ort)}</p>
</h5> <p class="mb-1">Tel: ${createPhoneLink(customer.telefon)}</p>
<p class="card-text"> ${customer.mobil ? `<p class="mb-1">Mobil: ${createPhoneLink(customer.mobil)}</p>` : ''}
<strong>Fachrichtung:</strong> ${customer.Fachrichtung || 'N/A'}<br> ${customer.email ? `<p class="mb-1">E-Mail: ${createEmailLink(customer.email)}</p>` : ''}
<strong>Adresse:</strong> ${createAddressLink(customer.Strasse, customer.PLZ, customer.Ort)}<br> ${customer.bemerkung ? `<p class="mb-1">Bemerkung: ${customer.bemerkung}</p>` : ''}
<strong>Telefon:</strong> ${createPhoneLink(customer.Tel)}<br>
<strong>E-Mail:</strong> ${createEmailLink(customer.mail)}
</p>
<div class="card-actions">
<button class="btn btn-outline-primary share-button" onclick="copyCustomerLink('${customer.Nummer}')">
🔗 Teilen
</button>
</div> </div>
<div class="card-actions">
<button class="share-button" onclick="copyCustomerLink('${customer.nummer}')">
<i class="fas fa-share-alt"></i> Teilen
</button>
</div> </div>
`; `;
resultsDiv.appendChild(card); resultsDiv.appendChild(card);
}); });
}) })
.catch(error => { .catch(error => {
console.error('Fehler:', error);
document.getElementById('results').innerHTML =
`<div class="alert alert-danger">${error.message}</div>`;
})
.finally(() => {
document.getElementById('loading').style.display = 'none'; document.getElementById('loading').style.display = 'none';
}); });
} }
// Event-Listener für die Live-Suche // Event-Listener für die Live-Suche
const searchInputs = [ const searchInputs = [
document.getElementById('q'),
document.getElementById('nameInput'), document.getElementById('nameInput'),
document.getElementById('ortInput'), document.getElementById('ortInput'),
document.getElementById('kundennummerInput'), document.getElementById('nummerInput'),
document.getElementById('fachrichtungInput'), document.getElementById('plzInput')
document.getElementById('searchInput')
]; ];
searchInputs.forEach(input => { const resetIcons = [
document.querySelector('.reset-icon[onclick="clearInput(\'q\')"]'),
document.querySelector('.reset-icon[onclick="clearInput(\'nameInput\')"]'),
document.querySelector('.reset-icon[onclick="clearInput(\'ortInput\')"]'),
document.querySelector('.reset-icon[onclick="clearInput(\'nummerInput\')"]'),
document.querySelector('.reset-icon[onclick="clearInput(\'plzInput\')"]')
];
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();
}); });
}); });
@@ -305,16 +356,14 @@
const name = urlParams.get('name'); const name = urlParams.get('name');
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 plz = urlParams.get('plz');
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('nummerInput').value = kundennummer;
if (fachrichtung) document.getElementById('fachrichtungInput').value = fachrichtung; if (plz) document.getElementById('plzInput').value = plz;
if (query) document.getElementById('searchInput').value = query;
if (name || ort || kundennummer || fachrichtung || query) { if (name || ort || kundennummer || plz) {
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>