vereinshaus/app/src/routes/(app)/beitraege/+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

531 lines
15 KiB
Svelte

<script lang="ts">
import { api } from '$lib/api';
import { user } from '$lib/user';
import { get } from 'svelte/store';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import { generatePain008, downloadXml, minEinzugsdatum, type SepaPosition } from '$lib/sepa';
import type { Beitrag, Mitglied, Verein } from '$lib/types';
// --- Daten ---
let beitraege = $state<Beitrag[]>([]);
let verein = $state<Verein | null>(null);
let loading = $state(true);
// --- Beitragsart-Formular ---
let showForm = $state(false);
let editId = $state<string | null>(null);
let fName = $state('');
let fBetrag = $state('');
let fRhythmus = $state<Beitrag['rhythmus']>('jaehrlich');
let fBeschr = $state('');
let saving = $state(false);
let formError = $state('');
// Plan-Check
const hatSepa = $derived(
verein?.plan && ['starter', 'wachstum', 'verband'].includes(verein.plan)
);
// --- SEPA-Export ---
let sepaFor = $state<Beitrag | null>(null);
let einzugsdatum = $state(minEinzugsdatum());
let sepaLoading = $state(false);
let sepaError = $state('');
let sepaPreview = $state<{ mitglieder: Mitglied[]; ohne: number } | null>(null);
const rhythmusLabel: Record<Beitrag['rhythmus'], string> = {
monatlich: 'monatlich',
quartalsweise: 'quartalsweise',
halbjaehrlich: 'halbjährlich',
jaehrlich: 'jährlich',
einmalig: 'einmalig',
};
onMount(async () => {
if (get(user)?.rolle === 'trainer') { goto('/'); return; }
[beitraege, verein] = await Promise.all([
api.get<Beitrag[]>('/beitraege', { sort: 'name' }),
api.get<Verein>('/vereine').catch(() => null),
]);
loading = false;
});
// --- Beitragsart speichern ---
function neuerBeitrag() {
editId = null; fName = ''; fBetrag = ''; fRhythmus = 'jaehrlich'; fBeschr = '';
formError = ''; showForm = true;
}
function bearbeiten(b: Beitrag) {
editId = b.id; fName = b.name; fBetrag = String(b.betrag);
fRhythmus = b.rhythmus; fBeschr = b.beschreibung ?? '';
formError = ''; showForm = true;
}
async function speichern() {
formError = '';
const betrag = parseFloat(fBetrag.replace(',', '.'));
if (!fName.trim() || isNaN(betrag) || betrag <= 0) {
formError = 'Name und gültiger Betrag sind Pflichtfelder.';
return;
}
saving = true;
try {
const data = { name: fName.trim(), betrag, rhythmus: fRhythmus, beschreibung: fBeschr.trim() };
if (editId) {
await api.put('/beitraege/' + editId, data);
beitraege = beitraege.map(b => b.id === editId ? { ...b, ...data } as Beitrag : b);
} else {
const neu = await api.post<Beitrag>('/beitraege', data);
beitraege = [...beitraege, neu].sort((a, b) => a.name.localeCompare(b.name));
}
showForm = false;
} catch (e: unknown) {
formError = e instanceof Error ? e.message : 'Fehler beim Speichern.';
} finally {
saving = false;
}
}
async function loeschen(b: Beitrag) {
if (!confirm(`"${b.name}" wirklich löschen?`)) return;
await api.del('/beitraege/' + b.id);
beitraege = beitraege.filter(x => x.id !== b.id);
}
// --- SEPA-Export ---
async function sepaOeffnen(b: Beitrag) {
sepaFor = b;
sepaError = '';
sepaPreview = null;
einzugsdatum = minEinzugsdatum();
sepaLoading = true;
try {
const alle = await api.get<Mitglied[]>('/mitglieder', { status: 'aktiv', sort: 'nachname,vorname' });
const mit = alle.filter(m => m.iban?.trim());
const ohne = alle.length - mit.length;
sepaPreview = { mitglieder: mit, ohne };
} catch (e: unknown) {
sepaError = e instanceof Error ? e.message : 'Fehler beim Laden der Mitglieder.';
} finally {
sepaLoading = false;
}
}
function sepaSchliessen() {
sepaFor = null; sepaPreview = null; sepaError = '';
}
async function sepaExportieren() {
if (!sepaFor || !sepaPreview || !verein) return;
if (!verein.glaeubigerid || !verein.iban || !verein.bic) {
sepaError = 'Bitte zuerst Gläubiger-ID, IBAN und BIC des Vereins in den Einstellungen hinterlegen.';
return;
}
sepaError = '';
sepaLoading = true;
try {
const today = new Date().toISOString().slice(0, 10);
const positionen: SepaPosition[] = sepaPreview.mitglieder.map((m, i) => ({
endToEndId: `VH-${sepaFor!.id.slice(0, 6)}-${String(i + 1).padStart(4, '0')}`,
betrag: sepaFor!.betrag,
mandatsreferenz: m.mandatsreferenz || `MANDAT-${m.id.slice(0, 8).toUpperCase()}`,
mandatsdatum: m.mandatsdatum || today,
debitorName: `${m.vorname} ${m.nachname}`,
debitorIban: m.iban!,
debitorBic: m.bic ?? '',
verwendungszweck: `${sepaFor!.name} (${rhythmusLabel[sepaFor!.rhythmus]})`,
}));
const xml = generatePain008(
{
glaeubigerid: verein.glaeubigerid,
vereinIban: verein.iban,
vereinBic: verein.bic,
vereinName: verein.name,
einzugsdatum,
},
positionen,
);
downloadXml(xml, `sepa-einzug-${einzugsdatum}.xml`);
// Einzüge als "ausstehend" anlegen
await Promise.all(
sepaPreview.mitglieder.map((m) =>
api.post('/einzuege', {
mitglied_id: m.id,
beitrag_id: sepaFor!.id,
betrag: sepaFor!.betrag,
faellig_am: einzugsdatum,
status: 'ausstehend',
}),
),
);
sepaSchliessen();
} catch (e: unknown) {
sepaError = e instanceof Error ? e.message : 'Fehler beim Export.';
} finally {
sepaLoading = false;
}
}
const gesamtbetrag = $derived(
sepaPreview ? sepaPreview.mitglieder.length * (sepaFor?.betrag ?? 0) : 0,
);
const sepaFehlt = $derived(
verein ? !verein.glaeubigerid || !verein.iban || !verein.bic : true,
);
</script>
<svelte:head><title>Beiträge — vereins.haus</title></svelte:head>
<div class="top">
<h1>Beiträge</h1>
<button class="btn-primary" onclick={neuerBeitrag}>+ Beitragsart</button>
</div>
{#if !loading && !hatSepa}
<div class="plan-hinweis">
<strong>SEPA-Export</strong> ist im <strong>Starter-Plan</strong> verfügbar (7 €/Monat).
<a href="/einstellungen">Jetzt upgraden →</a>
</div>
{:else if sepaFehlt && !loading}
<div class="hinweis">
SEPA-Einzug nicht möglich: Gläubiger-ID, IBAN und BIC des Vereins fehlen noch in den <a href="/einstellungen">Einstellungen</a>.
</div>
{/if}
{#if loading}
<p class="hint">Laden…</p>
{:else if beitraege.length === 0}
<p class="hint">Noch keine Beitragsarten — lege die erste an!</p>
{:else}
<ul class="liste">
{#each beitraege as b (b.id)}
<li class="karte">
<div class="karte-info">
<span class="karte-name">{b.name}</span>
{#if b.beschreibung}
<span class="karte-beschr">{b.beschreibung}</span>
{/if}
<span class="karte-meta">
{b.betrag.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}
· {rhythmusLabel[b.rhythmus]}
</span>
</div>
<div class="karte-aktionen">
<button class="btn-sepa" onclick={() => sepaOeffnen(b)} disabled={sepaFehlt || !hatSepa}>
SEPA
</button>
<button class="btn-icon" onclick={() => bearbeiten(b)} title="Bearbeiten"></button>
<button class="btn-icon btn-icon-red" onclick={() => loeschen(b)} title="Löschen"></button>
</div>
</li>
{/each}
</ul>
{/if}
<!-- Beitragsart-Formular -->
{#if showForm}
<div class="overlay" role="dialog" aria-modal="true">
<div class="sheet">
<h2>{editId ? 'Beitragsart bearbeiten' : 'Neue Beitragsart'}</h2>
<form onsubmit={(e) => { e.preventDefault(); speichern(); }}>
<div class="field">
<label for="fname">Name *</label>
<input id="fname" type="text" bind:value={fName} placeholder="z. B. Jahresbeitrag" required />
</div>
<div class="row">
<div class="field">
<label for="fbetrag">Betrag (€) *</label>
<input id="fbetrag" type="text" inputmode="decimal" bind:value={fBetrag} placeholder="48,00" required />
</div>
<div class="field">
<label for="frhythmus">Rhythmus</label>
<select id="frhythmus" bind:value={fRhythmus}>
<option value="monatlich">Monatlich</option>
<option value="quartalsweise">Quartalsweise</option>
<option value="halbjaehrlich">Halbjährlich</option>
<option value="jaehrlich">Jährlich</option>
<option value="einmalig">Einmalig</option>
</select>
</div>
</div>
<div class="field">
<label for="fbeschr">Beschreibung</label>
<input id="fbeschr" type="text" bind:value={fBeschr} placeholder="Optional" />
</div>
{#if formError}
<p class="error">{formError}</p>
{/if}
<div class="actions">
<button type="button" class="btn-ghost" onclick={() => showForm = false}>Abbrechen</button>
<button type="submit" class="btn-primary" disabled={saving}>
{saving ? 'Speichern…' : 'Speichern'}
</button>
</div>
</form>
</div>
</div>
{/if}
<!-- SEPA-Export -->
{#if sepaFor}
<div class="overlay" role="dialog" aria-modal="true">
<div class="sheet">
<h2>SEPA-Einzug</h2>
<p class="sepa-sub">{sepaFor.name} · {sepaFor.betrag.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} · {rhythmusLabel[sepaFor.rhythmus]}</p>
<div class="field">
<label for="einzugsdatum">Einzugsdatum *</label>
<input
id="einzugsdatum"
type="date"
bind:value={einzugsdatum}
min={minEinzugsdatum()}
/>
<span class="field-hint">SEPA CORE: mind. 2 Bankarbeitstage Vorlauf</span>
</div>
{#if sepaLoading}
<p class="hint">Laden…</p>
{:else if sepaPreview}
<div class="sepa-summary">
<div class="sepa-row">
<span>Mitglieder mit IBAN</span>
<strong>{sepaPreview.mitglieder.length}</strong>
</div>
{#if sepaPreview.ohne > 0}
<div class="sepa-row sepa-warn">
<span>Ohne IBAN (übersprungen)</span>
<strong>{sepaPreview.ohne}</strong>
</div>
{/if}
<div class="sepa-row sepa-total">
<span>Gesamtsumme</span>
<strong>{gesamtbetrag.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}</strong>
</div>
</div>
{#if sepaPreview.mitglieder.length === 0}
<p class="error">Keine aktiven Mitglieder mit IBAN vorhanden.</p>
{/if}
{/if}
{#if sepaError}
<p class="error">{sepaError}</p>
{/if}
<div class="actions">
<button type="button" class="btn-ghost" onclick={sepaSchliessen}>Abbrechen</button>
<button
class="btn-primary"
disabled={sepaLoading || !sepaPreview || sepaPreview.mitglieder.length === 0}
onclick={sepaExportieren}
>
XML herunterladen
</button>
</div>
</div>
</div>
{/if}
<style>
.top {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1.25rem;
}
.top .btn-primary { flex: none; padding: 0.45rem 0.9rem; font-size: 0.875rem; }
h1 { font-size: 1.4rem; font-weight: 700; color: #1e293b; }
.plan-hinweis {
background: #eff6ff; border: 1px solid #bfdbfe; border-radius: 8px;
padding: 0.75rem 1rem; font-size: 0.875rem; color: #1e40af; margin-bottom: 1rem;
}
.plan-hinweis a { color: #1e40af; font-weight: 700; }
.hinweis {
background: #fef9c3;
border: 1px solid #fde047;
border-radius: 8px;
padding: 0.75rem 1rem;
font-size: 0.875rem;
color: #713f12;
margin-bottom: 1rem;
}
.hinweis a { color: #713f12; }
.hint { color: #94a3b8; font-size: 0.95rem; text-align: center; margin-top: 3rem; }
.liste {
list-style: none;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.karte {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.9rem 1rem;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 10px;
}
.karte-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.1rem;
}
.karte-name { font-weight: 600; font-size: 0.95rem; color: #1e293b; }
.karte-beschr { font-size: 0.78rem; color: #94a3b8; }
.karte-meta { font-size: 0.82rem; color: #475569; }
.karte-aktionen {
display: flex;
align-items: center;
gap: 0.4rem;
flex-shrink: 0;
}
.btn-sepa {
padding: 0.35rem 0.7rem;
background: #e0e7ff;
color: #1e40af;
border: none;
border-radius: 6px;
font-size: 0.78rem;
font-weight: 700;
cursor: pointer;
transition: background 0.15s;
}
.btn-sepa:hover:not(:disabled) { background: #c7d2fe; }
.btn-sepa:disabled { opacity: 0.4; cursor: not-allowed; }
.btn-icon {
width: 2rem;
height: 2rem;
display: flex;
align-items: center;
justify-content: center;
background: none;
border: 1px solid #e2e8f0;
border-radius: 6px;
color: #64748b;
font-size: 0.9rem;
cursor: pointer;
transition: border-color 0.15s, color 0.15s;
}
.btn-icon:hover { border-color: #94a3b8; color: #1e293b; }
.btn-icon-red:hover { border-color: #fca5a5; color: #dc2626; }
/* Overlay & Sheet */
.overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.4);
display: flex;
align-items: flex-end;
justify-content: center;
z-index: 100;
padding: 1rem;
padding-bottom: calc(1rem + env(safe-area-inset-bottom));
}
.sheet {
background: #fff;
border-radius: 16px;
padding: 1.5rem;
width: 100%;
max-width: 480px;
max-height: 90dvh;
overflow-y: auto;
}
h2 { font-size: 1.1rem; font-weight: 700; color: #1e293b; margin-bottom: 0.25rem; }
.sepa-sub { font-size: 0.85rem; color: #64748b; margin-bottom: 1.25rem; }
.row { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; }
.field { display: flex; flex-direction: column; gap: 0.3rem; margin-bottom: 0.9rem; }
label { font-size: 0.875rem; font-weight: 500; color: #475569; }
.field-hint { font-size: 0.75rem; color: #94a3b8; }
input, select {
padding: 0.65rem 0.85rem;
border: 1.5px solid #e2e8f0;
border-radius: 8px;
font-size: 1rem;
background: #fff;
width: 100%;
box-sizing: border-box;
transition: border-color 0.15s;
}
input:focus, select:focus { outline: none; border-color: #1e40af; }
.sepa-summary {
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 8px;
margin-bottom: 1rem;
overflow: hidden;
}
.sepa-row {
display: flex;
justify-content: space-between;
padding: 0.65rem 1rem;
font-size: 0.9rem;
color: #1e293b;
border-bottom: 1px solid #e2e8f0;
}
.sepa-row:last-child { border-bottom: none; }
.sepa-warn { color: #92400e; background: #fffbeb; }
.sepa-total { font-weight: 700; background: #f0f9ff; }
.error { color: #dc2626; font-size: 0.875rem; margin-bottom: 0.75rem; }
.actions { display: flex; gap: 0.75rem; margin-top: 1.25rem; }
.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;
cursor: pointer;
}
</style>