vereinshaus/app/src/routes/(app)/mitglieder/neu/+page.svelte
rene 39981c0d17 Migrate: PocketBase → SvelteKit + better-sqlite3 + JWT
Vollständige Migration weg von PocketBase. Neuer Stack:
- better-sqlite3 (WAL-Mode, direkte SQLite-Abfragen)
- jose (JWT HS256, 30 Tage Laufzeit)
- bcryptjs (Passwort-Hashing, cost 12)

Neue Dateien:
- src/lib/server/db.ts    → SQLite-Singleton + Schema + Helpers
- src/lib/server/auth.ts  → JWT sign/verify, bcrypt, Bearer-Token
- src/lib/user.ts         → Svelte-Store (ersetzt pb.authStore)
- src/lib/api.ts          → fetch()-Wrapper (ersetzt pb.collection())
- src/app.d.ts            → App.Locals TypeScript-Deklaration
- 30 neue API-Routes unter src/routes/api/

Entfernt:
- Abhängigkeit von pocketbase npm-Paket (bleibt im package.json bis
  alle Referenzen bereinigt sind)
- PocketBase-Container aus docker-compose.yml
- Migrations und Hooks aus Deploy-Pipeline

Docker: Ein einziger Container, SQLite-Volume unter /data/
Makefile: PocketBase-spezifische Targets entfernt
seed.js: Komplett neu für neue REST-API
2026-05-21 21:55:04 +02:00

257 lines
7.9 KiB
Svelte

<script lang="ts">
import { api } from '$lib/api';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
let gruppen = $state<any[]>([]);
// Stammdaten
let vorname = $state('');
let nachname = $state('');
let email = $state('');
let telefon = $state('');
let geburtsdatum = $state('');
let status = $state('aktiv');
let eintrittsdatum = $state('');
let gruppe_ids = $state<string[]>([]);
let notizen = $state('');
// Adresse
let strasse = $state('');
let plz = $state('');
let ort = $state('');
// SEPA
let iban = $state('');
let bic = $state('');
let mandatsreferenz = $state('');
let mandatsdatum = $state('');
let error = $state('');
let loading = $state(false);
onMount(async () => {
gruppen = await api.get<any[]>('/gruppen', { sort: 'name' });
});
function toggleGruppe(id: string) {
gruppe_ids = gruppe_ids.includes(id)
? gruppe_ids.filter(g => g !== id)
: [...gruppe_ids, id];
}
async function speichern() {
error = ''; loading = true;
try {
await api.post('/mitglieder', {
vorname: vorname.trim(),
nachname: nachname.trim(),
email: email.trim() || null,
telefon: telefon.trim() || null,
geburtsdatum: geburtsdatum || null,
status,
eintrittsdatum: eintrittsdatum || null,
strasse: strasse.trim() || null,
plz: plz.trim() || null,
ort: ort.trim() || null,
iban: iban.trim() || null,
bic: bic.trim() || null,
mandatsreferenz: mandatsreferenz.trim() || null,
mandatsdatum: mandatsdatum || null,
notizen: notizen.trim() || null,
gruppe_ids,
});
goto('/mitglieder');
} catch (e: unknown) {
error = e instanceof Error ? e.message : 'Fehler beim Speichern.';
} finally {
loading = false;
}
}
</script>
<svelte:head><title>Neues Mitglied — vereins.haus</title></svelte:head>
<div class="top">
<a class="back" href="/mitglieder">← Zurück</a>
<h1>Neues Mitglied</h1>
</div>
<form onsubmit={(e) => { e.preventDefault(); speichern(); }}>
<section>
<h2>Stammdaten</h2>
<div class="row">
<div class="field">
<label for="vorname">Vorname *</label>
<input id="vorname" type="text" bind:value={vorname} required autocomplete="given-name" />
</div>
<div class="field">
<label for="nachname">Nachname *</label>
<input id="nachname" type="text" bind:value={nachname} required autocomplete="family-name" />
</div>
</div>
<div class="row">
<div class="field">
<label for="status">Status</label>
<select id="status" bind:value={status}>
<option value="aktiv">Aktiv</option>
<option value="passiv">Passiv</option>
<option value="ausgetreten">Ausgetreten</option>
</select>
</div>
<div class="field">
<label for="geburtsdatum">Geburtsdatum</label>
<input id="geburtsdatum" type="date" bind:value={geburtsdatum} />
</div>
</div>
<div class="row">
<div class="field">
<label for="eintrittsdatum">Eintrittsdatum</label>
<input id="eintrittsdatum" type="date" bind:value={eintrittsdatum} />
</div>
</div>
</section>
<section>
<h2>Kontakt</h2>
<div class="field">
<label for="email">E-Mail</label>
<input id="email" type="email" bind:value={email} autocomplete="email" />
</div>
<div class="field">
<label for="telefon">Telefon</label>
<input id="telefon" type="tel" bind:value={telefon} autocomplete="tel" />
</div>
</section>
<section>
<h2>Adresse</h2>
<div class="field">
<label for="strasse">Straße & Hausnummer</label>
<input id="strasse" type="text" bind:value={strasse} autocomplete="street-address" />
</div>
<div class="row">
<div class="field" style="flex: 0 0 5rem">
<label for="plz">PLZ</label>
<input id="plz" type="text" inputmode="numeric" bind:value={plz} autocomplete="postal-code" />
</div>
<div class="field">
<label for="ort">Ort</label>
<input id="ort" type="text" bind:value={ort} autocomplete="address-level2" />
</div>
</div>
</section>
<section>
<h2>SEPA-Lastschrift</h2>
<div class="field">
<label for="iban">IBAN</label>
<input id="iban" type="text" bind:value={iban} placeholder="DE12 3456 7890 …" autocomplete="off" />
</div>
<div class="field">
<label for="bic">BIC</label>
<input id="bic" type="text" bind:value={bic} placeholder="COBADEFFXXX" autocomplete="off" />
</div>
<div class="row">
<div class="field">
<label for="mandatsreferenz">Mandatsreferenz</label>
<input id="mandatsreferenz" type="text" bind:value={mandatsreferenz} placeholder="wird automatisch vergeben" />
</div>
<div class="field">
<label for="mandatsdatum">Mandatsdatum</label>
<input id="mandatsdatum" type="date" bind:value={mandatsdatum} />
</div>
</div>
</section>
{#if gruppen.length > 0}
<section>
<h2>Gruppen</h2>
<div class="checkboxes">
{#each gruppen as g (g.id)}
<label class="check-label" class:active={gruppe_ids.includes(g.id)}>
<input type="checkbox" checked={gruppe_ids.includes(g.id)} onchange={() => toggleGruppe(g.id)} />
{g.name}
</label>
{/each}
</div>
</section>
{/if}
<section>
<h2>Notizen</h2>
<div class="field">
<label for="notizen">Interne Notizen</label>
<textarea id="notizen" bind:value={notizen} rows="3" placeholder="Nur für Vorstand sichtbar"></textarea>
</div>
</section>
{#if error}
<p class="error">{error}</p>
{/if}
<div class="actions">
<a class="btn-ghost" href="/mitglieder">Abbrechen</a>
<button type="submit" class="btn-primary" disabled={loading || !vorname || !nachname}>
{loading ? 'Speichern…' : 'Mitglied anlegen'}
</button>
</div>
</form>
<style>
.top { margin-bottom: 1.25rem; }
.back { font-size: 0.9rem; color: #1e40af; text-decoration: none; display: block; margin-bottom: 0.5rem; }
h1 { font-size: 1.4rem; font-weight: 700; color: #1e293b; }
section {
margin-bottom: 1.5rem;
padding-bottom: 1.5rem;
border-bottom: 1px solid #f1f5f9;
}
section:last-of-type { border-bottom: none; }
h2 { font-size: 0.8rem; font-weight: 700; color: #94a3b8; text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 0.85rem; }
.row { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; }
.field { display: flex; flex-direction: column; gap: 0.3rem; margin-bottom: 0.85rem; }
label { font-size: 0.875rem; font-weight: 500; color: #475569; }
input, select, textarea {
padding: 0.65rem 0.85rem;
border: 1.5px solid #e2e8f0; border-radius: 8px;
font-size: 1rem; background: #fff; width: 100%;
box-sizing: border-box; font-family: inherit; resize: vertical;
transition: border-color 0.15s;
}
input:focus, select:focus, textarea:focus { outline: none; border-color: #1e40af; }
.checkboxes { display: flex; flex-wrap: wrap; gap: 0.5rem; }
.check-label {
display: flex; align-items: center; gap: 0.4rem;
padding: 0.4rem 0.75rem;
border: 1.5px solid #e2e8f0; border-radius: 20px;
font-size: 0.875rem; cursor: pointer;
transition: border-color 0.15s, background 0.15s;
}
.check-label.active { border-color: #1e40af; background: #e0e7ff; color: #1e40af; }
.check-label input { display: none; }
.error { color: #dc2626; font-size: 0.875rem; margin-bottom: 0.75rem; }
.actions { display: flex; gap: 0.75rem; margin-top: 1.5rem; margin-bottom: 5rem; }
.btn-primary {
flex: 1; padding: 0.75rem; background: #1e40af; color: #fff;
border: none; border-radius: 8px; font-size: 1rem; font-weight: 600;
cursor: pointer; transition: background 0.15s;
}
.btn-primary:hover:not(:disabled) { background: #1d3a9e; }
.btn-primary:disabled { opacity: 0.55; cursor: not-allowed; }
.btn-ghost {
padding: 0.75rem 1rem; background: none;
border: 1.5px solid #e2e8f0; border-radius: 8px;
font-size: 1rem; color: #64748b; text-decoration: none; text-align: center;
}
</style>