12 Commits

7 changed files with 939 additions and 613 deletions

View File

@@ -5,6 +5,24 @@ Alle wichtigen Änderungen an diesem Projekt werden in dieser Datei dokumentiert
Das Format basiert auf [Keep a Changelog](https://keepachangelog.com/de/1.0.0/), Das Format basiert auf [Keep a Changelog](https://keepachangelog.com/de/1.0.0/),
und dieses Projekt adhäriert zu [Semantic Versioning](https://semver.org/lang/de/). und dieses Projekt adhäriert zu [Semantic Versioning](https://semver.org/lang/de/).
## [Unreleased]
### Hinzugefügt
- CSV-Export-Funktion für Suchergebnisse
- Export-Button in der Benutzeroberfläche
- Automatische Formatierung der CSV-Datei mit allen relevanten Kundendaten
## [1.2.16] - 2024-03-21
### Geändert
- Verbesserte Suchfunktion: Highlighting für allgemeine Suche in allen Feldern
- Optimierte Reset-Buttons in den Suchfeldern
- Verbesserte CSS-Styles für die Suchfeld-Icons
## [1.2.15] - 2024-03-20
### Hinzugefügt
- Autovervollständigung für das Ort-Feld
- Neue API-Route für Ortsvorschläge
- Optimierte SQL-Abfragen für die Ortssuche
## [1.2.14] - 2024-03-20 ## [1.2.14] - 2024-03-20
### Hinzugefügt ### Hinzugefügt
- Autovervollständigung für das Fachrichtungsfeld - Autovervollständigung für das Fachrichtungsfeld

View File

@@ -1,6 +1,6 @@
# Medi-Customers # medisoftware Kundensuche
Eine moderne Webanwendung zur Suche und Verwaltung von Kundendaten, die MEDISOFT und MEDICONSULT Daten kombiniert. Eine einfache und effiziente Kundensuche für medisoftware Kunden.
## Features ## Features
@@ -14,7 +14,7 @@ Eine moderne Webanwendung zur Suche und Verwaltung von Kundendaten, die MEDISOFT
## Version ## Version
Aktuelle Version: 1.2.14 Aktuelle Version: 1.2.16
## Installation ## Installation

216
app.py
View File

@@ -5,61 +5,48 @@ import logging
import numpy as np import numpy as np
from datetime import datetime, timedelta from datetime import datetime, timedelta
from dotenv import load_dotenv from dotenv import load_dotenv
import requests
from collections import defaultdict
import ipaddress
import csv
import sqlite3 import sqlite3
from functools import wraps from functools import wraps
from contextlib import contextmanager
import time
import threading
app = Flask(__name__, static_folder='static') app = Flask(__name__, static_folder='static')
app.secret_key = os.getenv('SECRET_KEY', 'default-secret-key') app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY', 'dev')
app.config['ALLOWED_IP_RANGES'] = os.getenv('ALLOWED_IP_RANGES', '192.168.0.0/16,10.0.0.0/8').split(',')
app.config['VERSION'] = '1.2.17'
app.config['DATABASE'] = 'data/customers.db'
app.config['DATABASE_TIMEOUT'] = 20
app.config['DATABASE_POOL_SIZE'] = 5
logging.basicConfig(level=logging.INFO) logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Version der Anwendung # Thread-lokaler Speicher für Datenbankverbindungen
VERSION = "1.2.14" thread_local = threading.local()
# 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 isIPInSubnet(ip, subnet):
"""Überprüft, ob eine IP-Adresse in einem Subnetz liegt."""
try:
# Teile die IP und das Subnetz in ihre Komponenten
subnet_ip, bits = subnet.split('/')
ip_parts = [int(x) for x in ip.split('.')]
subnet_parts = [int(x) for x in subnet_ip.split('.')]
# Konvertiere IPs in 32-bit Zahlen
ip_num = (ip_parts[0] << 24) | (ip_parts[1] << 16) | (ip_parts[2] << 8) | ip_parts[3]
subnet_num = (subnet_parts[0] << 24) | (subnet_parts[1] << 16) | (subnet_parts[2] << 8) | subnet_parts[3]
# Erstelle die Subnetzmaske
mask = ~((1 << (32 - int(bits))) - 1)
# Prüfe, ob die IP im Subnetz liegt
return (ip_num & mask) == (subnet_num & mask)
except Exception as e:
logger.error(f"Fehler bei der IP-Überprüfung: {str(e)}")
return False
def get_db_connection(): def get_db_connection():
"""Erstellt eine neue Datenbankverbindung mit Timeout""" """Erstellt eine neue Datenbankverbindung für den aktuellen Thread"""
conn = sqlite3.connect(DB_FILE, timeout=20) if not hasattr(thread_local, "connection"):
conn.row_factory = sqlite3.Row thread_local.connection = sqlite3.connect(app.config['DATABASE'], timeout=app.config['DATABASE_TIMEOUT'])
return conn thread_local.connection.row_factory = sqlite3.Row
return thread_local.connection
@contextmanager
def get_db():
"""Context Manager für Datenbankverbindungen"""
conn = get_db_connection()
try:
yield conn
except Exception:
conn.rollback()
raise
finally:
conn.commit()
def init_db(): def init_db():
"""Initialisiert die SQLite-Datenbank mit der notwendigen Tabelle.""" """Initialisiert die SQLite-Datenbank mit der notwendigen Tabelle."""
conn = get_db_connection() with get_db() as conn:
c = conn.cursor() c = conn.cursor()
try: try:
@@ -86,36 +73,45 @@ def init_db():
) )
''') ''')
# Erstelle Indizes für alle Suchfelder # Optimierte Indizes für die häufigsten Suchanfragen
c.execute('CREATE INDEX IF NOT EXISTS idx_customers_nummer ON customers(nummer)') c.execute('CREATE INDEX IF NOT EXISTS idx_customers_name_ort ON customers(name, ort)')
c.execute('CREATE INDEX IF NOT EXISTS idx_customers_name ON customers(name)')
c.execute('CREATE INDEX IF NOT EXISTS idx_customers_strasse ON customers(strasse)')
c.execute('CREATE INDEX IF NOT EXISTS idx_customers_plz ON customers(plz)')
c.execute('CREATE INDEX IF NOT EXISTS idx_customers_ort ON customers(ort)')
c.execute('CREATE INDEX IF NOT EXISTS idx_customers_telefon ON customers(telefon)')
c.execute('CREATE INDEX IF NOT EXISTS idx_customers_mobil ON customers(mobil)')
c.execute('CREATE INDEX IF NOT EXISTS idx_customers_email ON customers(email)')
c.execute('CREATE INDEX IF NOT EXISTS idx_customers_fachrichtung ON customers(fachrichtung)') c.execute('CREATE INDEX IF NOT EXISTS idx_customers_fachrichtung ON customers(fachrichtung)')
c.execute('CREATE INDEX IF NOT EXISTS idx_customers_tag ON customers(tag)') c.execute('CREATE INDEX IF NOT EXISTS idx_customers_tag ON customers(tag)')
c.execute('CREATE INDEX IF NOT EXISTS idx_customers_handy ON customers(handy)') c.execute('CREATE INDEX IF NOT EXISTS idx_customers_plz ON customers(plz)')
c.execute('CREATE INDEX IF NOT EXISTS idx_customers_tele_firma ON customers(tele_firma)')
# Erstelle einen zusammengesetzten Index für die häufigste Suchkombination # Zusammengesetzter Index für die häufigste Suchkombination
c.execute('CREATE INDEX IF NOT EXISTS idx_customers_name_ort ON customers(name, ort)') c.execute('CREATE INDEX IF NOT EXISTS idx_customers_search ON customers(name, ort, fachrichtung, tag)')
conn.commit()
logger.info('Datenbank initialisiert') logger.info('Datenbank initialisiert')
except Exception as e: except Exception as e:
logger.error(f'Fehler bei der Datenbankinitialisierung: {str(e)}') logger.error(f'Fehler bei der Datenbankinitialisierung: {str(e)}')
raise raise
finally:
conn.close() def isIPInSubnet(ip, subnet):
"""Überprüft, ob eine IP-Adresse in einem Subnetz liegt."""
try:
# Teile die IP und das Subnetz in ihre Komponenten
subnet_ip, bits = subnet.split('/')
ip_parts = [int(x) for x in ip.split('.')]
subnet_parts = [int(x) for x in subnet_ip.split('.')]
# Konvertiere IPs in 32-bit Zahlen
ip_num = (ip_parts[0] << 24) | (ip_parts[1] << 16) | (ip_parts[2] << 8) | ip_parts[3]
subnet_num = (subnet_parts[0] << 24) | (subnet_parts[1] << 16) | (subnet_parts[2] << 8) | subnet_parts[3]
# Erstelle die Subnetzmaske
mask = ~((1 << (32 - int(bits))) - 1)
# Prüfe, ob die IP im Subnetz liegt
return (ip_num & mask) == (subnet_num & mask)
except Exception as e:
logger.error(f"Fehler bei der IP-Überprüfung: {str(e)}")
return False
def import_csv(): def import_csv():
"""Importiert die CSV-Datei in die Datenbank""" """Importiert die CSV-Datei in die Datenbank"""
conn = None
try: try:
conn = get_db_connection() with get_db() as conn:
c = conn.cursor() c = conn.cursor()
# Lösche bestehende Daten # Lösche bestehende Daten
@@ -128,18 +124,24 @@ def import_csv():
df.columns = df.columns.str.strip().str.replace('"', '') df.columns = df.columns.str.strip().str.replace('"', '')
df = df.apply(lambda x: x.str.strip().str.replace('"', '') if x.dtype == "object" else x) df = df.apply(lambda x: x.str.strip().str.replace('"', '') if x.dtype == "object" else x)
for _, row in df.iterrows(): # Filtere Datensätze mit Fachrichtung "intern"
c.execute(''' df = df[df['Fachrichtung'].str.lower() != 'intern']
# Bereite die Daten für den Batch-Insert vor
data = [(
row['VorNachname'], row['Nummer'], row['Strasse'], row['PLZ'], row['Ort'],
row['Tel'], row['Tel'], row['mail'], row['Fachrichtung'], 'medisoft',
row['Handy'], row['Tele Firma'], row['Kontakt1'], row['Kontakt2'], row['Kontakt3']
) for _, row in df.iterrows()]
# Führe Batch-Insert durch
c.executemany('''
INSERT INTO customers ( INSERT INTO customers (
name, nummer, strasse, plz, ort, telefon, mobil, email, name, nummer, strasse, plz, ort, telefon, mobil, email,
fachrichtung, tag, handy, tele_firma, kontakt1, kontakt2, kontakt3 fachrichtung, tag, handy, tele_firma, kontakt1, kontakt2, kontakt3
) )
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
''', ( ''', data)
row['VorNachname'], row['Nummer'], row['Strasse'], row['PLZ'], row['Ort'],
row['Tel'], row['Tel'], row['mail'], row['Fachrichtung'], 'medisoft',
row['Handy'], row['Tele Firma'], row['Kontakt1'], row['Kontakt2'], row['Kontakt3']
))
else: else:
logger.warning("MEDISOFT CSV-Datei nicht gefunden") logger.warning("MEDISOFT CSV-Datei nicht gefunden")
@@ -150,33 +152,31 @@ def import_csv():
df_snk.columns = df_snk.columns.str.strip().str.replace('"', '') df_snk.columns = df_snk.columns.str.strip().str.replace('"', '')
df_snk = df_snk.apply(lambda x: x.str.strip().str.replace('"', '') if x.dtype == "object" else x) df_snk = df_snk.apply(lambda x: x.str.strip().str.replace('"', '') if x.dtype == "object" else x)
for _, row in df_snk.iterrows(): # Filtere Datensätze mit Fachrichtung "intern"
c.execute(''' df_snk = df_snk[df_snk['Fachrichtung'].str.lower() != 'intern']
# Bereite die Daten für den Batch-Insert vor
data = [(
row['VorNachname'], row['Nummer'], row['Strasse'], row['PLZ'], row['Ort'],
row['Tel'], row['Tel'], row['mail'], row['Fachrichtung'], 'mediconsult',
row['Handy'], row['Tele Firma'], row['Kontakt1'], row['Kontakt2'], row['Kontakt3']
) for _, row in df_snk.iterrows()]
# Führe Batch-Insert durch
c.executemany('''
INSERT INTO customers ( INSERT INTO customers (
name, nummer, strasse, plz, ort, telefon, mobil, email, name, nummer, strasse, plz, ort, telefon, mobil, email,
fachrichtung, tag, handy, tele_firma, kontakt1, kontakt2, kontakt3 fachrichtung, tag, handy, tele_firma, kontakt1, kontakt2, kontakt3
) )
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
''', ( ''', data)
row['VorNachname'], row['Nummer'], row['Strasse'], row['PLZ'], row['Ort'],
row['Tel'], row['Tel'], row['mail'], row['Fachrichtung'], 'mediconsult',
row['Handy'], row['Tele Firma'], row['Kontakt1'], row['Kontakt2'], row['Kontakt3']
))
else: else:
logger.warning("MEDICONSULT CSV-Datei nicht gefunden") logger.warning("MEDICONSULT CSV-Datei nicht gefunden")
conn.commit()
logger.info("CSV-Daten erfolgreich in die Datenbank importiert") logger.info("CSV-Daten erfolgreich in die Datenbank importiert")
except Exception as e: except Exception as e:
logger.error(f"Fehler beim Importieren der CSV-Datei: {str(e)}") logger.error(f"Fehler beim Importieren der CSV-Datei: {str(e)}")
raise raise
finally:
if conn:
conn.close()
def clean_dataframe(df):
"""Konvertiert NaN-Werte in None für JSON-Kompatibilität"""
return df.replace({np.nan: None})
@app.route('/login', methods=['GET', 'POST']) @app.route('/login', methods=['GET', 'POST'])
def login(): def login():
@@ -184,7 +184,7 @@ def login():
client_ip = request.headers.get('X-Forwarded-For', request.remote_addr) client_ip = request.headers.get('X-Forwarded-For', request.remote_addr)
# Überprüfe, ob die Client-IP in einem der erlaubten Bereichen liegt # Überprüfe, ob die Client-IP in einem der erlaubten Bereichen liegt
is_allowed = any(isIPInSubnet(client_ip, range.strip()) for range in ALLOWED_IP_RANGES if range.strip()) is_allowed = any(isIPInSubnet(client_ip, range.strip()) for range in app.config['ALLOWED_IP_RANGES'] if range.strip())
if is_allowed: if is_allowed:
logger.info(f"Client-IP {client_ip} ist in einem erlaubten Bereich, automatischer Login") logger.info(f"Client-IP {client_ip} ist in einem erlaubten Bereich, automatischer Login")
@@ -193,7 +193,7 @@ def login():
if request.method == 'POST': if request.method == 'POST':
password = request.form.get('password') password = request.form.get('password')
if password == STATIC_PASSWORD: if password == os.environ.get('LOGIN_PASSWORD'):
session['logged_in'] = True session['logged_in'] = True
logger.info("Erfolgreicher Login") logger.info("Erfolgreicher Login")
return redirect(url_for('index')) return redirect(url_for('index'))
@@ -213,8 +213,8 @@ def index():
client_ip = request.headers.get('X-Forwarded-For', request.remote_addr) client_ip = request.headers.get('X-Forwarded-For', request.remote_addr)
logger.info(f"Client-IP: {client_ip}") logger.info(f"Client-IP: {client_ip}")
logger.info(f"Erlaubte IP-Bereiche: {ALLOWED_IP_RANGES}") logger.info(f"Erlaubte IP-Bereiche: {app.config['ALLOWED_IP_RANGES']}")
return render_template('index.html', allowed_ip_ranges=','.join(ALLOWED_IP_RANGES), version=VERSION) return render_template('index.html', allowed_ip_ranges=','.join(app.config['ALLOWED_IP_RANGES']), version=app.config['VERSION'])
@app.route('/search', methods=['GET', 'POST']) @app.route('/search', methods=['GET', 'POST'])
def search(): def search():
@@ -235,7 +235,7 @@ def search():
fachrichtung = request.args.get('fachrichtung', '') fachrichtung = request.args.get('fachrichtung', '')
tag = request.args.get('tag', 'medisoft') tag = request.args.get('tag', 'medisoft')
conn = get_db_connection() with get_db() as conn:
c = conn.cursor() c = conn.cursor()
# Baue die SQL-Abfrage # Baue die SQL-Abfrage
@@ -316,9 +316,8 @@ def search():
sql_query += " ORDER BY name LIMIT 100" sql_query += " ORDER BY name LIMIT 100"
# Führe die Abfrage aus # Führe die Abfrage aus
cursor = conn.cursor() c.execute(sql_query, params)
cursor.execute(sql_query, params) results = c.fetchall()
results = cursor.fetchall()
formatted_results = [] formatted_results = []
for row in results: for row in results:
@@ -341,7 +340,6 @@ def search():
} }
formatted_results.append(customer) formatted_results.append(customer)
conn.close()
return jsonify(formatted_results) return jsonify(formatted_results)
except Exception as e: except Exception as e:
@@ -352,7 +350,7 @@ def search():
def get_fachrichtungen(): def get_fachrichtungen():
try: try:
search_term = request.args.get('q', '').lower() search_term = request.args.get('q', '').lower()
conn = get_db_connection() with get_db() as conn:
c = conn.cursor() c = conn.cursor()
# Hole alle eindeutigen Fachrichtungen, die mit dem Suchbegriff übereinstimmen # Hole alle eindeutigen Fachrichtungen, die mit dem Suchbegriff übereinstimmen
@@ -367,13 +365,35 @@ def get_fachrichtungen():
''', (f'%{search_term}%',)) ''', (f'%{search_term}%',))
fachrichtungen = [row[0] for row in c.fetchall()] fachrichtungen = [row[0] for row in c.fetchall()]
conn.close()
return jsonify(fachrichtungen) return jsonify(fachrichtungen)
except Exception as e: except Exception as e:
logger.error(f"Fehler beim Abrufen der Fachrichtungen: {str(e)}") logger.error(f"Fehler beim Abrufen der Fachrichtungen: {str(e)}")
return jsonify([]) return jsonify([])
@app.route('/api/orte')
def get_orte():
try:
search_term = request.args.get('q', '').lower()
with get_db() as conn:
c = conn.cursor()
# Hole alle eindeutigen Orte, die mit dem Suchbegriff übereinstimmen
c.execute('''
SELECT DISTINCT ort
FROM customers
WHERE ort IS NOT NULL
AND ort != ''
AND LOWER(ort) LIKE ?
ORDER BY ort
LIMIT 10
''', (f'%{search_term}%',))
orte = [row[0] for row in c.fetchall()]
return jsonify(orte)
except Exception as e:
logger.error(f"Fehler beim Abrufen der Orte: {str(e)}")
return jsonify([])
def init_app(app): def init_app(app):
"""Initialisiert die Anwendung mit allen notwendigen Einstellungen.""" """Initialisiert die Anwendung mit allen notwendigen Einstellungen."""
with app.app_context(): with app.app_context():
@@ -382,10 +402,10 @@ def init_app(app):
os.makedirs('data', exist_ok=True) os.makedirs('data', exist_ok=True)
# Lösche die alte Datenbank, falls sie existiert # Lösche die alte Datenbank, falls sie existiert
if os.path.exists(DB_FILE): if os.path.exists(app.config['DATABASE']):
try: try:
os.remove(DB_FILE) os.remove(app.config['DATABASE'])
logger.info(f"Alte Datenbank {DB_FILE} wurde gelöscht") logger.info(f"Alte Datenbank {app.config['DATABASE']} wurde gelöscht")
except Exception as e: except Exception as e:
logger.error(f"Fehler beim Löschen der alten Datenbank: {str(e)}") logger.error(f"Fehler beim Löschen der alten Datenbank: {str(e)}")

View File

@@ -1,24 +0,0 @@
.result-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.result-tag {
padding: 4px 8px;
border-radius: 4px;
font-size: 0.9em;
font-weight: 500;
text-transform: uppercase;
}
.tag-medisoft {
background-color: #e3f2fd;
color: #1976d2;
}
.tag-mediconsult {
background-color: #f3e5f5;
color: #7b1fa2;
}

View File

@@ -151,6 +151,36 @@ body {
position: relative; position: relative;
} }
.reset-icon {
position: absolute;
right: 40px;
top: 50%;
transform: translateY(-50%);
cursor: pointer;
color: #6c757d;
z-index: 10;
padding: 0.375rem;
display: none;
}
.input-group input:not(:placeholder-shown) + .reset-icon {
display: block;
}
.reset-icon:hover {
color: #dc3545;
}
.search-icon {
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
color: #6c757d;
z-index: 10;
padding: 0.375rem;
}
.result-counts { .result-counts {
display: flex; display: flex;
justify-content: center; justify-content: center;
@@ -182,18 +212,78 @@ body {
.customer-card { .customer-card {
background: white; background: white;
border-radius: 8px; border-radius: 8px;
padding: 1.5rem; box-shadow: 0 2px 4px rgba(0,0,0,0.1);
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; margin-bottom: 1rem;
padding: 1rem;
}
.customer-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 0.5rem;
}
.customer-name {
margin: 0;
font-size: 1.2rem;
color: #333;
}
.customer-actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
.customer-details {
font-size: 0.9rem;
color: #666;
}
.customer-details p {
margin: 0.25rem 0;
}
.customer-details strong {
color: #333;
}
.phone-link, .email-link, .customer-link {
color: #007bff;
text-decoration: none;
}
.phone-link:hover, .email-link:hover, .customer-link:hover {
text-decoration: underline;
}
.address-text {
margin-right: 0.5rem;
}
.address-link, .route-link {
color: #6c757d;
text-decoration: none;
margin-left: 0.5rem;
}
.address-link:hover, .route-link:hover {
color: #343a40;
}
.location-pin, .route-pin {
font-size: 0.9rem;
}
.badge {
font-size: 0.8rem;
padding: 0.35em 0.65em;
}
.btn-sm {
padding: 0.25rem 0.5rem;
font-size: 0.875rem;
} }
.footer-content { .footer-content {
@@ -272,22 +362,26 @@ body {
background-color: #ff9800; background-color: #ff9800;
} }
/* Adress-Links */ .autocomplete-items {
.address-text { position: absolute;
margin-right: 5px; border: 1px solid #d4d4d4;
border-top: none;
z-index: 99;
top: 100%;
left: 0;
right: 0;
background-color: white;
max-height: 200px;
overflow-y: auto;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
} }
.address-link, .route-link { .autocomplete-items div {
color: #666; padding: 8px 12px;
text-decoration: none; cursor: pointer;
margin-left: 5px; background-color: white;
transition: color 0.2s;
} }
.address-link:hover, .route-link:hover { .autocomplete-items div:hover {
color: #0d6efd; background-color: #f8f9fa;
}
.location-pin, .route-pin {
font-size: 1.1em;
} }

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

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

View File

@@ -3,10 +3,13 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="client-ip" content="{{ request.headers.get('X-Forwarded-For', request.remote_addr) }}">
<meta name="allowed-ip-ranges" content="{{ allowed_ip_ranges }}">
<title>medisoftware Kundensuche</title> <title>medisoftware Kundensuche</title>
<link rel="icon" type="image/x-icon" href="{{ url_for('static', filename='favicon.ico') }}"> <link rel="icon" type="image/x-icon" href="{{ url_for('static', filename='favicon.ico') }}">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet"> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" rel="stylesheet"> <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
<link href="{{ url_for('static', filename='css/styles.css') }}" rel="stylesheet"> <link href="{{ url_for('static', filename='css/styles.css') }}" rel="stylesheet">
</head> </head>
<body> <body>
@@ -78,8 +81,11 @@
</div> </div>
</div> </div>
<div class="result-counts"> <div id="result-counts" class="mt-2">
<span id="resultCount" class="result-count"></span> <span id="result-count"></span>
<button id="exportButton" class="btn btn-sm btn-outline-primary ms-2" onclick="exportToCSV()" style="display: none;">
<i class="bi bi-file-earmark-spreadsheet"></i> Als CSV exportieren
</button>
</div> </div>
<div id="loading" class="loading"> <div id="loading" class="loading">
@@ -104,309 +110,6 @@
</div> </div>
</footer> </footer>
<script> <script src="{{ url_for('static', filename='js/main.js') }}"></script>
let searchTimeout;
let lastResults = [];
function createPhoneLink(phone) {
if (!phone) return '';
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 => isIPInSubnet(clientIP, range.trim()));
// Entferne alle nicht-numerischen Zeichen
let cleanNumber = phone.replace(/\D/g, '');
// Formatiere die Nummer
let formattedNumber = cleanNumber;
if (cleanNumber.length === 11) {
formattedNumber = cleanNumber.replace(/(\d{4})(\d{7})/, '$1-$2');
} else if (cleanNumber.length === 10) {
formattedNumber = cleanNumber.replace(/(\d{3})(\d{7})/, '$1-$2');
}
// Erstelle den Link
return `<a href="tel:${cleanNumber}" class="phone-link">${formattedNumber}</a>`;
}
function createEmailLink(email) {
if (!email) return '';
return `<a href="mailto:${email}" class="email-link">${email}</a>`;
}
function highlightText(text, searchTerm) {
if (!searchTerm || !text) return text;
// Escapen von Sonderzeichen im Suchbegriff
const escapedSearchTerm = searchTerm.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
// Erstelle einen regulären Ausdruck ohne Wortgrenzen
const regex = new RegExp(escapedSearchTerm, 'gi');
return text.replace(regex, '<mark>$&</mark>');
}
function createAddressLink(street, plz, city) {
if (!street || !plz || !city) return '';
const address = `${street}, ${plz} ${city}`;
const searchQuery = encodeURIComponent(address);
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">
<i class="fa-solid fa-location-dot location-pin"></i>
</a>
<a href="https://www.google.com/maps/dir/?api=1&destination=${routeQuery}"
class="route-link" target="_blank" rel="noopener noreferrer">
<i class="fa-solid fa-route route-pin"></i>
</a>`;
}
function adjustCustomerNumber(number) {
return number - 12000;
}
function isIPInSubnet(ip, subnet) {
// Teile die IP und das Subnetz in ihre Komponenten
const [subnetIP, bits] = subnet.split('/');
const ipParts = ip.split('.').map(Number);
const subnetParts = subnetIP.split('.').map(Number);
// Konvertiere IPs in 32-bit Zahlen
const ipNum = (ipParts[0] << 24) | (ipParts[1] << 16) | (ipParts[2] << 8) | ipParts[3];
const subnetNum = (subnetParts[0] << 24) | (subnetParts[1] << 16) | (subnetParts[2] << 8) | subnetParts[3];
// Erstelle die Subnetzmaske
const mask = ~((1 << (32 - bits)) - 1);
// Prüfe, ob die IP im Subnetz liegt
return (ipNum & mask) === (subnetNum & mask);
}
function createCustomerLink(nummer) {
const clientIP = '{{ 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);
});
if (isAllowed) {
const adjustedNumber = adjustCustomerNumber(nummer);
return `<a href="medisw:openkkbefe/P${adjustedNumber}?NetGrp=4" class="customer-link">${nummer}</a>`;
} else {
return nummer;
}
}
function showCopyFeedback() {
const feedback = document.getElementById('shareFeedback');
feedback.style.display = 'block';
feedback.style.opacity = '1';
feedback.addEventListener('animationend', () => {
feedback.style.display = 'none';
}, { once: true });
}
async function copyCustomerLink(customerNumber) {
const url = new URL(window.location.href);
url.searchParams.set('kundennummer', customerNumber);
try {
await navigator.clipboard.writeText(url.toString());
showCopyFeedback();
} catch (err) {
// Fehlerbehandlung ohne console.log
}
}
function updateResultCounts() {
// Nur Gesamtzahl anzeigen
const generalCount = lastResults.length;
document.getElementById('resultCount').textContent =
generalCount > 0 ? `${generalCount} Treffer gefunden` : '';
document.getElementById('resultCount').classList.toggle('visible', generalCount > 0);
}
function displayResults(results) {
const resultsDiv = document.getElementById('results');
const resultCount = document.getElementById('resultCount');
const generalSearchTerm = document.getElementById('q').value;
const nameSearchTerm = document.getElementById('nameInput').value;
const fachrichtungSearchTerm = document.getElementById('fachrichtungInput').value;
if (!results || results.length === 0) {
resultsDiv.innerHTML = '<p>Keine Ergebnisse gefunden.</p>';
resultCount.textContent = '0 Ergebnisse';
return;
}
resultCount.textContent = `${results.length} Ergebnisse`;
const resultsList = results.map(customer => {
// Hilfsfunktion zum Erstellen von Feldern nur wenn sie Werte haben
const createFieldIfValue = (label, value, formatter = (v) => v) => {
if (!value || value === 'N/A' || value === 'n/a' || value === 'N/a' || (typeof value === 'string' && value.trim() === '')) return '';
const formattedValue = formatter(value);
return `<p class="mb-1"><strong>${label}:</strong> ${formattedValue}</p>`;
};
return `
<div class="card mb-1">
<div class="card-body py-1">
<div class="d-flex justify-content-between align-items-start">
<h5 class="card-title mb-1">${highlightText(customer.name, generalSearchTerm || nameSearchTerm)}</h5>
<div class="d-flex align-items-center gap-2">
<span class="badge ${(customer.tag || 'medisoft') === 'medisoft' ? 'bg-primary' : 'bg-warning text-dark'}">${(customer.tag || 'medisoft').toUpperCase()}</span>
<button class="btn btn-sm btn-outline-primary" onclick="copyCustomerLink('${customer.nummer}')">
<i class="fas fa-share-alt"></i> Teilen
</button>
</div>
</div>
<div class="card-text">
${createFieldIfValue('Nummer', highlightText(customer.nummer, generalSearchTerm), createCustomerLink)}
${createFieldIfValue('Adresse', (customer.strasse && customer.plz && customer.ort) ? true : false,
() => createAddressLink(
highlightText(customer.strasse, generalSearchTerm),
highlightText(customer.plz, generalSearchTerm),
highlightText(customer.ort, generalSearchTerm)
))}
${createFieldIfValue('Telefon', highlightText(customer.telefon, generalSearchTerm), createPhoneLink)}
${createFieldIfValue('Mobil', highlightText(customer.mobil, generalSearchTerm), createPhoneLink)}
${createFieldIfValue('Handy', highlightText(customer.handy, generalSearchTerm), createPhoneLink)}
${createFieldIfValue('Telefon Firma', highlightText(customer.tele_firma, generalSearchTerm), createPhoneLink)}
${createFieldIfValue('E-Mail', highlightText(customer.email, generalSearchTerm), createEmailLink)}
${createFieldIfValue('Fachrichtung', highlightText(customer.fachrichtung, generalSearchTerm || fachrichtungSearchTerm))}
${createFieldIfValue('Kontakt 1', highlightText(customer.kontakt1, generalSearchTerm), createPhoneLink)}
${createFieldIfValue('Kontakt 2', highlightText(customer.kontakt2, generalSearchTerm), createPhoneLink)}
${createFieldIfValue('Kontakt 3', highlightText(customer.kontakt3, generalSearchTerm), createPhoneLink)}
${customer.tags && customer.tags.length > 0 ? `
<p class="mb-0"><strong>Tags:</strong>
${customer.tags.map(tag => `<span class="badge bg-primary me-1">${tag}</span>`).join('')}
</p>
` : ''}
</div>
</div>
</div>
`}).join('');
resultsDiv.innerHTML = resultsList;
}
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;
const fachrichtung = document.getElementById('fachrichtungInput').value;
const selectedTag = document.getElementById('tagFilter').value;
// Prüfe, ob mindestens ein Suchfeld einen Wert hat
if (!q && !name && !ort && !nummer && !plz && !fachrichtung) {
document.getElementById('results').innerHTML = '';
document.getElementById('resultCount').textContent = '';
return;
}
// 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);
if (fachrichtung) params.append('fachrichtung', fachrichtung);
if (selectedTag) params.append('tag', selectedTag);
// Führe die Suche durch
fetch('/search?' + params.toString())
.then(response => {
if (!response.ok) {
throw new Error('Netzwerk-Antwort war nicht ok');
}
return response.json();
})
.then(data => {
// Verstecke das Lade-Icon
document.getElementById('loading').style.display = 'none';
if (data.error) {
console.error('Fehler bei der Suche:', data.error);
return;
}
lastResults = data;
updateResultCounts();
displayResults(data);
})
.catch(error => {
document.getElementById('loading').style.display = 'none';
console.error('Fehler bei der Suche:', error);
document.getElementById('results').innerHTML = '<p>Ein Fehler ist aufgetreten. Bitte versuchen Sie es später erneut.</p>';
});
}
// Event-Listener für die Live-Suche
const searchInputs = [
document.getElementById('q'),
document.getElementById('nameInput'),
document.getElementById('ortInput'),
document.getElementById('nummerInput'),
document.getElementById('plzInput'),
document.getElementById('fachrichtungInput')
];
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\')"]'),
document.querySelector('.reset-icon[onclick="clearInput(\'fachrichtungInput\')"]')
];
searchInputs.forEach((input, index) => {
input.addEventListener('input', function() {
clearTimeout(searchTimeout);
// Erhöhe das Debounce-Intervall auf 500ms
searchTimeout = setTimeout(searchCustomers, 500);
// Reset-Icon anzeigen/verstecken
resetIcons[index].classList.toggle('visible', this.value.length > 0);
});
// Reset-Funktionalität
resetIcons[index].addEventListener('click', function() {
searchInputs[index].value = '';
searchCustomers();
});
});
// URL-Parameter beim Laden der Seite prüfen
window.addEventListener('load', function() {
const urlParams = new URLSearchParams(window.location.search);
const name = urlParams.get('name');
const ort = urlParams.get('ort');
const kundennummer = urlParams.get('kundennummer');
const plz = urlParams.get('plz');
if (name) document.getElementById('nameInput').value = name;
if (ort) document.getElementById('ortInput').value = ort;
if (kundennummer) document.getElementById('nummerInput').value = kundennummer;
if (plz) document.getElementById('plzInput').value = plz;
if (name || ort || kundennummer || plz) {
searchCustomers();
}
});
</script>
</body> </body>
</html> </html>