Fehler beseitigt, Zeitpläne und Status, Export Funktion

einzelne Sortierregeln
This commit is contained in:
Eduard Wisch 2026-02-10 19:15:45 +01:00
parent e2fe187c5d
commit adbb1935ec
6 changed files with 206 additions and 56 deletions

7
.claude/settings.json Executable file
View file

@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"Bash(mysql:*)"
]
}
}

BIN
Docker - Image/V 2.2/V 2.2.tar Executable file

Binary file not shown.

BIN
Docker - Image/V 2.3/V 2.3.tar Executable file

Binary file not shown.

View file

@ -1307,6 +1307,28 @@ def export_regeln(db: Session = Depends(get_db)):
return {"regeln": export_data, "anzahl": len(export_data)} return {"regeln": export_data, "anzahl": len(export_data)}
@router.get("/regeln/{id}/export")
def export_einzelne_regel(id: int, db: Session = Depends(get_db)):
"""Exportiert eine einzelne Regel als JSON"""
regel = db.query(SortierRegel).filter(SortierRegel.id == id).first()
if not regel:
raise HTTPException(status_code=404, detail="Regel nicht gefunden")
export_data = {
"name": regel.name,
"prioritaet": regel.prioritaet,
"muster": regel.muster,
"extraktion": regel.extraktion,
"schema": regel.schema,
"unterordner": regel.unterordner,
"ziel_ordner": regel.ziel_ordner,
"nur_umbenennen": regel.nur_umbenennen,
"ist_fallback": regel.ist_fallback,
"aktiv": regel.aktiv
}
return {"regeln": [export_data], "anzahl": 1, "regel_name": regel.name}
class RegelnImportRequest(BaseModel): class RegelnImportRequest(BaseModel):
regeln: List[dict] regeln: List[dict]
modus: str = "hinzufuegen" # "hinzufuegen", "ersetzen", "aktualisieren" modus: str = "hinzufuegen" # "hinzufuegen", "ersetzen", "aktualisieren"
@ -2578,10 +2600,11 @@ def regel_vorschau(id: int, data: ExtraktionTestRequest, db: Session = Depends(g
class ZeitplanCreate(BaseModel): class ZeitplanCreate(BaseModel):
name: str name: str
typ: str # "mail_abruf", "grobsortierung", "sortierregeln" typ: str # "mail_abruf", "grobsortierung", "sortierregeln", "db_backup"
postfach_id: Optional[int] = None postfach_id: Optional[int] = None
quell_ordner_id: Optional[int] = None quell_ordner_id: Optional[int] = None
regel_id: Optional[int] = None # Für typ="sortierregeln" regel_id: Optional[int] = None # Für typ="sortierregeln"
datenbank_id: Optional[int] = None # Für typ="db_backup"
intervall: str # "stündlich", "täglich", "wöchentlich", "monatlich" intervall: str # "stündlich", "täglich", "wöchentlich", "monatlich"
stunde: int = 6 stunde: int = 6
minute: int = 0 minute: int = 0
@ -2597,6 +2620,7 @@ class ZeitplanResponse(BaseModel):
postfach_id: Optional[int] postfach_id: Optional[int]
quell_ordner_id: Optional[int] quell_ordner_id: Optional[int]
regel_id: Optional[int] regel_id: Optional[int]
datenbank_id: Optional[int]
intervall: str intervall: str
stunde: int stunde: int
minute: int minute: int
@ -2639,8 +2663,17 @@ def erstelle_zeitplan(data: ZeitplanCreate, db: Session = Depends(get_db)):
from ..services.scheduler_service import sync_zeitplaene, trigger_zeitplan_manuell from ..services.scheduler_service import sync_zeitplaene, trigger_zeitplan_manuell
sync_zeitplaene() sync_zeitplaene()
# Zeitplan direkt beim Erstellen einmal ausführen # Zeitplan direkt beim Erstellen einmal ausführen (in separatem Thread)
trigger_zeitplan_manuell(zeitplan.id) import threading
def run_zeitplan():
try:
trigger_zeitplan_manuell(zeitplan.id)
except Exception as e:
import logging
logging.getLogger(__name__).warning(f"Fehler bei sofortiger Ausführung: {e}")
thread = threading.Thread(target=run_zeitplan, daemon=True)
thread.start()
return zeitplan return zeitplan
@ -2662,8 +2695,17 @@ def aktualisiere_zeitplan(id: int, data: ZeitplanCreate, db: Session = Depends(g
from ..services.scheduler_service import sync_zeitplaene, trigger_zeitplan_manuell from ..services.scheduler_service import sync_zeitplaene, trigger_zeitplan_manuell
sync_zeitplaene() sync_zeitplaene()
# Zeitplan direkt beim Speichern einmal ausführen # Zeitplan direkt beim Speichern einmal ausführen (in separatem Thread)
trigger_zeitplan_manuell(zeitplan.id) import threading
def run_zeitplan():
try:
trigger_zeitplan_manuell(zeitplan.id)
except Exception as e:
import logging
logging.getLogger(__name__).warning(f"Fehler bei sofortiger Ausführung: {e}")
thread = threading.Thread(target=run_zeitplan, daemon=True)
thread.start()
return zeitplan return zeitplan

View file

@ -465,14 +465,19 @@ def execute_mail_abruf(db, zeitplan: Zeitplan) -> Dict:
)) ))
# Postfach-Status aktualisieren # Postfach-Status aktualisieren
postfach.letzter_abruf = datetime.utcnow() try:
postfach.letzte_anzahl = len(ergebnisse) postfach.letzter_abruf = datetime.utcnow()
db.commit() postfach.letzte_anzahl = len(ergebnisse)
db.commit()
except Exception as commit_error:
logger.warning(f"Postfach-Status Update fehlgeschlagen: {commit_error}")
db.rollback()
gesamt_dateien += len(ergebnisse) gesamt_dateien += len(ergebnisse)
fetcher.disconnect() fetcher.disconnect()
except Exception as e: except Exception as e:
db.rollback()
fehler.append(f"{postfach.name}: {str(e)[:100]}") fehler.append(f"{postfach.name}: {str(e)[:100]}")
if fehler: if fehler:

View file

@ -1166,6 +1166,7 @@ function renderRegeln(regeln) {
<button class="btn btn-sm" onclick="bearbeiteRegel(${r.id})" title="Bearbeiten"></button> <button class="btn btn-sm" onclick="bearbeiteRegel(${r.id})" title="Bearbeiten"></button>
<button class="btn btn-sm" onclick="regelAktivieren(${r.id})" title="${r.aktiv ? 'Deaktivieren' : 'Aktivieren'}">${r.aktiv ? '⏸' : '▶'}</button> <button class="btn btn-sm" onclick="regelAktivieren(${r.id})" title="${r.aktiv ? 'Deaktivieren' : 'Aktivieren'}">${r.aktiv ? '⏸' : '▶'}</button>
<button class="btn btn-sm" onclick="kopiereRegel(${r.id})" title="Regel kopieren">📋</button> <button class="btn btn-sm" onclick="kopiereRegel(${r.id})" title="Regel kopieren">📋</button>
<button class="btn btn-sm" onclick="exportiereEinzelneRegel(${r.id})" title="Regel exportieren">📤</button>
<button class="btn btn-sm btn-danger" onclick="regelLoeschen(${r.id})" title="Löschen">×</button> <button class="btn btn-sm btn-danger" onclick="regelLoeschen(${r.id})" title="Löschen">×</button>
</div> </div>
</div> </div>
@ -1185,6 +1186,30 @@ async function kopiereRegel(id) {
// ============ Regeln Import/Export ============ // ============ Regeln Import/Export ============
async function exportiereEinzelneRegel(id) {
try {
const result = await api(`/regeln/${id}/export`);
const json = JSON.stringify(result.regeln, null, 2);
// Download als Datei
const blob = new Blob([json], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
// Dateiname mit Regelname
const fileName = `regel_${result.regel_name.replace(/[^a-zA-Z0-9äöüÄÖÜß_-]/g, '_')}.json`;
a.download = fileName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
showAlert(`Regel "${result.regel_name}" exportiert`, 'success');
} catch (error) {
showAlert('Fehler beim Export: ' + error.message, 'error');
}
}
async function exportiereRegeln() { async function exportiereRegeln() {
try { try {
const result = await api('/regeln/export'); const result = await api('/regeln/export');
@ -2500,15 +2525,16 @@ function renderZeitplaene(zeitplaene) {
const naechste = zp.naechste_ausfuehrung ? formatDatum(zp.naechste_ausfuehrung) : '-'; const naechste = zp.naechste_ausfuehrung ? formatDatum(zp.naechste_ausfuehrung) : '-';
const letzte = zp.letzte_ausfuehrung ? formatDatum(zp.letzte_ausfuehrung) : 'Noch nie'; const letzte = zp.letzte_ausfuehrung ? formatDatum(zp.letzte_ausfuehrung) : 'Noch nie';
const statusText = zp.letzter_status === 'erfolg' ? 'Erfolg' : (zp.letzter_status === 'fehler' ? 'Fehler' : zp.letzter_status);
return ` return `
<div class="config-item" style="${aktivClass}"> <div class="config-item" style="${aktivClass}">
<div class="config-item-info"> <div class="config-item-info">
<h4>${typIcon} ${escapeHtml(zp.name)} <h4>${typIcon} ${escapeHtml(zp.name)}
<span class="badge ${zp.aktiv ? 'badge-success' : 'badge-danger'}">${zp.aktiv ? 'Aktiv' : 'Inaktiv'}</span> <span class="badge ${zp.aktiv ? 'badge-success' : 'badge-danger'}">${zp.aktiv ? 'Aktiv' : 'Inaktiv'}</span>
<span class="badge badge-info">${zp.intervall}</span> <span class="badge badge-info">${formatIntervall(zp.intervall)}</span>
</h4> </h4>
<small>Nächste: ${naechste} | Letzte: ${letzte}</small> <small>Nächste: ${naechste} | Letzte: ${letzte}</small>
${zp.letzter_status ? `<small style="display:block;" class="${statusClass}">Status: ${zp.letzter_status}${zp.letzte_meldung ? ' - ' + escapeHtml(zp.letzte_meldung.substring(0, 80)) : ''}</small>` : ''} ${zp.letzter_status ? `<small style="display:block;" class="${statusClass}">Status: ${statusText}${zp.letzte_meldung ? ' - ' + escapeHtml(zp.letzte_meldung.substring(0, 80)) : ''}</small>` : ''}
</div> </div>
<div class="config-item-actions"> <div class="config-item-actions">
<button class="btn btn-sm btn-success" onclick="zeitplanAusfuehren(${zp.id})" title="Jetzt ausführen"></button> <button class="btn btn-sm btn-success" onclick="zeitplanAusfuehren(${zp.id})" title="Jetzt ausführen"></button>
@ -2523,50 +2549,92 @@ function renderZeitplaene(zeitplaene) {
async function ladeStatus() { async function ladeStatus() {
try { try {
const result = await api('/status/uebersicht'); const result = await api('/status/uebersicht');
renderStatusUebersicht(result); renderStatusFuerTabs(result);
} catch (error) { } catch (error) {
console.error('Fehler:', error); console.error('Fehler beim Laden des Status:', error);
} }
} }
function renderStatusUebersicht(status) { function renderStatusFuerTabs(status) {
const container = document.getElementById('status-uebersicht'); // Mailabruf Status
const mailabrufContainer = document.getElementById('status-mailabruf');
let html = '<div class="status-grid">'; if (mailabrufContainer) {
let html = '';
// Postfächer if (status.postfaecher && status.postfaecher.length > 0) {
html += '<div class="status-section"><h4>📧 Postfächer</h4>'; for (const p of status.postfaecher) {
if (status.postfaecher && status.postfaecher.length > 0) { const aktiv = p.aktiv ? '🟢' : '⚪';
for (const p of status.postfaecher) { const letzte = p.letzter_abruf ? formatDatum(p.letzter_abruf) : 'Nie';
const aktiv = p.aktiv ? '🟢' : '⚪'; html += `<div class="status-item">${aktiv} ${escapeHtml(p.name)}: ${letzte} (${p.letzte_anzahl || 0} Dateien)</div>`;
const letzte = p.letzter_abruf ? formatDatum(p.letzter_abruf) : 'Nie'; }
html += `<div class="status-item">${aktiv} ${escapeHtml(p.name)}: ${letzte} (${p.letzte_anzahl || 0} Dateien)</div>`; } else {
html = '<p class="empty-state">Keine Postfächer konfiguriert</p>';
} }
} else { mailabrufContainer.innerHTML = html;
html += '<div class="status-item">Keine Postfächer</div>';
} }
html += '</div>';
// Grobsortierung // Grobsortierung Status
html += '<div class="status-section"><h4>📁 Grobsortierung</h4>'; const grobContainer = document.getElementById('status-grobsortierung');
if (status.quell_ordner && status.quell_ordner.length > 0) { if (grobContainer) {
for (const o of status.quell_ordner) { let html = '';
const aktiv = o.aktiv ? '🟢' : '⚪'; if (status.quell_ordner && status.quell_ordner.length > 0) {
html += `<div class="status-item">${aktiv} ${escapeHtml(o.name)}</div>`; for (const o of status.quell_ordner) {
const aktiv = o.aktiv ? '🟢' : '⚪';
html += `<div class="status-item">${aktiv} ${escapeHtml(o.name)}: ${escapeHtml(o.pfad)}</div>`;
}
} else {
html = '<p class="empty-state">Keine Quellordner konfiguriert</p>';
} }
} else { grobContainer.innerHTML = html;
html += '<div class="status-item">Keine Ordner</div>';
} }
html += '</div>';
// Scheduler // Feinsortierung Status
html += '<div class="status-section"><h4>⏰ Scheduler</h4>'; const feinContainer = document.getElementById('status-feinsortierung');
const schedulerStatus = status.scheduler?.scheduler_laeuft ? '🟢 Läuft' : '🔴 Gestoppt'; if (feinContainer) {
html += `<div class="status-item">${schedulerStatus}</div>`; let html = '';
html += '</div>'; const schedulerStatus = status.scheduler?.scheduler_laeuft ? '🟢 Scheduler läuft' : '🔴 Scheduler gestoppt';
const regelnZeitplaene = (status.scheduler?.zeitplaene || []).filter(z => z.typ === 'sortierregeln');
html += `<div class="status-item">${schedulerStatus}</div>`;
if (regelnZeitplaene.length > 0) {
for (const z of regelnZeitplaene) {
const statusIcon = z.letzter_status === 'erfolg' ? '✓' : (z.letzter_status === 'fehler' ? '✗' : '○');
html += `<div class="status-item">${statusIcon} ${escapeHtml(z.name)}: ${z.letzte_meldung || 'Noch nicht ausgeführt'}</div>`;
}
}
feinContainer.innerHTML = html;
}
html += '</div>'; // DB Backup Status
container.innerHTML = html; const dbContainer = document.getElementById('status-dbbackup');
if (dbContainer) {
let html = '';
const schedulerStatus = status.scheduler?.scheduler_laeuft ? '🟢 Scheduler läuft' : '🔴 Scheduler gestoppt';
const dbZeitplaene = (status.scheduler?.zeitplaene || []).filter(z => z.typ === 'db_backup');
html += `<div class="status-item">${schedulerStatus}</div>`;
if (dbZeitplaene.length > 0) {
for (const z of dbZeitplaene) {
const statusIcon = z.letzter_status === 'erfolg' ? '✓' : (z.letzter_status === 'fehler' ? '✗' : '○');
const letzteAusfuehrung = z.letzte_ausfuehrung ? formatDatum(z.letzte_ausfuehrung) : 'Noch nie';
html += `<div class="status-item">${statusIcon} ${escapeHtml(z.name)}</div>`;
html += `<div class="status-item" style="margin-left: 20px; font-size: 0.9em;">Letztes Backup: ${letzteAusfuehrung}</div>`;
if (z.letzte_meldung) {
html += `<div class="status-item" style="margin-left: 20px; font-size: 0.9em;">${escapeHtml(z.letzte_meldung)}</div>`;
}
}
} else {
html += '<div class="status-item">Keine DB-Backup Zeitpläne konfiguriert</div>';
}
dbContainer.innerHTML = html;
}
}
function formatIntervall(intervall) {
const mapping = {
'stündlich': 'Stündlich',
'täglich': 'Täglich',
'wöchentlich': 'Wöchentlich',
'monatlich': 'Monatlich'
};
return mapping[intervall] || intervall;
} }
function formatDatum(isoString) { function formatDatum(isoString) {
@ -2599,6 +2667,7 @@ async function zeigeZeitplanModal(zeitplan = null) {
document.getElementById('zp-postfach').value = zeitplan?.postfach_id || ''; document.getElementById('zp-postfach').value = zeitplan?.postfach_id || '';
document.getElementById('zp-ordner').value = zeitplan?.quell_ordner_id || ''; document.getElementById('zp-ordner').value = zeitplan?.quell_ordner_id || '';
document.getElementById('zp-regel').value = zeitplan?.regel_id || ''; document.getElementById('zp-regel').value = zeitplan?.regel_id || '';
document.getElementById('zp-db').value = zeitplan?.datenbank_id || '';
zeitplanTypChanged(); zeitplanTypChanged();
zeitplanIntervallChanged(); zeitplanIntervallChanged();
@ -2673,6 +2742,7 @@ async function speichereZeitplan() {
const postfachId = document.getElementById('zp-postfach').value; const postfachId = document.getElementById('zp-postfach').value;
const ordnerId = document.getElementById('zp-ordner').value; const ordnerId = document.getElementById('zp-ordner').value;
const regelId = document.getElementById('zp-regel').value; const regelId = document.getElementById('zp-regel').value;
const dbId = document.getElementById('zp-db').value;
if (data.typ === 'mail_abruf' && postfachId) { if (data.typ === 'mail_abruf' && postfachId) {
data.postfach_id = parseInt(postfachId); data.postfach_id = parseInt(postfachId);
@ -2683,6 +2753,9 @@ async function speichereZeitplan() {
if (data.typ === 'sortierregeln' && regelId) { if (data.typ === 'sortierregeln' && regelId) {
data.regel_id = parseInt(regelId); data.regel_id = parseInt(regelId);
} }
if (data.typ === 'db_backup' && dbId) {
data.datenbank_id = parseInt(dbId);
}
if (!data.name) { if (!data.name) {
showAlert('Bitte einen Namen eingeben', 'warning'); showAlert('Bitte einen Namen eingeben', 'warning');
@ -2696,17 +2769,34 @@ async function speichereZeitplan() {
await api('/zeitplaene', { method: 'POST', body: JSON.stringify(data) }); await api('/zeitplaene', { method: 'POST', body: JSON.stringify(data) });
} }
schliesseModal('zeitplan-modal'); schliesseModal('zeitplan-modal');
ladeZeitplaene(); aktualisiereZeitplanListen();
ladeStatus(); ladeStatus();
} catch (error) { } catch (error) {
showAlert(error.message, 'error'); showAlert(error.message, 'error');
} }
} }
function aktualisiereZeitplanListen() {
ladeZeitplaene();
const aktiverTab = localStorage.getItem('aktiver-tab');
if (aktiverTab) {
const tabMapping = {
'mailabruf': ['mail_abruf', 'zeitplaene-mailabruf'],
'grobsortierung': ['grobsortierung', 'zeitplaene-grobsortierung'],
'feinsortierung': ['sortierregeln', 'zeitplaene-feinsortierung'],
'dbbackup': ['db_backup', 'zeitplaene-dbbackup']
};
const mapping = tabMapping[aktiverTab];
if (mapping) {
ladeZeitplaeneNachTyp(mapping[0], mapping[1]);
}
}
}
async function zeitplanAktivieren(id) { async function zeitplanAktivieren(id) {
try { try {
await api(`/zeitplaene/${id}/aktivieren`, { method: 'POST' }); await api(`/zeitplaene/${id}/aktivieren`, { method: 'POST' });
ladeZeitplaene(); aktualisiereZeitplanListen();
} catch (error) { } catch (error) {
showAlert(error.message, 'error'); showAlert(error.message, 'error');
} }
@ -2717,7 +2807,7 @@ async function zeitplanAusfuehren(id) {
zeigeLoading('Führe Zeitplan aus...'); zeigeLoading('Führe Zeitplan aus...');
const result = await api(`/zeitplaene/${id}/ausfuehren`, { method: 'POST' }); const result = await api(`/zeitplaene/${id}/ausfuehren`, { method: 'POST' });
showAlert(result.meldung || 'Ausgeführt', 'success', 'Zeitplan ausgeführt'); showAlert(result.meldung || 'Ausgeführt', 'success', 'Zeitplan ausgeführt');
ladeZeitplaene(); aktualisiereZeitplanListen();
ladeStatus(); ladeStatus();
} catch (error) { } catch (error) {
showAlert(error.message, 'error'); showAlert(error.message, 'error');
@ -2739,7 +2829,7 @@ async function zeitplanLoeschen(id) {
if (!await showConfirm('Zeitplan wirklich löschen?')) return; if (!await showConfirm('Zeitplan wirklich löschen?')) return;
try { try {
await api(`/zeitplaene/${id}`, { method: 'DELETE' }); await api(`/zeitplaene/${id}`, { method: 'DELETE' });
ladeZeitplaene(); aktualisiereZeitplanListen();
} catch (error) { } catch (error) {
showAlert(error.message, 'error'); showAlert(error.message, 'error');
} }
@ -2835,8 +2925,8 @@ function debugLog(message, type = 'info') {
async function ladeZeitplaeneNachTyp(typ, containerId) { async function ladeZeitplaeneNachTyp(typ, containerId) {
try { try {
const zeitplaene = await api('/zeitplaene'); const result = await api('/zeitplaene');
const gefiltert = zeitplaene.filter(z => z.typ === typ); const gefiltert = (result.zeitplaene || []).filter(z => z.typ === typ);
const container = document.getElementById(containerId); const container = document.getElementById(containerId);
if (gefiltert.length === 0) { if (gefiltert.length === 0) {
@ -2844,19 +2934,25 @@ async function ladeZeitplaeneNachTyp(typ, containerId) {
return; return;
} }
container.innerHTML = gefiltert.map(z => ` container.innerHTML = gefiltert.map(z => {
<div class="config-item"> const letzteAusfuehrung = z.letzte_ausfuehrung ? formatDatum(z.letzte_ausfuehrung) : 'Noch nie';
const naechsteAusfuehrung = z.naechste_ausfuehrung ? formatDatum(z.naechste_ausfuehrung) : '-';
return `
<div class="config-item" style="${z.aktiv ? '' : 'opacity: 0.5;'}">
<div class="config-item-info"> <div class="config-item-info">
<h4>${escapeHtml(z.name)} ${z.aktiv ? '✓' : ''}</h4> <h4>${escapeHtml(z.name)} ${z.aktiv ? '✓' : ''}</h4>
<small>${z.intervall} ${z.stunde != null ? `um ${z.stunde}:${String(z.minute || 0).padStart(2, '0')}` : ''}</small> <small>${formatIntervall(z.intervall)} ${z.stunde != null ? `um ${z.stunde}:${String(z.minute || 0).padStart(2, '0')} Uhr` : ''}</small>
<small style="display:block;">Nächste: ${naechsteAusfuehrung} | Letzte: ${letzteAusfuehrung}</small>
${z.letzter_status ? `<small style="display:block;" class="${z.letzter_status === 'erfolg' ? 'success' : 'error'}">Status: ${z.letzter_status === 'erfolg' ? 'Erfolg' : 'Fehler'}${z.letzte_meldung ? ' - ' + escapeHtml(z.letzte_meldung.substring(0, 60)) : ''}</small>` : ''}
</div> </div>
<div class="config-item-actions"> <div class="config-item-actions">
<button class="btn btn-sm" onclick="zeitplanAusfuehren(${z.id})" title="Jetzt ausführen"></button> <button class="btn btn-sm btn-success" onclick="zeitplanAusfuehren(${z.id})" title="Jetzt ausführen"></button>
<button class="btn btn-sm" onclick="zeitplanAktivieren(${z.id})">${z.aktiv ? '⏸' : '▶'}</button> <button class="btn btn-sm" onclick="zeitplanBearbeiten(${z.id})" title="Bearbeiten"></button>
<button class="btn btn-sm btn-danger" onclick="zeitplanLoeschen(${z.id})">🗑</button> <button class="btn btn-sm" onclick="zeitplanAktivieren(${z.id})" title="${z.aktiv ? 'Deaktivieren' : 'Aktivieren'}">${z.aktiv ? '⏸' : '▶'}</button>
<button class="btn btn-sm btn-danger" onclick="zeitplanLoeschen(${z.id})" title="Löschen">🗑</button>
</div> </div>
</div> </div>
`).join(''); `}).join('');
} catch (error) { } catch (error) {
console.error('Fehler beim Laden der Zeitpläne:', error); console.error('Fehler beim Laden der Zeitpläne:', error);
} }