Import/Export: CSV-Export (Alle/Aktive/SEPA), JSON-Backup, CSV-Import mit Spalten-Mapping
This commit is contained in:
parent
95c2dc0f26
commit
59d94f9c47
4 changed files with 464 additions and 0 deletions
18
app/package-lock.json
generated
18
app/package-lock.json
generated
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
432
app/src/routes/(app)/import-export/+page.svelte
Normal file
432
app/src/routes/(app)/import-export/+page.svelte
Normal 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>
|
||||||
Loading…
Add table
Add a link
Reference in a new issue