Import/Export: CSV-Export (Alle/Aktive/SEPA), JSON-Backup, CSV-Import mit Spalten-Mapping

This commit is contained in:
rene 2026-05-20 20:25:07 +02:00
parent 95c2dc0f26
commit 59d94f9c47
4 changed files with 464 additions and 0 deletions

18
app/package-lock.json generated
View file

@ -9,6 +9,7 @@
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"ical-generator": "^10.2.0", "ical-generator": "^10.2.0",
"papaparse": "^5.5.3",
"pocketbase": "^0.26.9", "pocketbase": "^0.26.9",
"rrule": "^2.8.1", "rrule": "^2.8.1",
"web-push": "^3.6.7" "web-push": "^3.6.7"
@ -17,6 +18,7 @@
"@sveltejs/adapter-node": "^5.5.4", "@sveltejs/adapter-node": "^5.5.4",
"@sveltejs/kit": "^2.57.0", "@sveltejs/kit": "^2.57.0",
"@sveltejs/vite-plugin-svelte": "^7.0.0", "@sveltejs/vite-plugin-svelte": "^7.0.0",
"@types/papaparse": "^5.5.2",
"@types/web-push": "^3.6.4", "@types/web-push": "^3.6.4",
"svelte": "^5.55.2", "svelte": "^5.55.2",
"svelte-check": "^4.4.6", "svelte-check": "^4.4.6",
@ -2655,6 +2657,16 @@
"undici-types": ">=7.24.0 <7.24.7" "undici-types": ">=7.24.0 <7.24.7"
} }
}, },
"node_modules/@types/papaparse": {
"version": "5.5.2",
"resolved": "https://registry.npmjs.org/@types/papaparse/-/papaparse-5.5.2.tgz",
"integrity": "sha512-gFnFp/JMzLHCwRf7tQHrNnfhN4eYBVYYI897CGX4MY1tzY9l2aLkVyx2IlKZ/SAqDbB3I1AOZW5gTMGGsqWliA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/resolve": { "node_modules/@types/resolve": {
"version": "1.20.2", "version": "1.20.2",
"resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz",
@ -5090,6 +5102,12 @@
"dev": true, "dev": true,
"license": "BlueOak-1.0.0" "license": "BlueOak-1.0.0"
}, },
"node_modules/papaparse": {
"version": "5.5.3",
"resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.5.3.tgz",
"integrity": "sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A==",
"license": "MIT"
},
"node_modules/path-key": { "node_modules/path-key": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",

View file

@ -15,6 +15,7 @@
"@sveltejs/adapter-node": "^5.5.4", "@sveltejs/adapter-node": "^5.5.4",
"@sveltejs/kit": "^2.57.0", "@sveltejs/kit": "^2.57.0",
"@sveltejs/vite-plugin-svelte": "^7.0.0", "@sveltejs/vite-plugin-svelte": "^7.0.0",
"@types/papaparse": "^5.5.2",
"@types/web-push": "^3.6.4", "@types/web-push": "^3.6.4",
"svelte": "^5.55.2", "svelte": "^5.55.2",
"svelte-check": "^4.4.6", "svelte-check": "^4.4.6",
@ -26,6 +27,7 @@
}, },
"dependencies": { "dependencies": {
"ical-generator": "^10.2.0", "ical-generator": "^10.2.0",
"papaparse": "^5.5.3",
"pocketbase": "^0.26.9", "pocketbase": "^0.26.9",
"rrule": "^2.8.1", "rrule": "^2.8.1",
"web-push": "^3.6.7" "web-push": "^3.6.7"

View file

@ -330,6 +330,10 @@
<div class="divider"></div> <div class="divider"></div>
<a href="/import-export" class="btn-importexport">Import / Export</a>
<div class="divider" style="margin-top:1rem"></div>
<button class="btn-logout" onclick={abmelden}>Abmelden</button> <button class="btn-logout" onclick={abmelden}>Abmelden</button>
{/if} {/if}
@ -482,6 +486,14 @@
cursor: pointer; align-self: flex-start; cursor: pointer; align-self: flex-start;
} }
.btn-importexport {
display: block; width: 100%; padding: 0.75rem;
background: none; border: 1.5px solid #e2e8f0; border-radius: 8px;
font-size: 0.95rem; color: #1e40af; text-align: center;
text-decoration: none; margin-bottom: 0;
}
.btn-importexport:hover { border-color: #1e40af; background: #f0f9ff; }
.btn-logout { .btn-logout {
width: 100%; width: 100%;
padding: 0.75rem; padding: 0.75rem;

View file

@ -0,0 +1,432 @@
<script lang="ts">
import { pb } from '$lib/pb';
import { onMount } from 'svelte';
import Papa from 'papaparse';
import type { Mitglied } from '$lib/types';
let loading = $state(true);
let mitglieder = $state<Mitglied[]>([]);
let vereinName = $state('');
// Export
let exportStatus = $state('');
// Import
let csvHeaders = $state<string[]>([]);
let csvRows = $state<Record<string, string>[]>([]);
let feldMapping = $state<Record<string, string>>({});
let importPhase = $state<'idle' | 'mapping' | 'preview' | 'done'>('idle');
let importResult = $state<{ ok: number; fehler: string[] }>({ ok: 0, fehler: [] });
let importLaeuft = $state(false);
// Mögliche Zielfelder
const zielfelder: { key: string; label: string }[] = [
{ key: '', label: '— ignorieren —' },
{ key: 'vorname', label: 'Vorname' },
{ key: 'nachname', label: 'Nachname' },
{ key: 'email', label: 'E-Mail' },
{ key: 'telefon', label: 'Telefon' },
{ key: 'geburtsdatum', label: 'Geburtsdatum (YYYY-MM-DD)' },
{ key: 'eintrittsdatum',label: 'Eintrittsdatum (YYYY-MM-DD)' },
{ key: 'austrittsdatum',label: 'Austrittsdatum (YYYY-MM-DD)' },
{ key: 'strasse', label: 'Straße' },
{ key: 'plz', label: 'PLZ' },
{ key: 'ort', label: 'Ort' },
{ key: 'iban', label: 'IBAN' },
{ key: 'bic', label: 'BIC' },
{ key: 'status', label: 'Status (aktiv/passiv/ausgetreten)' },
{ key: 'notizen', label: 'Notizen' },
];
onMount(async () => {
const vid = pb.authStore.record?.verein_id as string;
const [m, v] = await Promise.all([
pb.collection('mitglieder').getFullList<Mitglied>({ sort: 'nachname,vorname' }),
pb.collection('vereine').getOne<any>(vid),
]);
mitglieder = m;
vereinName = v.name ?? '';
loading = false;
});
// ── EXPORT ──────────────────────────────────────────────────────────────
function toCSV(rows: Record<string, any>[], fields: string[]): string {
const escape = (v: any) => {
const s = String(v ?? '');
return s.includes(',') || s.includes('"') || s.includes('\n')
? `"${s.replace(/"/g, '""')}"` : s;
};
const header = fields.join(',');
const body = rows.map(r => fields.map(f => escape(r[f])).join(','));
return '' + [header, ...body].join('\r\n'); // BOM für Excel
}
function downloadCSV(csv: string, name: string) {
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = name; a.click();
URL.revokeObjectURL(url);
}
function exportiereAlle() {
const felder = ['vorname','nachname','email','telefon','geburtsdatum',
'eintrittsdatum','austrittsdatum','strasse','plz','ort',
'iban','bic','status','notizen'];
downloadCSV(toCSV(mitglieder, felder), `${vereinName}-Mitglieder.csv`);
exportStatus = `${mitglieder.length} Mitglieder exportiert.`;
}
function exportiereAktive() {
const aktive = mitglieder.filter(m => m.status === 'aktiv');
const felder = ['vorname','nachname','email','telefon','strasse','plz','ort','status'];
downloadCSV(toCSV(aktive, felder), `${vereinName}-Aktive.csv`);
exportStatus = `${aktive.length} aktive Mitglieder exportiert.`;
}
function exportiereMitIBAN() {
const sepa = mitglieder.filter(m => m.iban?.trim());
const felder = ['vorname','nachname','iban','bic','mandatsreferenz','mandatsdatum','status'];
downloadCSV(toCSV(sepa, felder), `${vereinName}-SEPA.csv`);
exportStatus = `${sepa.length} Mitglieder mit IBAN exportiert.`;
}
async function exportiereBackup() {
const vid = pb.authStore.record?.verein_id as string;
const [verein, mitgl, gruppen, termine, beitraege, nachrichten] = await Promise.all([
pb.collection('vereine').getOne<any>(vid),
pb.collection('mitglieder').getFullList(),
pb.collection('gruppen').getFullList(),
pb.collection('termine').getFullList(),
pb.collection('beitraege').getFullList(),
pb.collection('nachrichten').getFullList(),
]);
const backup = {
exportiert_am: new Date().toISOString(),
version: '1',
verein, mitglieder: mitgl, gruppen, termine, beitraege, nachrichten,
};
const blob = new Blob([JSON.stringify(backup, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = `${vereinName}-Backup-${new Date().toISOString().slice(0,10)}.json`;
a.click(); URL.revokeObjectURL(url);
exportStatus = 'Datensicherung heruntergeladen.';
}
// ── IMPORT ──────────────────────────────────────────────────────────────
// Versuche automatisch Spalten zu Feldern zuzuordnen
function automap(headers: string[]): Record<string, string> {
const normalize = (s: string) => s.toLowerCase().replace(/[^a-z]/g, '');
const aliase: Record<string, string> = {
vorname: 'vorname', firstname: 'vorname', givenname: 'vorname',
nachname: 'nachname', lastname: 'nachname', surname: 'nachname', name: 'nachname',
email: 'email', mail: 'email', emailadresse: 'email',
telefon: 'telefon', phone: 'telefon', tel: 'telefon', mobil: 'telefon',
geburtsdatum: 'geburtsdatum', geburtstag: 'geburtsdatum', birthdate: 'geburtsdatum',
eintrittsdatum: 'eintrittsdatum', eintritt: 'eintrittsdatum', mitgliedseit: 'eintrittsdatum',
strasse: 'strasse', street: 'strasse', adresse: 'strasse',
plz: 'plz', postleitzahl: 'plz', zip: 'plz',
ort: 'ort', city: 'ort', stadt: 'ort',
iban: 'iban', bic: 'bic', swift: 'bic',
status: 'status', mitgliedsstatus: 'status',
notizen: 'notizen', notes: 'notizen', bemerkung: 'notizen', bemerkungen: 'notizen',
};
const mapping: Record<string, string> = {};
for (const h of headers) {
mapping[h] = aliase[normalize(h)] ?? '';
}
return mapping;
}
function handleDateiUpload(e: Event) {
const file = (e.target as HTMLInputElement).files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (ev) => {
const text = ev.target?.result as string;
const result = Papa.parse<Record<string, string>>(text, {
header: true, skipEmptyLines: true,
});
csvHeaders = (result as any).meta?.fields ?? [];
csvRows = (result as any).data ?? [];
feldMapping = automap(csvHeaders);
importPhase = 'mapping';
};
reader.readAsText(file, 'UTF-8');
}
function normalisiereStatus(s: string): Mitglied['status'] {
const v = s.toLowerCase().trim();
if (v === 'passiv' || v === 'passive') return 'passiv';
if (v === 'ausgetreten' || v === 'ex' || v === 'former') return 'ausgetreten';
return 'aktiv';
}
function normalisiereDate(s: string): string | null {
if (!s?.trim()) return null;
// DD.MM.YYYY → YYYY-MM-DD
const de = s.match(/^(\d{1,2})\.(\d{1,2})\.(\d{4})$/);
if (de) return `${de[3]}-${de[2].padStart(2,'0')}-${de[1].padStart(2,'0')}`;
// already YYYY-MM-DD
if (/^\d{4}-\d{2}-\d{2}/.test(s)) return s.slice(0, 10);
return null;
}
async function importStarten() {
importLaeuft = true;
const vid = pb.authStore.record?.verein_id as string;
let ok = 0;
const fehler: string[] = [];
for (const row of csvRows) {
const record: Record<string, any> = { verein_id: vid, status: 'aktiv' };
for (const [csvSpalte, ziel] of Object.entries(feldMapping)) {
if (!ziel) continue;
const wert = row[csvSpalte]?.trim() ?? '';
if (['geburtsdatum','eintrittsdatum','austrittsdatum','mandatsdatum'].includes(ziel)) {
const d = normalisiereDate(wert);
if (d) record[ziel] = d;
} else if (ziel === 'status') {
record[ziel] = normalisiereStatus(wert);
} else if (wert) {
record[ziel] = wert;
}
}
if (!record.vorname || !record.nachname) {
fehler.push(`Zeile übersprungen: Vor- oder Nachname fehlt (${row[csvHeaders[0]] ?? '?'})`);
continue;
}
try {
await pb.collection('mitglieder').create(record);
ok++;
} catch (e: unknown) {
fehler.push(`${record.vorname} ${record.nachname}: ${e instanceof Error ? e.message : 'Fehler'}`);
}
}
// Mitgliederliste aktualisieren
mitglieder = await pb.collection('mitglieder').getFullList<Mitglied>({ sort: 'nachname,vorname' });
importResult = { ok, fehler };
importPhase = 'done';
importLaeuft = false;
}
function resetImport() {
csvHeaders = []; csvRows = []; feldMapping = {};
importPhase = 'idle'; importResult = { ok: 0, fehler: [] };
}
</script>
<svelte:head><title>Import / Export — vereins.haus</title></svelte:head>
<div class="top">
<a class="back" href="/einstellungen">← Einstellungen</a>
<h1>Import / Export</h1>
</div>
{#if loading}
<p class="hint">Laden…</p>
{:else}
<!-- ── EXPORT ────────────────────────────────────── -->
<section>
<h2>Mitglieder exportieren</h2>
<div class="export-grid">
<button class="export-btn" onclick={exportiereAlle}>
<span class="export-icon">📋</span>
<span class="export-label">Alle Mitglieder</span>
<span class="export-sub">CSV · alle Felder · {mitglieder.length} Einträge</span>
</button>
<button class="export-btn" onclick={exportiereAktive}>
<span class="export-icon"></span>
<span class="export-label">Aktive Mitglieder</span>
<span class="export-sub">CSV · Kontaktfelder · {mitglieder.filter(m => m.status === 'aktiv').length} Einträge</span>
</button>
<button class="export-btn" onclick={exportiereMitIBAN}>
<span class="export-icon"></span>
<span class="export-label">SEPA-Liste</span>
<span class="export-sub">CSV · IBAN/Mandat · {mitglieder.filter(m => m.iban?.trim()).length} Einträge</span>
</button>
</div>
{#if exportStatus}
<p class="status-ok">{exportStatus}</p>
{/if}
</section>
<section>
<h2>Datensicherung</h2>
<p class="hinweis-text">Vollständige Sicherungskopie aller Vereinsdaten als JSON für Archivierung oder Wechsel der Software (DSGVO Art. 20).</p>
<button class="export-btn breit" onclick={exportiereBackup}>
<span class="export-icon">💾</span>
<span class="export-label">Datensicherung herunterladen</span>
<span class="export-sub">JSON · Mitglieder, Termine, Beiträge, Nachrichten</span>
</button>
</section>
<!-- ── IMPORT ────────────────────────────────────── -->
<section>
<h2>Mitglieder importieren</h2>
<p class="hinweis-text">CSV-Datei hochladen die Spalten werden automatisch erkannt und können vor dem Import angepasst werden.</p>
{#if importPhase === 'idle'}
<label class="upload-label">
<input type="file" accept=".csv,.txt" onchange={handleDateiUpload} class="file-input" />
<span class="upload-icon"></span>
<span>CSV-Datei auswählen</span>
<span class="upload-sub">UTF-8 oder Windows-1252, Komma- oder Semikolon-getrennt</span>
</label>
{:else if importPhase === 'mapping'}
<div class="mapping-header">
<span>{csvRows.length} Zeilen erkannt · Spalten zuordnen:</span>
<button class="btn-ghost-sm" onclick={resetImport}>Abbrechen</button>
</div>
<div class="mapping-tabelle">
<div class="mapping-row header">
<span>CSV-Spalte</span>
<span>→ Vereinshaus-Feld</span>
<span>Vorschau (1. Zeile)</span>
</div>
{#each csvHeaders as h}
<div class="mapping-row">
<span class="col-name">{h}</span>
<select bind:value={feldMapping[h]}>
{#each zielfelder as f}
<option value={f.key}>{f.label}</option>
{/each}
</select>
<span class="col-preview">{csvRows[0]?.[h] ?? ''}</span>
</div>
{/each}
</div>
<div class="mapping-actions">
<p class="hinweis-text">Vor- und Nachname sind Pflichtfelder.</p>
<button class="btn-primary" onclick={importStarten} disabled={importLaeuft}>
{importLaeuft ? `Importiere…` : `${csvRows.length} Mitglieder importieren`}
</button>
</div>
{:else if importPhase === 'done'}
<div class="import-result" class:hat-fehler={importResult.fehler.length > 0}>
<p class="result-ok">{importResult.ok} Mitglieder importiert</p>
{#if importResult.fehler.length > 0}
<p class="result-fehler-titel">{importResult.fehler.length} Probleme:</p>
<ul class="fehler-liste">
{#each importResult.fehler as f}
<li>{f}</li>
{/each}
</ul>
{/if}
<button class="btn-ghost" onclick={resetImport}>Weiteren Import starten</button>
</div>
{/if}
</section>
{/if}
<style>
.top { margin-bottom: 1.25rem; }
.back { font-size: 0.85rem; color: #1e40af; text-decoration: none; display: block; margin-bottom: 0.25rem; }
h1 { font-size: 1.4rem; font-weight: 700; color: #1e293b; }
.hint { color: #94a3b8; text-align: center; margin-top: 3rem; }
section {
margin-bottom: 2rem;
padding-bottom: 1.5rem;
border-bottom: 1px solid #f1f5f9;
}
h2 {
font-size: 0.72rem; font-weight: 700; color: #94a3b8;
text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 0.75rem;
}
.hinweis-text { font-size: 0.85rem; color: #64748b; margin-bottom: 0.85rem; line-height: 1.5; }
/* Export */
.export-grid { display: flex; flex-direction: column; gap: 0.5rem; margin-bottom: 0.75rem; }
.export-btn {
display: flex; align-items: center; gap: 0.85rem;
padding: 0.85rem 1rem; background: #fff;
border: 1.5px solid #e2e8f0; border-radius: 10px;
cursor: pointer; text-align: left; width: 100%;
transition: border-color 0.15s;
}
.export-btn:hover { border-color: #1e40af; }
.export-btn.breit { width: 100%; }
.export-icon { font-size: 1.2rem; flex-shrink: 0; width: 1.5rem; text-align: center; }
.export-label { font-weight: 600; font-size: 0.9rem; color: #1e293b; flex: 1; }
.export-sub { font-size: 0.75rem; color: #94a3b8; white-space: nowrap; }
.status-ok { font-size: 0.85rem; color: #16a34a; }
/* Upload */
.upload-label {
display: flex; flex-direction: column; align-items: center; gap: 0.4rem;
padding: 2rem 1rem; border: 2px dashed #e2e8f0; border-radius: 10px;
cursor: pointer; color: #64748b; transition: border-color 0.15s; text-align: center;
}
.upload-label:hover { border-color: #1e40af; color: #1e40af; }
.file-input { display: none; }
.upload-icon { font-size: 1.5rem; }
.upload-sub { font-size: 0.75rem; color: #94a3b8; }
/* Mapping */
.mapping-header {
display: flex; justify-content: space-between; align-items: center;
margin-bottom: 0.75rem; font-size: 0.85rem; color: #64748b;
}
.btn-ghost-sm {
padding: 0.3rem 0.65rem; background: none;
border: 1px solid #e2e8f0; border-radius: 6px;
font-size: 0.8rem; color: #64748b; cursor: pointer;
}
.mapping-tabelle {
border: 1px solid #e2e8f0; border-radius: 8px; overflow: hidden;
margin-bottom: 1rem; overflow-x: auto;
}
.mapping-row {
display: grid; grid-template-columns: 1fr 1.4fr 1fr;
gap: 0.5rem; padding: 0.5rem 0.75rem;
border-bottom: 1px solid #f1f5f9; align-items: center;
}
.mapping-row:last-child { border-bottom: none; }
.mapping-row.header { background: #f8fafc; font-size: 0.72rem; font-weight: 700; color: #94a3b8; text-transform: uppercase; }
.col-name { font-size: 0.82rem; color: #1e293b; font-weight: 500; }
.col-preview { font-size: 0.75rem; color: #94a3b8; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.mapping-row select {
padding: 0.3rem 0.5rem; border: 1px solid #e2e8f0; border-radius: 6px;
font-size: 0.8rem; background: #fff; width: 100%;
}
.mapping-actions { display: flex; flex-direction: column; gap: 0.5rem; }
/* Ergebnis */
.import-result {
background: #f0fdf4; border: 1px solid #86efac; border-radius: 10px;
padding: 1rem; display: flex; flex-direction: column; gap: 0.5rem;
}
.import-result.hat-fehler { background: #fefce8; border-color: #fde047; }
.result-ok { font-weight: 700; color: #16a34a; font-size: 0.95rem; }
.result-fehler-titel { font-size: 0.85rem; color: #92400e; font-weight: 600; }
.fehler-liste { margin: 0; padding-left: 1.2rem; }
.fehler-liste li { font-size: 0.78rem; color: #92400e; }
.btn-primary {
padding: 0.75rem; background: #1e40af; color: #fff;
border: none; border-radius: 8px; font-size: 0.95rem; font-weight: 600;
cursor: pointer; transition: background 0.15s; width: 100%;
}
.btn-primary:hover:not(:disabled) { background: #1d3a9e; }
.btn-primary:disabled { opacity: 0.55; cursor: not-allowed; }
.btn-ghost {
padding: 0.6rem 1rem; background: none;
border: 1.5px solid #e2e8f0; border-radius: 8px;
font-size: 0.9rem; color: #64748b; cursor: pointer; align-self: flex-start;
}
</style>