14 Commits

Author SHA1 Message Date
57d6daa298 Feat: Offline-Funktionalität mit IndexedDB implementiert 2025-03-18 16:22:57 +01:00
80281d1c2c Feat: App-Icons für PWA hinzugefügt 2025-03-18 16:20:31 +01:00
4c69478fa8 Feat: PWA-Implementierung - Offline-Funktionalität hinzugefügt 2025-03-18 16:16:47 +01:00
35645fc671 Fix: Login-Funktionalität wiederhergestellt und Passwort aktualisiert 2025-03-18 16:11:10 +01:00
fab869eb58 Logo mit Link zu medisoftware.de versehen 2025-03-18 15:47:56 +01:00
6cd8f199c4 Gitignore: docker-compose.yml hinzugefügt 2025-03-18 15:42:48 +01:00
948a17b739 Docker Compose: Beispiel-Konfiguration hinzugefügt und Original aus Repository entfernt 2025-03-18 15:41:23 +01:00
f2290cf77f Version 1.2.6: Verbesserte Suchfunktion und Highlighting - app.py aktualisiert 2025-03-18 15:33:01 +01:00
0627b6ff33 Version 1.2.6: Verbesserte Suchfunktion und Highlighting 2025-03-18 15:32:20 +01:00
24ba040537 Version 1.2.5: Dokumentation aktualisiert 2025-03-18 15:12:50 +01:00
997786be54 Feature: Suchfeld für Fachrichtung hinzugefügt 2025-03-18 15:11:15 +01:00
c4974787d4 Dokumentation: Korrigiere Versionsangabe am Ende der README.md 2025-03-18 14:08:04 +01:00
a42bdaa721 Dokumentation: Aktualisiere Versionsnummer in README.md auf v1.2.4 2025-03-18 14:06:31 +01:00
49938a1085 Version 1.2.4: Performance-Optimierung durch Datenbankindizes 2025-03-18 14:02:07 +01:00
15 changed files with 699 additions and 82 deletions

2
.gitignore vendored
View File

@@ -50,3 +50,5 @@ spezexpo.csv
# Database
*.db
data/customers.db
data/customers.csv
docker-compose.yml

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/),
und dieses Projekt adhäriert zu [Semantic Versioning](https://semver.org/lang/de/).
## [1.2.6] - 2024-03-19
### Geändert
- Verbesserte Suchfunktion: Keine Ergebnisse mehr bei leeren Suchfeldern
- Optimiertes Highlighting der Suchergebnisse für alle Suchfelder
- Fachrichtung wird jetzt in den Suchergebnissen hervorgehoben
## [1.2.5] - 2024-03-19
### Hinzugefügt
- Neues Suchfeld für Fachrichtung
- Index für das Fachrichtung-Feld in der Datenbank
- Fachrichtung in der allgemeinen Suche integriert
## [1.2.4] - 2024-03-19
### Geändert
- Performance-Optimierung: Indizes für alle Suchfelder hinzugefügt
- Verbesserte Suchgeschwindigkeit durch optimierte Datenbankindizes
- Zusammengesetzter Index für die häufigste Suchkombination (Name + Ort) hinzugefügt
## [1.2.3] - 2024-03-19
### Geändert
- Performance-Optimierung: Entfernung aller console.log Anweisungen

View File

@@ -51,7 +51,7 @@ Die Anwendung unterstützt CIDR-Notation für IP-Bereiche. Beispiele:
## Version
Aktuelle Version: 1.2.1
Aktuelle Version: v1.2.6
## Lizenz
@@ -103,4 +103,4 @@ curl "http://localhost:5001/search?fachrichtung=Zahnarzt&ort=Berlin&name=Schmidt
## Version
Aktuelle Version: [v1.2.0](CHANGELOG.md#v120---2024-03-17)
Aktuelle Version: [v1.2.4](CHANGELOG.md#v124---2024-03-19)

93
app.py
View File

@@ -1,4 +1,4 @@
from flask import Flask, render_template, request, jsonify, url_for, redirect, session
from flask import Flask, render_template, request, jsonify, url_for, redirect, session, make_response, send_from_directory
import pandas as pd
import os
import logging
@@ -38,7 +38,7 @@ def init_db():
conn = sqlite3.connect(DB_FILE)
c = conn.cursor()
# Erstelle die Tabelle mit Indizes
# Erstelle die Tabelle
c.execute('''
CREATE TABLE IF NOT EXISTS customers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -50,7 +50,8 @@ def init_db():
telefon TEXT,
mobil TEXT,
email TEXT,
bemerkung TEXT
bemerkung TEXT,
fachrichtung TEXT
)
''')
@@ -63,6 +64,7 @@ def init_db():
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)')
# Erstelle einen zusammengesetzten Index für die häufigste Suchkombination
c.execute('CREATE INDEX IF NOT EXISTS idx_customers_name_ort ON customers(name, ort)')
@@ -97,8 +99,8 @@ def import_csv():
# Importiere die Daten
for _, row in df.iterrows():
c.execute('''
INSERT INTO customers (nummer, name, strasse, plz, ort, telefon, mobil, email, bemerkung)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO customers (nummer, name, strasse, plz, ort, telefon, mobil, email, bemerkung, fachrichtung)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
''', (
row['Nummer'],
row['name'],
@@ -108,7 +110,8 @@ def import_csv():
row['Tel'],
row['Handy'],
row['mail'],
f"Fachrichtung: {row['Fachrichtung']}"
f"Fachrichtung: {row['Fachrichtung']}",
row['Fachrichtung']
))
conn.commit()
@@ -121,6 +124,19 @@ def import_csv():
def search_customers(search_params):
"""Sucht nach Kunden basierend auf den Suchparametern."""
# Prüfe, ob alle Suchfelder leer sind
if not any([
search_params.get('q'),
search_params.get('name'),
search_params.get('ort'),
search_params.get('nummer'),
search_params.get('plz'),
search_params.get('telefon'),
search_params.get('email'),
search_params.get('fachrichtung')
]):
return []
conn = sqlite3.connect(DB_FILE)
c = conn.cursor()
@@ -132,8 +148,21 @@ def search_customers(search_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)
operator = search_params.get('operator', 'or').upper()
if operator == 'AND':
# Bei UND-Verknüpfung müssen alle Begriffe in mindestens einem Feld vorkommen
terms = search_params['q'].split()
conditions = []
for term in terms:
term = f"%{term}%"
conditions.append("(name LIKE ? OR ort LIKE ? OR nummer LIKE ? OR telefon LIKE ? OR mobil LIKE ? OR email LIKE ? OR bemerkung LIKE ? OR fachrichtung LIKE ?)")
params.extend([term] * 8)
query += " AND " + " AND ".join(conditions)
else:
# Bei ODER-Verknüpfung (Standard) muss mindestens ein Begriff in einem Feld vorkommen
query += " AND (name LIKE ? OR ort LIKE ? OR nummer LIKE ? OR telefon LIKE ? OR mobil LIKE ? OR email LIKE ? OR bemerkung LIKE ? OR fachrichtung LIKE ?)"
params.extend([search_term] * 8)
# Spezifische Suche für einzelne Felder
if search_params.get('name'):
@@ -152,6 +181,10 @@ def search_customers(search_params):
query += " AND plz LIKE ?"
params.append(f"%{search_params['plz']}%")
if search_params.get('fachrichtung'):
query += " AND fachrichtung LIKE ?"
params.append(f"%{search_params['fachrichtung']}%")
# Führe die Abfrage aus
c.execute(query, params)
results = c.fetchall()
@@ -169,7 +202,8 @@ def search_customers(search_params):
'telefon': row[6],
'mobil': row[7],
'email': row[8],
'bemerkung': row[9]
'bemerkung': row[9],
'fachrichtung': row[10]
}
customers.append(customer)
@@ -270,7 +304,9 @@ def search():
'plz': request.args.get('plz', ''),
'telefon': request.args.get('telefon', ''),
'email': request.args.get('email', ''),
'q': request.args.get('q', '')
'q': request.args.get('q', ''),
'fachrichtung': request.args.get('fachrichtung', ''),
'operator': request.args.get('operator', 'or')
}
# Führe die Suche in der Datenbank durch
@@ -284,6 +320,43 @@ def search():
logger.error(f'Fehler bei der Suche: {str(e)}')
return jsonify({"error": str(e)}), 500
@app.route('/sw.js')
def sw():
response = make_response(send_from_directory('static', 'sw.js'))
response.headers['Content-Type'] = 'application/javascript'
return response
@app.route('/offline')
def offline():
return render_template('offline.html')
@app.route('/api/search', methods=['POST'])
def api_search():
"""API-Endpunkt für die Kundensuche"""
try:
search_params = request.get_json()
results = search_customers(search_params)
return jsonify(results)
except Exception as e:
return jsonify({"error": str(e)}), 500
@app.route('/api/customers')
def api_customers():
"""API-Endpunkt für alle Kunden"""
try:
conn = sqlite3.connect(DB_FILE)
c = conn.cursor()
c.execute('SELECT * FROM customers')
columns = [description[0] for description in c.description]
results = []
for row in c.fetchall():
customer = dict(zip(columns, row))
results.append(customer)
conn.close()
return jsonify(results)
except Exception as e:
return jsonify({"error": str(e)}), 500
def init_app(app):
"""Initialisiert die Anwendung mit allen notwendigen Einstellungen."""
with app.app_context():

View File

@@ -8,7 +8,6 @@ services:
environment:
- FLASK_APP=app.py
- FLASK_ENV=production
- 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

39
scripts/generate_icons.py Normal file
View File

@@ -0,0 +1,39 @@
from PIL import Image, ImageDraw, ImageFont
import os
def create_icon(size):
# Erstelle ein neues Bild mit grünem Hintergrund
image = Image.new('RGB', (size, size), '#4CAF50')
draw = ImageDraw.Draw(image)
# Versuche, eine Schriftart zu laden
try:
font = ImageFont.truetype("arial.ttf", size // 3)
except:
font = ImageFont.load_default()
# Text "MEDI" zeichnen
text = "MEDI"
text_bbox = draw.textbbox((0, 0), text, font=font)
text_width = text_bbox[2] - text_bbox[0]
text_height = text_bbox[3] - text_bbox[1]
x = (size - text_width) // 2
y = (size - text_height) // 2
draw.text((x, y), text, fill='white', font=font)
# Speichere das Icon
output_file = f'static/images/icon-{size}x{size}.png'
image.save(output_file, 'PNG')
print(f'Icon {size}x{size} generiert: {output_file}')
def main():
# Erstelle das Verzeichnis, falls es nicht existiert
os.makedirs('static/images', exist_ok=True)
# Generiere Icons in verschiedenen Größen
sizes = [192, 512]
for size in sizes:
create_icon(size)
if __name__ == '__main__':
main()

View File

@@ -236,3 +236,99 @@ body {
font-size: 1.2rem;
padding: 0 1rem;
}
.search-options {
font-size: 0.9em;
color: #666;
}
.search-options .form-check {
margin-right: 1rem;
}
.search-options .form-check-input {
cursor: pointer;
}
.search-options .form-check-label {
cursor: pointer;
user-select: none;
}
/* Offline-Indikator */
.offline-indicator {
position: fixed;
bottom: 20px;
right: 20px;
background-color: #ff9800;
color: white;
padding: 10px 20px;
border-radius: 5px;
display: flex;
align-items: center;
gap: 10px;
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
z-index: 1000;
}
.offline-icon {
font-size: 1.2em;
}
/* Synchronisations-Status */
.sync-status {
position: fixed;
bottom: 20px;
right: 20px;
background-color: #4CAF50;
color: white;
padding: 10px 20px;
border-radius: 5px;
display: flex;
align-items: center;
gap: 10px;
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
z-index: 1000;
}
.sync-icon {
font-size: 1.2em;
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* Offline-Modus Styling */
body.offline {
filter: grayscale(20%);
}
body.offline .search-container {
opacity: 0.9;
}
body.offline .footer::after {
content: "Offline-Modus";
display: block;
text-align: center;
color: #ff9800;
font-size: 0.8em;
margin-top: 5px;
}
/* Offline-Fallback für Bilder */
.offline img {
opacity: 0.8;
}
/* Verbesserte Sichtbarkeit im Offline-Modus */
.offline .search-field input {
background-color: rgba(255, 255, 255, 0.9);
}
.offline .result-count {
color: #666;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

25
static/images/icon.svg Normal file
View File

@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="512" height="512" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
<!-- Hintergrund -->
<rect width="512" height="512" fill="#4CAF50"/>
<!-- MEDI Text -->
<text x="256" y="300"
font-family="Arial, sans-serif"
font-size="200"
font-weight="bold"
fill="white"
text-anchor="middle">
MEDI
</text>
<!-- Herz-Symbol -->
<path d="M256 150
C 256 150, 200 100, 150 100
C 100 100, 50 150, 50 200
C 50 250, 256 350, 256 350
C 256 350, 462 250, 462 200
C 462 150, 412 100, 362 100
C 312 100, 256 150, 256 150"
fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 775 B

144
static/js/db.js Normal file
View File

@@ -0,0 +1,144 @@
// IndexedDB Konfiguration
const DB_NAME = 'mediCustomersDB';
const DB_VERSION = 1;
const STORE_NAME = 'customers';
// Datenbank initialisieren
const initDB = () => {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onerror = () => {
console.error('Fehler beim Öffnen der Datenbank:', request.error);
reject(request.error);
};
request.onsuccess = () => {
console.log('Datenbank erfolgreich geöffnet');
resolve(request.result);
};
request.onupgradeneeded = (event) => {
const db = event.target.result;
if (!db.objectStoreNames.contains(STORE_NAME)) {
const store = db.createObjectStore(STORE_NAME, { keyPath: 'nummer' });
// Indizes für die Suche erstellen
store.createIndex('name', 'name', { unique: false });
store.createIndex('ort', 'ort', { unique: false });
store.createIndex('plz', 'plz', { unique: false });
store.createIndex('fachrichtung', 'fachrichtung', { unique: false });
}
};
});
};
// Kunden in IndexedDB speichern
const saveCustomers = async (customers) => {
const db = await initDB();
const tx = db.transaction(STORE_NAME, 'readwrite');
const store = tx.objectStore(STORE_NAME);
return Promise.all(customers.map(customer => {
return new Promise((resolve, reject) => {
const request = store.put(customer);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}));
};
// Kunden aus IndexedDB suchen
const searchCustomersOffline = async (searchParams) => {
const db = await initDB();
const tx = db.transaction(STORE_NAME, 'readonly');
const store = tx.objectStore(STORE_NAME);
return new Promise((resolve, reject) => {
const request = store.getAll();
request.onsuccess = () => {
let results = request.result;
// Filtern basierend auf den Suchparametern
if (searchParams.q) {
const searchTerms = searchParams.q.toLowerCase().split(' ');
const operator = searchParams.operator || 'or';
results = results.filter(customer => {
const searchableText = `${customer.name} ${customer.ort} ${customer.nummer} ${customer.plz} ${customer.fachrichtung}`.toLowerCase();
if (operator === 'and') {
return searchTerms.every(term => searchableText.includes(term));
} else {
return searchTerms.some(term => searchableText.includes(term));
}
});
}
// Spezifische Feldsuche
if (searchParams.name) {
results = results.filter(c => c.name.toLowerCase().includes(searchParams.name.toLowerCase()));
}
if (searchParams.ort) {
results = results.filter(c => c.ort.toLowerCase().includes(searchParams.ort.toLowerCase()));
}
if (searchParams.nummer) {
results = results.filter(c => c.nummer.toString().includes(searchParams.nummer));
}
if (searchParams.plz) {
results = results.filter(c => c.plz.includes(searchParams.plz));
}
if (searchParams.fachrichtung) {
results = results.filter(c => c.fachrichtung.toLowerCase().includes(searchParams.fachrichtung.toLowerCase()));
}
resolve(results);
};
request.onerror = () => {
reject(request.error);
};
});
};
// Synchronisationsstatus speichern
const syncStatus = {
lastSync: null,
isOnline: navigator.onLine
};
// Event Listener für Online/Offline Status
window.addEventListener('online', () => {
syncStatus.isOnline = true;
document.body.classList.remove('offline');
synchronizeData();
});
window.addEventListener('offline', () => {
syncStatus.isOnline = false;
document.body.classList.add('offline');
});
// Daten mit dem Server synchronisieren
const synchronizeData = async () => {
if (!syncStatus.isOnline) return;
try {
const response = await fetch('/api/customers');
const customers = await response.json();
await saveCustomers(customers);
syncStatus.lastSync = new Date();
console.log('Daten erfolgreich synchronisiert');
} catch (error) {
console.error('Fehler bei der Synchronisation:', error);
}
};
// Export der Funktionen
window.dbHelper = {
initDB,
saveCustomers,
searchCustomersOffline,
synchronizeData,
syncStatus
};

28
static/manifest.json Normal file
View File

@@ -0,0 +1,28 @@
{
"name": "MEDI Kunden",
"short_name": "MEDI",
"description": "MEDI Kundenverwaltung - Offline-fähige PWA",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#4CAF50",
"orientation": "portrait",
"icons": [
{
"src": "/static/images/icon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/static/images/icon-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
],
"categories": ["business", "productivity"],
"lang": "de-DE",
"dir": "ltr",
"prefer_related_applications": false
}

75
static/sw.js Normal file
View File

@@ -0,0 +1,75 @@
const CACHE_NAME = 'medi-customers-v1';
const urlsToCache = [
'/',
'/static/css/styles.css',
'/static/js/script.js',
'/static/images/logo.png',
'/static/images/icon-192x192.png',
'/static/images/icon-512x512.png'
];
// Installation des Service Workers
self.addEventListener('install', event => {
event.waitUntil(
caches.open(CACHE_NAME)
.then(cache => {
console.log('Cache geöffnet');
return cache.addAll(urlsToCache);
})
);
});
// Aktivierung des Service Workers
self.addEventListener('activate', event => {
event.waitUntil(
caches.keys().then(cacheNames => {
return Promise.all(
cacheNames.map(cacheName => {
if (cacheName !== CACHE_NAME) {
console.log('Lösche alten Cache:', cacheName);
return caches.delete(cacheName);
}
})
);
})
);
});
// Fetch-Event-Handler
self.addEventListener('fetch', event => {
event.respondWith(
caches.match(event.request)
.then(response => {
// Cache-Treffer - gib die Antwort zurück
if (response) {
return response;
}
// Kein Cache-Treffer - führe Netzwerkanfrage durch
return fetch(event.request)
.then(response => {
// Prüfe, ob wir eine gültige Antwort erhalten haben
if (!response || response.status !== 200 || response.type !== 'basic') {
return response;
}
// Klone die Antwort
const responseToCache = response.clone();
// Speichere die Antwort im Cache
caches.open(CACHE_NAME)
.then(cache => {
cache.put(event.request, responseToCache);
});
return response;
})
.catch(() => {
// Fallback für Offline-Zugriff
if (event.request.mode === 'navigate') {
return caches.match('/offline.html');
}
});
})
);
});

View File

@@ -5,15 +5,22 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>medisoftware Kundensuche</title>
<link rel="icon" type="image/x-icon" href="{{ url_for('static', filename='favicon.ico') }}">
<link rel="manifest" href="{{ url_for('static', filename='manifest.json') }}">
<meta name="theme-color" content="#4CAF50">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black">
<meta name="apple-mobile-web-app-title" content="MEDI">
<link rel="apple-touch-icon" href="{{ url_for('static', filename='images/icon-192x192.png') }}">
<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="{{ url_for('static', filename='css/styles.css') }}" rel="stylesheet">
<script src="{{ url_for('static', filename='js/db.js') }}"></script>
</head>
<body>
<div class="main-content">
<div class="container">
<div class="text-center mb-4">
<img src="{{ url_for('static', filename='medisoftware_logo_rb_200.png') }}" alt="medisoftware Logo" class="img-fluid" style="max-width: 200px;">
<a href="https://medisoftware.de" target="_blank" rel="noopener noreferrer"><img src="{{ url_for('static', filename='medisoftware_logo_rb_200.png') }}" alt="medisoftware Logo" class="img-fluid" style="max-width: 200px;"></a>
</div>
<div class="search-container">
<h1 class="text-center mb-4">Kundensuche</h1>
@@ -24,6 +31,16 @@
<i class="fas fa-times reset-icon" onclick="clearInput('q')"></i>
<i class="fas fa-search search-icon"></i>
</div>
<div class="search-options mt-2">
<div class="form-check form-check-inline">
<input class="form-check-input" type="radio" name="searchOperator" id="searchOr" value="or" checked>
<label class="form-check-label" for="searchOr">ODER</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="radio" name="searchOperator" id="searchAnd" value="and">
<label class="form-check-label" for="searchAnd">UND</label>
</div>
</div>
</div>
<div class="search-fields">
@@ -58,6 +75,14 @@
<i class="fas fa-search search-icon"></i>
</div>
</div>
<div class="search-field">
<div class="input-group">
<input type="text" id="fachrichtungInput" class="form-control" placeholder="Fachrichtung" oninput="searchCustomers()">
<i class="fas fa-times reset-icon" onclick="clearInput('fachrichtungInput')"></i>
<i class="fas fa-search search-icon"></i>
</div>
</div>
</div>
<div class="result-counts">
@@ -82,11 +107,54 @@
<footer class="footer">
<div class="footer-content">
Proudly 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.2</div>
<div style="font-size: 0.8em;">Version: v1.2.6</div>
</div>
</footer>
<div id="syncStatus" class="sync-status" style="display: none;">
<span class="sync-icon">🔄</span>
<span class="sync-text">Synchronisiere Daten...</span>
</div>
<div id="offlineIndicator" class="offline-indicator" style="display: none;">
<span class="offline-icon">📡</span>
<span class="offline-text">Offline-Modus</span>
</div>
<script>
// Service Worker und IndexedDB initialisieren
if ('serviceWorker' in navigator) {
window.addEventListener('load', async () => {
try {
const registration = await navigator.serviceWorker.register('/sw.js');
console.log('ServiceWorker registration successful');
// IndexedDB initialisieren und erste Synchronisation starten
await window.dbHelper.initDB();
await window.dbHelper.synchronizeData();
} catch (err) {
console.log('ServiceWorker registration failed: ', err);
}
});
}
// Offline-Status-Anzeige aktualisieren
const updateOfflineStatus = () => {
const offlineIndicator = document.getElementById('offlineIndicator');
const syncStatus = document.getElementById('syncStatus');
if (!navigator.onLine) {
offlineIndicator.style.display = 'block';
syncStatus.style.display = 'none';
} else {
offlineIndicator.style.display = 'none';
}
};
window.addEventListener('online', updateOfflineStatus);
window.addEventListener('offline', updateOfflineStatus);
updateOfflineStatus();
let searchTimeout;
let lastResults = [];
@@ -225,20 +293,33 @@
return;
}
const searchTerm = document.getElementById('q').value;
// Hole alle Suchbegriffe
const searchTerms = {
general: document.getElementById('q').value,
name: document.getElementById('nameInput').value,
ort: document.getElementById('ortInput').value,
nummer: document.getElementById('nummerInput').value,
plz: document.getElementById('plzInput').value,
fachrichtung: document.getElementById('fachrichtungInput').value
};
results.forEach(customer => {
const card = document.createElement('div');
card.className = 'customer-card';
card.innerHTML = `
<div class="customer-info">
<h5 class="mb-1">${highlightText(customer.name, searchTerm)}</h5>
<h5 class="mb-1">${highlightText(customer.name, searchTerms.general || searchTerms.name)}</h5>
<p class="mb-1 customer-number">${createCustomerLink(customer.nummer)}</p>
<p class="mb-1">${createAddressLink(customer.strasse, customer.plz, customer.ort)}</p>
<p class="mb-1">${createAddressLink(
customer.strasse,
highlightText(customer.plz, searchTerms.general || searchTerms.plz),
highlightText(customer.ort, searchTerms.general || searchTerms.ort)
)}</p>
<p class="mb-1">Tel: ${createPhoneLink(customer.telefon)}</p>
${customer.mobil ? `<p class="mb-1">Mobil: ${createPhoneLink(customer.mobil)}</p>` : ''}
${customer.email ? `<p class="mb-1">E-Mail: ${createEmailLink(customer.email)}</p>` : ''}
${customer.bemerkung ? `<p class="mb-1">Bemerkung: ${customer.bemerkung}</p>` : ''}
${customer.fachrichtung ? `<p class="mb-1">Fachrichtung: ${highlightText(customer.fachrichtung, searchTerms.general || searchTerms.fachrichtung)}</p>` : ''}
</div>
<div class="card-actions">
<button class="share-button" onclick="copyCustomerLink('${customer.nummer}')">
@@ -250,71 +331,48 @@
});
}
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;
async function searchCustomers() {
const searchParams = {
q: document.getElementById('q').value,
name: document.getElementById('nameInput').value,
ort: document.getElementById('ortInput').value,
nummer: document.getElementById('nummerInput').value,
plz: document.getElementById('plzInput').value,
fachrichtung: document.getElementById('fachrichtungInput').value,
operator: document.querySelector('input[name="searchOperator"]:checked').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);
try {
let results;
if (navigator.onLine) {
// Online: Server-Suche
const response = await fetch('/api/search', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(searchParams)
});
results = await response.json();
// Führe die Suche durch
fetch('/search?' + params.toString())
.then(response => response.json())
.then(data => {
// Verstecke das Lade-Icon
document.getElementById('loading').style.display = 'none';
if (data.error) {
return;
// Speichere die Ergebnisse in IndexedDB
await window.dbHelper.saveCustomers(results);
} else {
// Offline: Lokale Suche
results = await window.dbHelper.searchCustomersOffline(searchParams);
}
lastResults = data;
lastResults = results;
displayResults(results);
updateResultCounts();
const resultsDiv = document.getElementById('results');
resultsDiv.innerHTML = '';
if (data.length === 0) {
resultsDiv.innerHTML = '<p class="text-center text-muted">Keine Ergebnisse gefunden</p>';
return;
}
data.forEach(customer => {
const card = document.createElement('div');
card.className = 'customer-card';
card.innerHTML = `
<div class="customer-info">
<h5 class="mb-1">${highlightText(customer.name, q || name)}</h5>
<p class="mb-1 customer-number">${createCustomerLink(customer.nummer)}</p>
<p class="mb-1">${createAddressLink(customer.strasse, customer.plz, customer.ort)}</p>
<p class="mb-1">Tel: ${createPhoneLink(customer.telefon)}</p>
${customer.mobil ? `<p class="mb-1">Mobil: ${createPhoneLink(customer.mobil)}</p>` : ''}
${customer.email ? `<p class="mb-1">E-Mail: ${createEmailLink(customer.email)}</p>` : ''}
${customer.bemerkung ? `<p class="mb-1">Bemerkung: ${customer.bemerkung}</p>` : ''}
</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);
});
})
.catch(error => {
} catch (error) {
console.error('Fehler bei der Suche:', error);
document.getElementById('results').innerHTML = '<p>Ein Fehler ist aufgetreten. Bitte versuchen Sie es später erneut.</p>';
} finally {
document.getElementById('loading').style.display = 'none';
});
}
}
// Event-Listener für die Live-Suche
@@ -323,7 +381,8 @@
document.getElementById('nameInput'),
document.getElementById('ortInput'),
document.getElementById('nummerInput'),
document.getElementById('plzInput')
document.getElementById('plzInput'),
document.getElementById('fachrichtungInput')
];
const resetIcons = [
@@ -331,7 +390,8 @@
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(\'plzInput\')"]'),
document.querySelector('.reset-icon[onclick="clearInput(\'fachrichtungInput\')"]')
];
searchInputs.forEach((input, index) => {

58
templates/offline.html Normal file
View File

@@ -0,0 +1,58 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Offline - MEDI Kunden</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/styles.css') }}">
<style>
.offline-container {
text-align: center;
padding: 2rem;
max-width: 600px;
margin: 2rem auto;
}
.offline-icon {
font-size: 4rem;
color: #666;
margin-bottom: 1rem;
}
.offline-message {
font-size: 1.2rem;
color: #333;
margin-bottom: 1.5rem;
}
.retry-button {
background-color: #4CAF50;
color: white;
padding: 0.8rem 1.5rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
}
.retry-button:hover {
background-color: #45a049;
}
</style>
</head>
<body>
<div class="offline-container">
<div class="offline-icon">📡</div>
<h1>Sie sind offline</h1>
<p class="offline-message">
Es scheint, dass Sie keine Internetverbindung haben.
Bitte überprüfen Sie Ihre Verbindung und versuchen Sie es erneut.
</p>
<button class="retry-button" onclick="window.location.reload()">
Erneut versuchen
</button>
</div>
<script>
// Automatische Überprüfung der Verbindung
window.addEventListener('online', function() {
window.location.reload();
});
</script>
</body>
</html>