516 lines
14 KiB
Svelte
516 lines
14 KiB
Svelte
<script lang="ts">
|
|
import { pb } from '$lib/pb';
|
|
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('');
|
|
|
|
// --- 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 (pb.authStore.record?.rolle === 'trainer') { goto('/'); return; }
|
|
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="top">
|
|
<h1>Beiträge</h1>
|
|
<button class="btn-primary" onclick={neuerBeitrag}>+ Beitragsart</button>
|
|
</div>
|
|
|
|
{#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>
|
|
.top {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
margin-bottom: 1.25rem;
|
|
}
|
|
h1 { font-size: 1.4rem; font-weight: 700; color: #1e293b; }
|
|
|
|
.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;
|
|
}
|
|
</style>
|