Feature: SEPA-Export, Push-Notifications, Onboarding + vollständige UI
- Phosphor Icons (Icon.svelte, svg-Registry) - Schema-Abgleich: alle Felder zwischen PB-Migrations und types.ts konsistent - Stripe entfernt, SEPA pain.008 XML-Export implementiert (sepa.ts) - Beiträge: vollständiges CRUD + SEPA-Einzug-Sheet mit Vorschau - Termine: vollständiges CRUD (upcoming/vergangen, datetime-local) - Mitglieder: Formulare um alle Felder erweitert (Adresse, SEPA-Mandat, Notizen) - Nachrichten: Brevo E-Mail via PocketBase-Hook, UI mit Gruppen-Filter - Push-Notifications: VAPID, Custom Service Worker (injectManifest), Subscribe/Send API-Routen, automatische Subscription nach Login - Onboarding: 3-Schritt-Flow für neue Vereine, Guard im App-Layout - Makefile: .env wird vollständig zur DS übertragen
This commit is contained in:
parent
c2c4dfd518
commit
77c6f513b5
32 changed files with 3012 additions and 399 deletions
|
|
@ -1,27 +1,514 @@
|
|||
<script lang="ts">
|
||||
import { pb } from '$lib/pb';
|
||||
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('');
|
||||
|
||||
// --- 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 () => {
|
||||
const vid = pb.authStore.record?.verein_id as string;
|
||||
[beitraege, verein] = await Promise.all([
|
||||
pb.collection('beitraege').getFullList<Beitrag>({ sort: 'name' }),
|
||||
pb.collection('vereine').getOne<Verein>(vid).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 vid = pb.authStore.record?.verein_id as string;
|
||||
const data = { verein_id: vid, name: fName.trim(), betrag, rhythmus: fRhythmus, beschreibung: fBeschr.trim() };
|
||||
if (editId) {
|
||||
await pb.collection('beitraege').update(editId, data);
|
||||
beitraege = beitraege.map(b => b.id === editId ? { ...b, ...data } as Beitrag : b);
|
||||
} else {
|
||||
const neu = await pb.collection('beitraege').create<Beitrag>(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 pb.collection('beitraege').delete(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 pb.collection('mitglieder').getFullList<Mitglied>({
|
||||
filter: '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
|
||||
const vid = pb.authStore.record?.verein_id as string;
|
||||
await Promise.all(
|
||||
sepaPreview.mitglieder.map((m) =>
|
||||
pb.collection('einzuege').create({
|
||||
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="page-header">
|
||||
<div class="top">
|
||||
<h1>Beiträge</h1>
|
||||
<button class="btn-primary">+ Beitragsart</button>
|
||||
<button class="btn-primary" onclick={neuerBeitrag}>+ Beitragsart</button>
|
||||
</div>
|
||||
|
||||
<p class="placeholder">SEPA-Beitragseinzug — in Entwicklung</p>
|
||||
{#if sepaFehlt && !loading}
|
||||
<div class="hinweis">
|
||||
SEPA-Einzug nicht möglich: Gläubiger-ID, IBAN und BIC des Vereins fehlen noch in den Einstellungen.
|
||||
</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}>
|
||||
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>
|
||||
.page-header {
|
||||
.top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
h1 { font-size: 1.4rem; font-weight: 700; color: #1e293b; }
|
||||
.btn-primary {
|
||||
background: #1e40af; color: #fff; border: none;
|
||||
border-radius: 8px; padding: 0.5rem 1rem;
|
||||
font-size: 0.9rem; font-weight: 600;
|
||||
|
||||
.hinweis {
|
||||
background: #fef9c3;
|
||||
border: 1px solid #fde047;
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
color: #713f12;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
.placeholder { color: #94a3b8; font-size: 0.95rem; }
|
||||
</style>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue