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:
rene 2026-05-20 13:01:11 +02:00
parent c2c4dfd518
commit 77c6f513b5
32 changed files with 3012 additions and 399 deletions

View file

@ -0,0 +1,20 @@
<script lang="ts">
import { icons, type IconName } from '$lib/icons';
let { name, size = 24, class: cls = '' }: { name: IconName; size?: number; class?: string } = $props();
</script>
<span
class="ph-icon {cls}"
style="width:{size}px;height:{size}px;display:inline-flex;align-items:center;justify-content:center;"
aria-hidden="true"
>
{@html icons[name]}
</span>
<style>
.ph-icon :global(svg) {
width: 100%;
height: 100%;
}
</style>

15
app/src/lib/icons.ts Normal file
View file

@ -0,0 +1,15 @@
import house from './icons/house.svg?raw';
import users from './icons/users.svg?raw';
import calendar from './icons/calendar.svg?raw';
import currencyEur from './icons/currency-eur.svg?raw';
import envelope from './icons/envelope.svg?raw';
export const icons = {
house,
users,
calendar,
'currency-eur': currencyEur,
envelope,
} as const;
export type IconName = keyof typeof icons;

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><rect width="256" height="256" fill="none"/><rect x="40" y="40" width="176" height="176" rx="8" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="176" y1="24" x2="176" y2="56" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="80" y1="24" x2="80" y2="56" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="40" y1="88" x2="216" y2="88" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><polyline points="88 128 104 120 104 184" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><path d="M138.14,128a16,16,0,1,1,26.64,17.63L136,184h32" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/></svg>

After

Width:  |  Height:  |  Size: 980 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><rect width="256" height="256" fill="none"/><line x1="40" y1="112" x2="136" y2="112" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="40" y1="144" x2="120" y2="144" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><path d="M184,197.67A72,72,0,0,1,64,144V112A72,72,0,0,1,184,58.33" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/></svg>

After

Width:  |  Height:  |  Size: 561 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><rect width="256" height="256" fill="none"/><polyline points="224 56 128 144 32 56" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><path d="M32,56H224a0,0,0,0,1,0,0V192a8,8,0,0,1-8,8H40a8,8,0,0,1-8-8V56A0,0,0,0,1,32,56Z" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="110.55" y1="128" x2="34.47" y2="197.74" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="221.53" y1="197.74" x2="145.45" y2="128" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/></svg>

After

Width:  |  Height:  |  Size: 743 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><rect width="256" height="256" fill="none"/><path d="M104,216V152h48v64h64V120a8,8,0,0,0-2.34-5.66l-80-80a8,8,0,0,0-11.32,0l-80,80A8,8,0,0,0,40,120v96Z" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/></svg>

After

Width:  |  Height:  |  Size: 321 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><rect width="256" height="256" fill="none"/><circle cx="84" cy="108" r="52" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><path d="M10.23,200a88,88,0,0,1,147.54,0" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><path d="M172,160a87.93,87.93,0,0,1,73.77,40" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><path d="M152.69,59.7A52,52,0,1,1,172,160" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/></svg>

After

Width:  |  Height:  |  Size: 675 B

109
app/src/lib/sepa.ts Normal file
View file

@ -0,0 +1,109 @@
export interface SepaKopf {
glaeubigerid: string;
vereinIban: string;
vereinBic: string;
vereinName: string;
einzugsdatum: string; // YYYY-MM-DD
}
export interface SepaPosition {
endToEndId: string;
betrag: number;
mandatsreferenz: string;
mandatsdatum: string; // YYYY-MM-DD
debitorName: string;
debitorIban: string;
debitorBic: string;
verwendungszweck: string;
}
function x(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function iban(raw: string): string {
return raw.replace(/\s/g, '').toUpperCase();
}
export function generatePain008(kopf: SepaKopf, positionen: SepaPosition[]): string {
if (positionen.length === 0) throw new Error('Keine Positionen für den SEPA-Export.');
const now = new Date().toISOString().slice(0, 19);
const msgId = `VH-${Date.now()}`;
const ctrlSum = positionen.reduce((s, p) => s + p.betrag, 0).toFixed(2);
const nbOfTxs = positionen.length;
const txXml = positionen.map((p) => `
<DrctDbtTxInf>
<PmtId><EndToEndId>${x(p.endToEndId)}</EndToEndId></PmtId>
<InstdAmt Ccy="EUR">${p.betrag.toFixed(2)}</InstdAmt>
<DrctDbtTx>
<MndtRltdInf>
<MndtId>${x(p.mandatsreferenz)}</MndtId>
<DtOfSgntr>${p.mandatsdatum}</DtOfSgntr>
</MndtRltdInf>
<CdtrSchmeId><Id><PrvtId><Othr>
<Id>${x(kopf.glaeubigerid)}</Id>
<SchmeNm><Prtry>SEPA</Prtry></SchmeNm>
</Othr></PrvtId></Id></CdtrSchmeId>
</DrctDbtTx>
<DbtrAgt><FinInstnId>${p.debitorBic ? `<BIC>${x(p.debitorBic)}</BIC>` : '<Othr><Id>NOTPROVIDED</Id></Othr>'}</FinInstnId></DbtrAgt>
<Dbtr><Nm>${x(p.debitorName)}</Nm></Dbtr>
<DbtrAcct><Id><IBAN>${iban(p.debitorIban)}</IBAN></Id></DbtrAcct>
<RmtInf><Ustrd>${x(p.verwendungszweck.slice(0, 140))}</Ustrd></RmtInf>
</DrctDbtTxInf>`).join('');
return `<?xml version="1.0" encoding="UTF-8"?>
<Document xmlns="urn:iso:std:iso:20022:tech:xsd:pain.008.003.02"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<CstmrDrctDbtInitn>
<GrpHdr>
<MsgId>${msgId}</MsgId>
<CreDtTm>${now}</CreDtTm>
<NbOfTxs>${nbOfTxs}</NbOfTxs>
<CtrlSum>${ctrlSum}</CtrlSum>
<InitgPty><Nm>${x(kopf.vereinName)}</Nm></InitgPty>
</GrpHdr>
<PmtInf>
<PmtInfId>${msgId}-PI</PmtInfId>
<PmtMtd>DD</PmtMtd>
<NbOfTxs>${nbOfTxs}</NbOfTxs>
<CtrlSum>${ctrlSum}</CtrlSum>
<PmtTpInf>
<SvcLvl><Cd>SEPA</Cd></SvcLvl>
<LclInstrm><Cd>CORE</Cd></LclInstrm>
<SeqTp>RCUR</SeqTp>
</PmtTpInf>
<ReqdColltnDt>${kopf.einzugsdatum}</ReqdColltnDt>
<Cdtr><Nm>${x(kopf.vereinName)}</Nm></Cdtr>
<CdtrAcct><Id><IBAN>${iban(kopf.vereinIban)}</IBAN></Id></CdtrAcct>
<CdtrAgt><FinInstnId><BIC>${x(kopf.vereinBic)}</BIC></FinInstnId></CdtrAgt>
<CdtrSchmeId><Id><PrvtId><Othr>
<Id>${x(kopf.glaeubigerid)}</Id>
<SchmeNm><Prtry>SEPA</Prtry></SchmeNm>
</Othr></PrvtId></Id></CdtrSchmeId>${txXml}
</PmtInf>
</CstmrDrctDbtInitn>
</Document>`;
}
export function downloadXml(xml: string, dateiname: string) {
const blob = new Blob([xml], { type: 'application/xml;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = dateiname;
a.click();
URL.revokeObjectURL(url);
}
export function minEinzugsdatum(): string {
// SEPA CORE RCUR: mindestens 2 Bankarbeitstage Vorlaufzeit (vereinfacht: +3 Tage)
const d = new Date();
d.setDate(d.getDate() + 3);
return d.toISOString().slice(0, 10);
}

View file

@ -1,18 +1,20 @@
export type Plan = 'free' | 'starter' | 'wachstum' | 'verband';
export type MitgliedStatus = 'aktiv' | 'passiv' | 'ausgetreten';
export type EinzugStatus = 'ausstehend' | 'eingezogen' | 'fehlgeschlagen';
export type Rhythmus = 'monatlich' | 'quartalsweise' | 'halbjaehrlich' | 'jaehrlich';
export type EinzugStatus = 'ausstehend' | 'eingezogen' | 'fehlgeschlagen' | 'storniert';
export type Rhythmus = 'monatlich' | 'quartalsweise' | 'halbjaehrlich' | 'jaehrlich' | 'einmalig';
export interface Verein {
id: string;
name: string;
adresse: string;
plz: string;
ort: string;
bundesland: string;
adresse?: string;
plz?: string;
ort?: string;
bundesland?: string;
plan: Plan;
stripe_customer_id?: string;
dosb_mitglied: boolean;
glaeubigerid?: string;
iban?: string;
bic?: string;
}
export interface Mitglied {
@ -20,19 +22,21 @@ export interface Mitglied {
verein_id: string;
vorname: string;
nachname: string;
email: string;
email?: string;
telefon?: string;
geburtsdatum?: string;
eintrittsdatum: string;
eintrittsdatum?: string;
austrittsdatum?: string;
strasse: string;
plz: string;
ort: string;
strasse?: string;
plz?: string;
ort?: string;
iban?: string;
bic?: string;
gruppe_ids: string[];
status: MitgliedStatus;
notizen?: string;
mandatsreferenz?: string;
mandatsdatum?: string;
}
export interface Gruppe {
@ -53,13 +57,11 @@ export interface Beitrag {
export interface Einzug {
id: string;
verein_id: string;
mitglied_id: string;
beitrag_id: string;
betrag: number;
faelligkeitsdatum: string;
faellig_am?: string;
status: EinzugStatus;
stripe_payment_intent_id?: string;
}
export interface Termin {
@ -80,5 +82,5 @@ export interface Nachricht {
betreff: string;
text: string;
gruppe_ids: string[];
gesendet_am: string;
gesendet_am?: string;
}