Compare commits

..

38 commits

Author SHA1 Message Date
b12a8e2caa PWA: iOS Installations-Banner + Safe-Area-Fixes 2026-05-22 09:02:54 +02:00
4fee85bd22 Refactor: CSS-Variablen für alle Farbwerte (theme.css) 2026-05-22 08:59:35 +02:00
e2d7655e13 Einstellungen: Passwort-Ändern-Funktion 2026-05-22 08:19:58 +02:00
aa9ab9d776 Merge feat/no-pocketbase: PocketBase → SvelteKit + SQLite + JWT 2026-05-22 08:12:37 +02:00
50810c4b50 PWA: PNG-Icons für Homescreen (iOS 180px, Android 192/512px) 2026-05-22 08:12:32 +02:00
51c0fe58aa Fix: HTML-Tags in Nachrichten-Vorschau strippen 2026-05-22 08:10:13 +02:00
957c4a9707 Fix: Route-Namen (orte/ort-ausfaelle) und /api/users ergänzt 2026-05-21 22:17:03 +02:00
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
61c430f2e6 Fix: neuigkeiten-Migration auf new Collection()+app.save() umgestellt
importCollections() kompiliert Access-Rules in PocketBase v0.38 nicht
korrekt → HTTP 400 bei jedem List-Request. Alle anderen Migrationen
(nachrichten, mitglieder, etc.) verwenden new Collection() + app.save()
und funktionieren einwandfrei. Gleiche Methode jetzt auch für neuigkeiten
und reaktionen.

Außerdem: Makefile um staging-reset-Target erweitert (löscht PB-Daten),
staging-deploy synchronisiert Migrations und Hooks jetzt vollständig
(nicht nur neue Dateien).
2026-05-21 20:49:11 +02:00
ff8e9b2c39 Test: autor_id als Text statt Relation (PB v0.38 Bug-Isolation) 2026-05-21 20:33:18 +02:00
caff0feae8 Fix: importCollections() statt app.save() für korrekte Rule-Compilation in PB v0.38 2026-05-21 20:30:14 +02:00
9898581ae4 Fix: Re-save nach Collection-Erstellung erzwingt Rule-Index in PocketBase v0.38 2026-05-21 20:24:40 +02:00
fb54f1bd27 Fix: reaktionen-Migration liest neuigkeiten-ID nach app.save() 2026-05-21 20:22:58 +02:00
e4ca28025c Fix: Neuigkeiten-Migration ohne explizite Feld-IDs (PocketBase v0.38 Kompatibilität) 2026-05-21 20:21:46 +02:00
34f6a4f11d Fix: pbc_-Prefix aus Custom-Collection-IDs entfernt (PocketBase reserviert pbc_ intern) 2026-05-21 20:09:20 +02:00
0aca72af53 Fix: autor_name denormalisiert speichern statt expand (viewRule-Konflikt) 2026-05-21 20:03:55 +02:00
74c3aa11b0 Fix: Neuigkeiten/Reaktionen Access-Rules via separater Migration neu kompilieren 2026-05-21 19:50:48 +02:00
75cb9bfc88 Fix: Neuigkeiten-Ladestate mit try/catch abgesichert 2026-05-21 19:25:27 +02:00
13c6ba73ca Neuigkeiten: Vereins-Feed mit Fotos/Videos, Reaktionen, Termin-Verknüpfung 2026-05-21 19:17:35 +02:00
d4a0a75cf7 UI: Übersicht aus Bottom-Nav entfernt, Header-Buttons einheitlich kompakt 2026-05-21 18:45:34 +02:00
2514ec7496 Nav: Einstellungen aus Bottom-Nav entfernt (Gear-Icon im Header reicht) 2026-05-21 18:15:30 +02:00
6dd4d657be Statische IPs für alle Container im npm_bridge-Netz (.11–.14) 2026-05-21 08:49:01 +02:00
a4436d70c2 Staging: docker-compose.staging.yml, Makefile-Targets, Seed-Script (18 Mitglieder, Termine, Orte, Beitragsarten) 2026-05-20 20:46:46 +02:00
81f34905cf Termine: Wochen- und Monatsansicht via @event-calendar/core, umschaltbar zur Listenansicht 2026-05-20 20:33:09 +02:00
59d94f9c47 Import/Export: CSV-Export (Alle/Aktive/SEPA), JSON-Backup, CSV-Import mit Spalten-Mapping 2026-05-20 20:25:07 +02:00
95c2dc0f26 Lizenzmodell: Plan-Anzeige in Einstellungen, SEPA-Gate für Free-Plan 2026-05-20 20:21:21 +02:00
b8e2a69912 Veranstaltungsorte: Verwaltung, Ausfälle, Ort-Picker in Terminen, Warnhinweise 2026-05-20 20:04:53 +02:00
3ac17b2645 Termine: Wiederholungsserien (rrule.js), iCal-Subscription-Feed (ical-generator) 2026-05-20 19:58:33 +02:00
c23ac90d35 Durchführender: Verfügbarkeit pro Termin, Umbenennung Trainer→Durchführender 2026-05-20 19:43:46 +02:00
59aa3cbcce Rollen: Trainer-Einladung, rollenbasierte Navigation und Zugriffskontrolle 2026-05-20 17:27:59 +02:00
7e2e5a643d Einstellungen: Vereinsprofil + SEPA-Bankdaten editierbar, Abmelden auf Einstellungsseite 2026-05-20 16:57:22 +02:00
472979a91c Übersicht: Vereins-Header mit Logo + nächste Termine statt redundanter Nav-Chips 2026-05-20 16:51:47 +02:00
f2906f5c60 Fix: favicon.ico 404 via 301-Redirect auf favicon.svg 2026-05-20 16:48:54 +02:00
bb0e67b2bd Branding: Logo, Favicon und App-Icon eingebaut 2026-05-20 16:40:43 +02:00
77c6f513b5 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
2026-05-20 13:01:11 +02:00
c2c4dfd518 Add Mitgliederverwaltungs-UI (Phase 1 MVP)
- List view with live search and member count
- Create form (/mitglieder/neu) with group checkboxes
- Detail/Edit view with inline edit toggle
- Delete with confirmation dialog
- Makefile: skip migration files already on DS (avoid root permission error)
2026-05-19 20:48:53 +02:00
375a3305bb Add PocketBase schema migrations and migration pipeline
- 8 collections: vereine, gruppen, mitglieder, beitraege, einzuege,
  termine, nachrichten, push_subscriptions
- verein_id relation added to users (multi-tenant isolation)
- API rules enforce tenant separation via @request.auth.verein_id
- docker-compose: --migrationsDir=/pb_data/migrations flag + volume mount
- Makefile: migrations sync step added to deploy target
2026-05-19 20:40:47 +02:00
94ca36f470 Fix: VITE_PB_URL als Docker build-arg (api.vereins.haus) 2026-05-18 18:52:13 +02:00
105 changed files with 9935 additions and 298 deletions

108
Makefile
View file

@ -1,16 +1,19 @@
# ==============================================================
# VEREINS.HAUS — Makefile
# Deploy-Strategie: SSH zur DS, Docker Compose
# Stack: SvelteKit + better-sqlite3 (kein PocketBase)
# ==============================================================
DS_HOST := ds
DS_IP := 10.47.11.10
DS_SSH_PORT := 4711
DS_PATH := /volume1/docker/vereinshaus
CONTAINER_PB := vereinshaus-pocketbase
CONTAINER_APP := vereinshaus-app
DOCKER := sudo /usr/local/bin/docker
STAGING_PATH := /volume1/docker/vereinshaus-staging
CONTAINER_APP_STAGING := vereinshaus-staging-app
TAR_EXCLUDE := --exclude='.git' \
--exclude='./app/node_modules' \
--exclude='./app/.svelte-kit' \
@ -18,11 +21,8 @@ TAR_EXCLUDE := --exclude='.git' \
--exclude='./.env' \
--exclude='./.DS_Store'
HOOKS_SRC := pocketbase/pb_hooks
HOOKS_DST := /volume1/docker/vereinshaus/pocketbase/data/pb_hooks
.PHONY: help check-ssh start stop restart status logs logs-f logs-app \
shell-pb pb-admin deploy
.PHONY: help check-ssh start stop restart status logs logs-app logs-f deploy \
staging-deploy staging-reset staging-seed staging-logs staging-status staging-stop
# ----------------------------------------------------------
# Hilfe
@ -31,17 +31,17 @@ help:
@echo ""
@echo " vereins.haus — verfügbare Befehle:"
@echo ""
@echo " make deploy App bauen + zur DS übertragen + Container neu starten"
@echo " make start Alle Container starten"
@echo " make stop Alle Container stoppen"
@echo " make restart Alle Container neu starten"
@echo " make status Container-Status anzeigen"
@echo " make deploy App bauen + zur DS übertragen + Container neu starten"
@echo " make start Container starten"
@echo " make stop Container stoppen"
@echo " make restart Container neu starten"
@echo " make status Container-Status anzeigen"
@echo " make logs App-Logs (100 Zeilen)"
@echo " make logs-f App Live-Log"
@echo ""
@echo " make logs PocketBase-Logs (100 Zeilen)"
@echo " make logs-app App-Logs (100 Zeilen)"
@echo " make logs-f PocketBase Live-Log"
@echo " make shell-pb Shell in PocketBase-Container"
@echo " make pb-admin PocketBase Admin-URL anzeigen"
@echo " make staging-deploy Staging deployen"
@echo " make staging-seed Testdaten einfügen"
@echo " make staging-reset Staging-DB löschen (Neustart)"
@echo ""
# ----------------------------------------------------------
@ -56,20 +56,14 @@ check-ssh:
fi
# ----------------------------------------------------------
# DEPLOY
# DEPLOY (Production)
# ----------------------------------------------------------
deploy: check-ssh
@echo "→ Sync zu DS..."
@COPYFILE_DISABLE=1 tar czf - $(TAR_EXCLUDE) . | ssh $(DS_HOST) "tar xzf - -C $(DS_PATH)/"
@echo "→ .env auf DS aktualisieren..."
@if [ -f .env ]; then \
grep -E "BREVO_KEY" .env | ssh $(DS_HOST) "cat > $(DS_PATH)/.env"; \
fi
@echo "→ PocketBase Hooks synchronisieren..."
@if ls $(HOOKS_SRC)/*.pb.js 2>/dev/null | grep -q .; then \
for f in $(HOOKS_SRC)/*.pb.js; do \
cat "$$f" | ssh $(DS_HOST) "cat > $(HOOKS_DST)/$$(basename $$f)"; \
done; \
cat .env | ssh $(DS_HOST) "cat > $(DS_PATH)/.env"; \
fi
@echo "→ Docker rebuild + restart..."
@ssh $(DS_HOST) " \
@ -78,7 +72,7 @@ deploy: check-ssh
$(DOCKER) compose build app && \
$(DOCKER) compose up -d"
@echo " ✓ Deploy fertig."
@ssh $(DS_HOST) "$(DOCKER) logs $(CONTAINER_APP) --tail=10"
@ssh $(DS_HOST) "$(DOCKER) logs $(CONTAINER_APP) --tail=15"
# ----------------------------------------------------------
# Container-Steuerung
@ -104,19 +98,61 @@ status: check-ssh
# Logs
# ----------------------------------------------------------
logs: check-ssh
@ssh $(DS_HOST) "$(DOCKER) logs $(CONTAINER_PB) --tail=100"
logs-app: check-ssh
@ssh $(DS_HOST) "$(DOCKER) logs $(CONTAINER_APP) --tail=100"
logs-app: logs
logs-f: check-ssh
@ssh $(DS_HOST) "$(DOCKER) logs $(CONTAINER_PB) -f"
@ssh $(DS_HOST) "$(DOCKER) logs $(CONTAINER_APP) -f"
# ----------------------------------------------------------
# Shell + Admin
# ----------------------------------------------------------
shell-pb: check-ssh
@ssh -t $(DS_HOST) "$(DOCKER) exec -it $(CONTAINER_PB) sh"
# ==============================================================
# STAGING
# ==============================================================
pb-admin:
@echo " PocketBase Admin: https://api.vereins.haus/_/"
staging-deploy: check-ssh
@echo "→ Sync zu DS (Staging)..."
@COPYFILE_DISABLE=1 tar czf - $(TAR_EXCLUDE) . | ssh $(DS_HOST) "tar xzf - -C $(STAGING_PATH)/"
@echo "→ .env auf DS (Staging)..."
@if [ -f .env ]; then \
cat .env | ssh $(DS_HOST) "cat > $(STAGING_PATH)/.env"; \
fi
@echo "→ Docker rebuild + restart (Staging)..."
@ssh $(DS_HOST) " \
cd $(STAGING_PATH) && \
$(DOCKER) compose -f docker-compose.staging.yml down && \
$(DOCKER) compose -f docker-compose.staging.yml build app-staging && \
$(DOCKER) compose -f docker-compose.staging.yml up -d"
@echo " ✓ Staging bereit."
@echo " App: https://staging.vereins.haus"
# Löscht die SQLite-DB auf Staging → frischer Start
# Danach: make staging-deploy && make staging-seed
staging-reset: check-ssh staging-stop
@echo "→ Staging-Daten löschen..."
@ssh $(DS_HOST) "rm -f \
$(STAGING_PATH)/data/vereinshaus.db \
$(STAGING_PATH)/data/vereinshaus.db-wal \
$(STAGING_PATH)/data/vereinshaus.db-shm && \
rm -rf $(STAGING_PATH)/data/uploads"
@echo " ✓ Reset fertig. Jetzt: make staging-deploy && make staging-seed"
staging-seed:
@echo "→ Testdaten in Staging einfügen..."
@if [ -f .env ]; then \
export $$(grep -v '^#' .env | xargs) && \
APP_URL=https://staging.vereins.haus node scripts/seed.js; \
else \
APP_URL=https://staging.vereins.haus node scripts/seed.js; \
fi
staging-logs: check-ssh
@ssh $(DS_HOST) "$(DOCKER) logs $(CONTAINER_APP_STAGING) --tail=50"
staging-status: check-ssh
@ssh $(DS_HOST) "$(DOCKER) ps \
--filter name=vereinshaus-staging \
--format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}'"
staging-stop: check-ssh
@ssh $(DS_HOST) "cd $(STAGING_PATH) && $(DOCKER) compose -f docker-compose.staging.yml down"
@echo " ✓ Staging gestoppt."

View file

@ -1,4 +1,5 @@
FROM node:22-alpine AS builder
RUN apk add --no-cache python3 make g++
WORKDIR /app
COPY package*.json ./
RUN npm ci
@ -6,6 +7,7 @@ COPY . .
RUN npm run build
FROM node:22-alpine
RUN apk add --no-cache python3 make g++
WORKDIR /app
COPY --from=builder /app/build ./build
COPY --from=builder /app/package*.json ./

748
app/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -15,13 +15,27 @@
"@sveltejs/adapter-node": "^5.5.4",
"@sveltejs/kit": "^2.57.0",
"@sveltejs/vite-plugin-svelte": "^7.0.0",
"@types/bcryptjs": "^2.4.6",
"@types/better-sqlite3": "^7.6.13",
"@types/papaparse": "^5.5.2",
"@types/web-push": "^3.6.4",
"svelte": "^5.55.2",
"svelte-check": "^4.4.6",
"typescript": "^6.0.2",
"vite": "^8.0.7",
"vite-plugin-pwa": "^1.3.0"
"vite-plugin-pwa": "^1.3.0",
"workbox-core": "^7.4.1",
"workbox-precaching": "^7.4.1"
},
"dependencies": {
"pocketbase": "^0.26.9"
"@event-calendar/core": "^5.7.0",
"bcryptjs": "^3.0.3",
"better-sqlite3": "^12.10.0",
"ical-generator": "^10.2.0",
"jose": "^6.2.3",
"papaparse": "^5.5.3",
"pocketbase": "^0.26.9",
"rrule": "^2.8.1",
"web-push": "^3.6.7"
}
}

15
app/src/app.d.ts vendored Normal file
View file

@ -0,0 +1,15 @@
declare global {
namespace App {
interface Locals {
user: {
id: string;
verein_id: string;
rolle: string | null;
name: string;
email: string;
} | null;
}
}
}
export {};

View file

@ -4,13 +4,12 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="light" />
<meta name="theme-color" content="#1e40af" />
<meta name="theme-color" content="#0F172A" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/icons/apple-touch-icon.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">

66
app/src/lib/api.ts Normal file
View file

@ -0,0 +1,66 @@
import { get } from 'svelte/store';
import { user } from './user';
import { goto } from '$app/navigation';
function token() { return get(user)?.token ?? ''; }
function headers(extra: Record<string, string> = {}): Record<string, string> {
return { Authorization: `Bearer ${token()}`, ...extra };
}
async function handleRes<T>(res: Response): Promise<T> {
if (res.status === 401) { user.clear(); goto('/login'); throw new Error('Nicht angemeldet'); }
if (!res.ok) {
const e = await res.json().catch(() => ({}));
throw new Error((e as { message?: string }).message || res.statusText);
}
if (res.status === 204) return undefined as T;
return res.json() as Promise<T>;
}
export const api = {
async get<T>(path: string, query: Record<string, string> = {}): Promise<T> {
const url = new URL('/api' + path, location.origin);
Object.entries(query).forEach(([k, v]) => v !== undefined && url.searchParams.set(k, v));
return handleRes<T>(await fetch(url.toString(), { headers: headers() }));
},
async post<T>(path: string, data?: unknown): Promise<T> {
return handleRes<T>(await fetch('/api' + path, {
method: 'POST',
headers: headers({ 'Content-Type': 'application/json' }),
body: JSON.stringify(data ?? {}),
}));
},
async put<T>(path: string, data?: unknown): Promise<T> {
return handleRes<T>(await fetch('/api' + path, {
method: 'PUT',
headers: headers({ 'Content-Type': 'application/json' }),
body: JSON.stringify(data ?? {}),
}));
},
async patch<T>(path: string, data?: unknown): Promise<T> {
return handleRes<T>(await fetch('/api' + path, {
method: 'PATCH',
headers: headers({ 'Content-Type': 'application/json' }),
body: JSON.stringify(data ?? {}),
}));
},
async del<T = void>(path: string): Promise<T> {
return handleRes<T>(await fetch('/api' + path, { method: 'DELETE', headers: headers() }));
},
async postForm<T>(path: string, form: FormData): Promise<T> {
return handleRes<T>(await fetch('/api' + path, {
method: 'POST', headers: headers(), body: form,
}));
},
fileUrl(verein_id: string, record_id: string, filename: string, thumb = false): string {
const base = `/api/files/${verein_id}/${record_id}/${filename}`;
return thumb ? base + '?thumb=1' : base;
}
};

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>

13
app/src/lib/event-calendar.d.ts vendored Normal file
View file

@ -0,0 +1,13 @@
declare module '@event-calendar/core' {
import type { SvelteComponent } from 'svelte';
export class Calendar extends SvelteComponent<{
plugins: any[];
options: Record<string, any>;
}> {}
export const TimeGrid: any;
export const DayGrid: any;
export const List: any;
export const Interaction: any;
}

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

@ -0,0 +1,19 @@
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';
import gear from './icons/gear.svg?raw';
import images from './icons/images.svg?raw';
export const icons = {
house,
users,
calendar,
'currency-eur': currencyEur,
envelope,
gear,
images,
} 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"/><circle cx="128" cy="128" r="40" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><path d="M41.43,178.09A99.14,99.14,0,0,1,31.36,153.8l16.78-21a81.59,81.59,0,0,1,0-9.64l-16.77-21a99.43,99.43,0,0,1,10.05-24.3l26.71-3a81,81,0,0,1,6.81-6.81l3-26.7A99.14,99.14,0,0,1,102.2,31.36l21,16.78a81.59,81.59,0,0,1,9.64,0l21-16.77a99.43,99.43,0,0,1,24.3,10.05l3,26.71a81,81,0,0,1,6.81,6.81l26.7,3a99.14,99.14,0,0,1,10.07,24.29l-16.78,21a81.59,81.59,0,0,1,0,9.64l16.77,21a99.43,99.43,0,0,1-10,24.3l-26.71,3a81,81,0,0,1-6.81,6.81l-3,26.7a99.14,99.14,0,0,1-24.29,10.07l-21-16.78a81.59,81.59,0,0,1-9.64,0l-21,16.77a99.43,99.43,0,0,1-24.3-10l-3-26.71a81,81,0,0,1-6.81-6.81Z" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/></svg>

After

Width:  |  Height:  |  Size: 920 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"/><rect x="64" y="48" width="160" height="128" rx="8" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><circle cx="172" cy="84" r="12"/><path d="M64,128.69l38.34-38.35a8,8,0,0,1,11.32,0L163.31,140,189,114.34a8,8,0,0,1,11.31,0L224,138.06" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><path d="M192,176v24a8,8,0,0,1-8,8H40a8,8,0,0,1-8-8V88a8,8,0,0,1,8-8H64" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/></svg>

After

Width:  |  Height:  |  Size: 672 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

@ -0,0 +1,52 @@
import { SignJWT, jwtVerify } from 'jose';
import bcrypt from 'bcryptjs';
import { error } from '@sveltejs/kit';
const JWT_SECRET = new TextEncoder().encode(
process.env.JWT_SECRET || 'vereinshaus-dev-secret-change-in-production'
);
export interface JwtPayload {
sub: string;
verein_id: string;
rolle: string | null;
name: string;
email: string;
}
export async function signJwt(payload: JwtPayload): Promise<string> {
return new SignJWT({ ...payload })
.setProtectedHeader({ alg: 'HS256' })
.setExpirationTime('30d')
.sign(JWT_SECRET);
}
export async function verifyJwt(token: string): Promise<JwtPayload | null> {
try {
const { payload } = await jwtVerify(token, JWT_SECRET);
return payload as unknown as JwtPayload;
} catch {
return null;
}
}
export async function hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, 12);
}
export async function checkPassword(password: string, hash: string): Promise<boolean> {
return bcrypt.compare(password, hash);
}
export function bearerToken(request: Request): string | null {
const h = request.headers.get('Authorization');
return h?.startsWith('Bearer ') ? h.slice(7) : null;
}
export async function requireAuth(request: Request): Promise<JwtPayload> {
const token = bearerToken(request);
if (!token) throw error(401, 'Nicht authentifiziert');
const user = await verifyJwt(token);
if (!user) throw error(401, 'Ungültiger Token');
return user;
}

219
app/src/lib/server/db.ts Normal file
View file

@ -0,0 +1,219 @@
import Database from 'better-sqlite3';
import { mkdirSync, existsSync } from 'fs';
import { dirname } from 'path';
const DB_PATH = process.env.DB_PATH || './data/vereinshaus.db';
const SCHEMA = `
PRAGMA journal_mode = WAL;
PRAGMA foreign_keys = ON;
PRAGMA busy_timeout = 5000;
CREATE TABLE IF NOT EXISTS vereine (
id TEXT PRIMARY KEY NOT NULL DEFAULT (lower(hex(randomblob(8)))),
name TEXT NOT NULL,
adresse TEXT, plz TEXT, ort TEXT, bundesland TEXT,
plan TEXT NOT NULL DEFAULT 'free',
dosb_mitglied INTEGER NOT NULL DEFAULT 0,
email TEXT, telefon TEXT, website TEXT,
glaeubigerid TEXT, iban TEXT, bic TEXT,
created TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
updated TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
);
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY NOT NULL DEFAULT (lower(hex(randomblob(8)))),
verein_id TEXT NOT NULL REFERENCES vereine(id) ON DELETE CASCADE,
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
name TEXT NOT NULL DEFAULT '',
rolle TEXT DEFAULT NULL,
created TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
updated TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
);
CREATE TABLE IF NOT EXISTS gruppen (
id TEXT PRIMARY KEY NOT NULL DEFAULT (lower(hex(randomblob(8)))),
verein_id TEXT NOT NULL REFERENCES vereine(id) ON DELETE CASCADE,
name TEXT NOT NULL,
beschreibung TEXT,
trainer_ids TEXT NOT NULL DEFAULT '[]',
created TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
updated TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
);
CREATE TABLE IF NOT EXISTS mitglieder (
id TEXT PRIMARY KEY NOT NULL DEFAULT (lower(hex(randomblob(8)))),
verein_id TEXT NOT NULL REFERENCES vereine(id) ON DELETE CASCADE,
vorname TEXT NOT NULL,
nachname TEXT NOT NULL,
email TEXT, telefon TEXT,
geburtsdatum TEXT, eintrittsdatum TEXT, austrittsdatum TEXT,
strasse TEXT, plz TEXT, ort TEXT,
iban TEXT, bic TEXT,
gruppe_ids TEXT NOT NULL DEFAULT '[]',
status TEXT NOT NULL DEFAULT 'aktiv',
notizen TEXT,
mandatsreferenz TEXT, mandatsdatum TEXT,
created TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
updated TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
);
CREATE TABLE IF NOT EXISTS beitraege (
id TEXT PRIMARY KEY NOT NULL DEFAULT (lower(hex(randomblob(8)))),
verein_id TEXT NOT NULL REFERENCES vereine(id) ON DELETE CASCADE,
name TEXT NOT NULL,
betrag REAL NOT NULL,
rhythmus TEXT NOT NULL DEFAULT 'jaehrlich',
beschreibung TEXT,
created TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
updated TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
);
CREATE TABLE IF NOT EXISTS einzuege (
id TEXT PRIMARY KEY NOT NULL DEFAULT (lower(hex(randomblob(8)))),
verein_id TEXT NOT NULL REFERENCES vereine(id) ON DELETE CASCADE,
mitglied_id TEXT NOT NULL REFERENCES mitglieder(id) ON DELETE CASCADE,
beitrag_id TEXT NOT NULL REFERENCES beitraege(id),
betrag REAL NOT NULL,
faellig_am TEXT,
status TEXT NOT NULL DEFAULT 'ausstehend',
created TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
updated TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
);
CREATE TABLE IF NOT EXISTS veranstaltungsorte (
id TEXT PRIMARY KEY NOT NULL DEFAULT (lower(hex(randomblob(8)))),
verein_id TEXT NOT NULL REFERENCES vereine(id) ON DELETE CASCADE,
name TEXT NOT NULL,
adresse TEXT,
typ TEXT DEFAULT 'sonstiges',
aktiv INTEGER NOT NULL DEFAULT 1,
created TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
updated TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
);
CREATE TABLE IF NOT EXISTS ort_ausfaelle (
id TEXT PRIMARY KEY NOT NULL DEFAULT (lower(hex(randomblob(8)))),
ort_id TEXT NOT NULL REFERENCES veranstaltungsorte(id) ON DELETE CASCADE,
von TEXT NOT NULL, bis TEXT NOT NULL, grund TEXT,
created TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
updated TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
);
CREATE TABLE IF NOT EXISTS termine (
id TEXT PRIMARY KEY NOT NULL DEFAULT (lower(hex(randomblob(8)))),
verein_id TEXT NOT NULL REFERENCES vereine(id) ON DELETE CASCADE,
titel TEXT NOT NULL,
beschreibung TEXT,
beginn TEXT NOT NULL,
ende TEXT,
ort TEXT,
ort_id TEXT REFERENCES veranstaltungsorte(id) ON DELETE SET NULL,
gruppe_ids TEXT NOT NULL DEFAULT '[]',
durchfuehrender_id TEXT,
verfuegbarkeit TEXT DEFAULT 'offen',
rrule TEXT,
serie_id TEXT,
created TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
updated TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
);
CREATE TABLE IF NOT EXISTS nachrichten (
id TEXT PRIMARY KEY NOT NULL DEFAULT (lower(hex(randomblob(8)))),
verein_id TEXT NOT NULL REFERENCES vereine(id) ON DELETE CASCADE,
autor_id TEXT NOT NULL,
betreff TEXT NOT NULL,
text TEXT NOT NULL DEFAULT '',
gruppe_ids TEXT NOT NULL DEFAULT '[]',
gesendet_am TEXT,
created TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
updated TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
);
CREATE TABLE IF NOT EXISTS neuigkeiten (
id TEXT PRIMARY KEY NOT NULL DEFAULT (lower(hex(randomblob(8)))),
verein_id TEXT NOT NULL REFERENCES vereine(id) ON DELETE CASCADE,
autor_id TEXT NOT NULL,
autor_name TEXT NOT NULL DEFAULT '',
text TEXT,
medien TEXT NOT NULL DEFAULT '[]',
gruppe_ids TEXT NOT NULL DEFAULT '[]',
termin_id TEXT REFERENCES termine(id) ON DELETE SET NULL,
created TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
updated TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
);
CREATE TABLE IF NOT EXISTS reaktionen (
id TEXT PRIMARY KEY NOT NULL DEFAULT (lower(hex(randomblob(8)))),
beitrag_id TEXT NOT NULL REFERENCES neuigkeiten(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
UNIQUE(beitrag_id, user_id)
);
CREATE TABLE IF NOT EXISTS einladungen (
id TEXT PRIMARY KEY NOT NULL DEFAULT (lower(hex(randomblob(8)))),
verein_id TEXT NOT NULL REFERENCES vereine(id) ON DELETE CASCADE,
token TEXT NOT NULL UNIQUE DEFAULT (lower(hex(randomblob(16)))),
rolle TEXT NOT NULL DEFAULT 'trainer',
genutzt INTEGER NOT NULL DEFAULT 0,
created TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
updated TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
);
CREATE TABLE IF NOT EXISTS push_subscriptions (
id TEXT PRIMARY KEY NOT NULL DEFAULT (lower(hex(randomblob(8)))),
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
verein_id TEXT NOT NULL REFERENCES vereine(id) ON DELETE CASCADE,
endpoint TEXT NOT NULL UNIQUE,
p256dh TEXT NOT NULL,
auth TEXT NOT NULL,
created TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
);
`;
let _db: Database.Database | null = null;
export function getDb(): Database.Database {
if (_db) return _db;
const dir = dirname(DB_PATH);
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
_db = new Database(DB_PATH);
_db.exec(SCHEMA);
return _db;
}
export function newId(): string {
const bytes = new Uint8Array(8);
crypto.getRandomValues(bytes);
return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
}
export function parseArr(val: unknown): string[] {
if (Array.isArray(val)) return val;
if (typeof val === 'string') { try { return JSON.parse(val); } catch { return []; } }
return [];
}
export function toArr(val: unknown): string {
return JSON.stringify(Array.isArray(val) ? val : []);
}
export function row<T extends Record<string, unknown>>(r: T): T {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(r)) {
if (typeof v === 'string' && (k.endsWith('_ids') || k === 'medien' || k === 'trainer_ids')) {
out[k] = parseArr(v);
} else if (typeof v === 'number' && (k === 'aktiv' || k === 'dosb_mitglied' || k === 'genutzt')) {
out[k] = Boolean(v);
} else {
out[k] = v;
}
}
return out as T;
}
export function rows<T extends Record<string, unknown>>(rs: T[]): T[] {
return rs.map(r => row(r));
}

View file

@ -0,0 +1,59 @@
:root {
/* Primärfarbe */
--c-primary: #1e40af;
--c-primary-dark: #1d3a9e;
--c-primary-light: #e0e7ff;
--c-primary-subtle: #eff6ff;
/* Text */
--c-text: #1e293b;
--c-text-secondary: #475569;
--c-text-muted: #64748b;
--c-text-hint: #94a3b8;
/* Hintergrund & Rahmen */
--c-border: #e2e8f0;
--c-bg: #f1f5f9;
--c-bg-card: #ffffff;
--c-bg-subtle: #f8fafc;
/* Dunkel (Theme-Farbe, Header, PWA) */
--c-dark: #0f172a;
/* Fehler / Rot */
--c-error: #dc2626;
--c-error-dark: #b91c1c;
--c-error-light: #fca5a5;
--c-error-bg: #fee2e2;
--c-error-subtle: #fef2f2;
/* Erfolg / Grün */
--c-success: #16a34a;
--c-success-light: #86efac;
--c-success-bg: #dcfce7;
/* Warnung / Gelb-Amber */
--c-warning: #f59e0b;
--c-warning-light: #fde047;
--c-warning-bg: #fef9c3;
--c-warning-subtle: #fffbeb;
--c-warning-dark: #92400e;
--c-warning-darker: #713f12;
/* Akzent / Lila (Plan-Badges) */
--c-accent: #7c3aed;
--c-accent-subtle: #ede9fe;
/* Primärfarbe weitere Töne */
--c-primary-100: #bfdbfe;
--c-primary-200: #c7d2fe;
--c-primary-bg: #f0f9ff;
/* Erfolg weitere Töne */
--c-success-dark: #166534;
--c-success-subtle: #f0fdf4;
/* Warnung weitere Töne */
--c-warning-amber: #854d0e;
--c-warning-pale: #fefce8;
}

View file

@ -1,18 +1,23 @@
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;
email?: string;
telefon?: string;
website?: string;
glaeubigerid?: string;
iban?: string;
bic?: string;
}
export interface Mitglied {
@ -20,26 +25,77 @@ 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 type Rolle = 'admin' | 'trainer';
export interface Neuigkeit {
id: string;
verein_id: string;
autor_id: string;
autor_name?: string;
text?: string;
medien: string[];
gruppe_ids: string[];
termin_id?: string;
created: string;
}
export interface Reaktion {
id: string;
beitrag_id: string;
user_id: string;
}
export type Verfuegbarkeit = 'offen' | 'bestaetigt' | 'abgesagt' | 'vertretung_gesucht';
export interface Gruppe {
id: string;
verein_id: string;
name: string;
beschreibung?: string;
trainer_ids: string[];
}
export type OrtTyp = 'halle' | 'platz' | 'gebaeude' | 'sonstiges';
export interface Veranstaltungsort {
id: string;
verein_id: string;
name: string;
adresse?: string;
typ?: OrtTyp;
aktiv: boolean;
}
export interface OrtAusfall {
id: string;
ort_id: string;
von: string;
bis: string;
grund?: string;
}
export interface Einladung {
id: string;
verein_id: string;
rolle: Rolle;
token: string;
genutzt: boolean;
}
export interface Beitrag {
@ -53,13 +109,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 {
@ -71,6 +125,11 @@ export interface Termin {
ende?: string;
ort?: string;
gruppe_ids: string[];
durchfuehrender_id?: string;
verfuegbarkeit?: Verfuegbarkeit;
rrule?: string;
serie_id?: string;
ort_id?: string;
}
export interface Nachricht {
@ -80,5 +139,5 @@ export interface Nachricht {
betreff: string;
text: string;
gruppe_ids: string[];
gesendet_am: string;
gesendet_am?: string;
}

34
app/src/lib/user.ts Normal file
View file

@ -0,0 +1,34 @@
import { writable } from 'svelte/store';
import { browser } from '$app/environment';
export interface AppUser {
id: string;
verein_id: string;
rolle: string | null;
name: string;
email: string;
token: string;
}
function createUserStore() {
let initial: AppUser | null = null;
if (browser) {
try { initial = JSON.parse(localStorage.getItem('vh_user') || 'null'); } catch { /* */ }
}
const { subscribe, set } = writable<AppUser | null>(initial);
return {
subscribe,
set(u: AppUser | null) {
if (browser) {
if (u) localStorage.setItem('vh_user', JSON.stringify(u));
else localStorage.removeItem('vh_user');
}
set(u);
},
clear() { this.set(null); }
};
}
export const user = createUserStore();

View file

@ -2,34 +2,82 @@
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { onMount } from 'svelte';
import { pb } from '$lib/pb';
import { user } from '$lib/user';
import Icon from '$lib/components/Icon.svelte';
import type { IconName } from '$lib/icons';
let { children } = $props();
let zeigInstallBanner = $state(false);
onMount(() => {
if (!pb.authStore.isValid) {
goto('/login');
}
if (!$user) { goto('/login'); return; }
if (!$user.verein_id) { goto('/onboarding'); return; }
registerPush();
// iOS Safari: Banner anzeigen wenn App noch nicht installiert
const isIos = /iphone|ipad|ipod/i.test(navigator.userAgent);
const isStandalone = window.matchMedia('(display-mode: standalone)').matches
|| (navigator as any).standalone === true;
const dismissed = sessionStorage.getItem('install-banner-dismissed');
if (isIos && !isStandalone && !dismissed) zeigInstallBanner = true;
});
function logout() {
pb.authStore.clear();
goto('/login');
async function registerPush() {
if (!('serviceWorker' in navigator) || !('PushManager' in window)) return;
const permission = await Notification.requestPermission();
if (permission !== 'granted') return;
try {
const reg = await navigator.serviceWorker.ready;
const keyRes = await fetch('/api/push/key');
const { publicKey } = await keyRes.json();
if (!publicKey) return;
let sub = await reg.pushManager.getSubscription();
if (!sub) {
sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(publicKey) as BufferSource,
});
}
await fetch('/api/push/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${$user?.token}` },
body: JSON.stringify({ subscription: sub.toJSON(), userId: $user?.id }),
});
} catch (e) {
console.warn('[push] Registrierung fehlgeschlagen:', e);
}
}
const navItems = [
{ href: '/', label: 'Übersicht', icon: '⊞' },
{ href: '/mitglieder', label: 'Mitglieder', icon: '👥' },
{ href: '/termine', label: 'Termine', icon: '📅' },
{ href: '/beitraege', label: 'Beiträge', icon: '💶' },
{ href: '/nachrichten', label: 'Nachrichten', icon: '✉️' },
function urlBase64ToUint8Array(base64String: string): Uint8Array {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
const raw = atob(base64);
return Uint8Array.from([...raw].map((c) => c.charCodeAt(0)));
}
const isAdmin = () => !$user?.rolle || $user?.rolle === 'admin';
const allNavItems: { href: string; label: string; icon: IconName; adminOnly?: boolean }[] = [
{ href: '/neuigkeiten', label: 'Neuigkeiten', icon: 'images' },
{ href: '/mitglieder', label: 'Mitglieder', icon: 'users' },
{ href: '/termine', label: 'Termine', icon: 'calendar' },
{ href: '/beitraege', label: 'Beiträge', icon: 'currency-eur', adminOnly: true },
{ href: '/nachrichten', label: 'Nachrichten', icon: 'envelope' },
];
const navItems = $derived(allNavItems.filter(i => !i.adminOnly || isAdmin()));
</script>
<div class="shell">
<header>
<span class="logo">vereins.haus</span>
<button class="logout-btn" onclick={logout}>Abmelden</button>
<a href="/" class="logo">
<img src="/favicon.svg" alt="" width="28" height="28" />
vereins.haus
</a>
<a href="/einstellungen" class="header-icon" aria-label="Einstellungen">
<Icon name="gear" size={22} />
</a>
</header>
<main>
@ -42,13 +90,23 @@
href={item.href}
class:active={$page.url.pathname === item.href}
>
<span class="nav-icon">{item.icon}</span>
<Icon name={item.icon} size={22} />
<span class="nav-label">{item.label}</span>
</a>
{/each}
</nav>
</div>
{#if zeigInstallBanner}
<div class="install-banner">
<div class="install-text">
<strong>Zum Homescreen hinzufügen</strong>
<span>Tippe auf <strong>Teilen</strong><strong>Zum Home-Bildschirm</strong> für die Vollbild-App</span>
</div>
<button class="install-close" onclick={() => { zeigInstallBanner = false; sessionStorage.setItem('install-banner-dismissed', '1'); }}>✕</button>
</div>
{/if}
<style>
.shell {
display: flex;
@ -56,6 +114,11 @@
min-height: 100dvh;
}
/* iOS PWA: Statusleiste oben nicht überlagern */
@supports (padding-top: env(safe-area-inset-top)) {
header { padding-top: calc(0.75rem + env(safe-area-inset-top)); }
}
header {
position: sticky;
top: 0;
@ -64,28 +127,29 @@
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
background: #fff;
border-bottom: 1px solid #e2e8f0;
background: var(--c-bg-card);
border-bottom: 1px solid var(--c-border);
}
.logo {
font-size: 1.1rem;
display: flex;
align-items: center;
gap: 0.45rem;
font-size: 1.05rem;
font-weight: 700;
color: #1e40af;
color: var(--c-dark);
letter-spacing: -0.02em;
text-decoration: none;
}
.logout-btn {
background: none;
border: none;
font-size: 0.85rem;
color: #64748b;
padding: 0.25rem 0.5rem;
}
.logout-btn:hover {
color: #1e293b;
.header-icon {
color: var(--c-text-muted);
display: flex;
align-items: center;
padding: 0.25rem;
transition: color 0.15s;
}
.header-icon:hover { color: var(--c-text); }
main {
flex: 1;
@ -103,8 +167,8 @@
right: 0;
height: calc(60px + env(safe-area-inset-bottom));
padding-bottom: env(safe-area-inset-bottom);
background: #fff;
border-top: 1px solid #e2e8f0;
background: var(--c-bg-card);
border-top: 1px solid var(--c-border);
display: flex;
z-index: 10;
}
@ -116,23 +180,56 @@
align-items: center;
justify-content: center;
gap: 0.15rem;
color: #94a3b8;
color: var(--c-text-hint);
transition: color .15s;
font-size: 0.7rem;
text-decoration: none;
}
.bottom-nav a.active {
color: #1e40af;
}
.nav-icon {
font-size: 1.3rem;
line-height: 1;
color: var(--c-primary);
}
.nav-label {
font-size: 0.65rem;
font-weight: 500;
}
/* iOS Installations-Banner */
.install-banner {
position: fixed;
bottom: calc(60px + env(safe-area-inset-bottom) + 0.5rem);
left: 1rem;
right: 1rem;
background: var(--c-dark);
color: #fff;
border-radius: 12px;
padding: 0.85rem 1rem;
display: flex;
align-items: center;
gap: 0.75rem;
z-index: 50;
box-shadow: 0 4px 20px rgba(0,0,0,0.25);
animation: slide-up 0.25s ease;
}
@keyframes slide-up {
from { transform: translateY(1rem); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
.install-text {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.2rem;
font-size: 0.82rem;
line-height: 1.4;
}
.install-close {
background: none;
border: none;
color: var(--c-text-hint);
font-size: 1rem;
padding: 0.25rem;
flex-shrink: 0;
}
</style>

View file

@ -1,72 +1,141 @@
<script lang="ts">
import { pb } from '$lib/pb';
import { api } from '$lib/api';
import { onMount } from 'svelte';
import type { Verein, Termin } from '$lib/types';
const vereinsname = pb.authStore.record?.name ?? 'Dein Verein';
let verein = $state<Verein | null>(null);
let termine = $state<Termin[]>([]);
let loading = $state(true);
onMount(async () => {
const now = new Date().toISOString();
[verein, termine] = await Promise.all([
api.get<Verein>('/vereine'),
api.get<Termin[]>('/termine', { sort: 'beginn', page: '1', perPage: '3' }),
]);
termine = (termine as any)?.items ?? termine;
loading = false;
});
function formatTermin(t: Termin): string {
const d = new Date(t.beginn);
return d.toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: 'long' })
+ ' · ' + d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }) + ' Uhr'
+ (t.ort ? ' · ' + t.ort : '');
}
</script>
<svelte:head>
<title>Übersicht — vereins.haus</title>
</svelte:head>
<h1>Willkommen, {vereinsname}</h1>
{#if !loading && verein}
<div class="verein-header">
<img src="/favicon.svg" alt="" class="verein-icon" />
<div>
<h1>{verein.name}</h1>
{#if verein.ort}
<span class="verein-ort">{verein.ort}</span>
{/if}
</div>
</div>
<div class="cards">
<a href="/mitglieder" class="card">
<span class="card-icon">👥</span>
<span class="card-label">Mitglieder</span>
</a>
<a href="/termine" class="card">
<span class="card-icon">📅</span>
<span class="card-label">Termine</span>
</a>
<a href="/beitraege" class="card">
<span class="card-icon">💶</span>
<span class="card-label">Beiträge</span>
</a>
<a href="/nachrichten" class="card">
<span class="card-icon">✉️</span>
<span class="card-label">Nachrichten</span>
</a>
</div>
{#if termine.length > 0}
<section>
<h2>Nächste Termine</h2>
<ul>
{#each termine as t (t.id)}
<li>
<span class="termin-titel">{t.titel}</span>
<span class="termin-wann">{formatTermin(t)}</span>
</li>
{/each}
</ul>
<a href="/termine" class="alle-link">Alle Termine →</a>
</section>
{:else}
<p class="hint">Noch keine Termine geplant.</p>
{/if}
{/if}
<style>
h1 {
font-size: 1.4rem;
font-weight: 700;
color: #1e293b;
.verein-header {
display: flex;
align-items: center;
gap: 1rem;
padding: 1.25rem;
background: var(--c-dark);
border-radius: 14px;
margin-bottom: 1.5rem;
}
.cards {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem;
.verein-icon {
width: 52px;
height: 52px;
border-radius: 12px;
flex-shrink: 0;
}
.card {
background: #fff;
border: 1.5px solid #e2e8f0;
h1 {
font-size: 1.15rem;
font-weight: 700;
color: var(--c-bg-card);
margin: 0;
}
.verein-ort {
font-size: 0.82rem;
color: var(--c-text-hint);
}
section {
background: var(--c-bg-card);
border: 1px solid var(--c-border);
border-radius: 12px;
padding: 1.5rem 1rem;
overflow: hidden;
}
h2 {
font-size: 0.72rem;
font-weight: 700;
color: var(--c-text-hint);
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 0.85rem 1rem 0.5rem;
margin: 0;
}
ul {
list-style: none;
padding: 0;
margin: 0;
}
li {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
transition: border-color .15s, box-shadow .15s;
gap: 0.15rem;
padding: 0.7rem 1rem;
border-top: 1px solid var(--c-bg);
}
.card:hover {
border-color: #1e40af;
box-shadow: 0 2px 8px rgba(30,64,175,.1);
.termin-titel { font-size: 0.92rem; font-weight: 600; color: var(--c-text); }
.termin-wann { font-size: 0.78rem; color: var(--c-text-muted); }
.alle-link {
display: block;
padding: 0.7rem 1rem;
border-top: 1px solid var(--c-bg);
font-size: 0.85rem;
color: var(--c-primary);
text-decoration: none;
font-weight: 500;
}
.card-icon {
font-size: 2rem;
}
.card-label {
.hint {
color: var(--c-text-hint);
font-size: 0.9rem;
font-weight: 600;
color: #1e293b;
text-align: center;
margin-top: 2rem;
}
</style>

View file

@ -1,27 +1,531 @@
<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="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 !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>
.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;
.top .btn-primary { flex: none; padding: 0.45rem 0.9rem; font-size: 0.875rem; }
h1 { font-size: 1.4rem; font-weight: 700; color: var(--c-text); }
.plan-hinweis {
background: var(--c-primary-subtle); border: 1px solid var(--c-primary-100); border-radius: 8px;
padding: 0.75rem 1rem; font-size: 0.875rem; color: var(--c-primary); margin-bottom: 1rem;
}
.plan-hinweis a { color: var(--c-primary); font-weight: 700; }
.hinweis {
background: var(--c-warning-bg);
border: 1px solid var(--c-warning-light);
border-radius: 8px;
padding: 0.75rem 1rem;
font-size: 0.875rem;
color: var(--c-warning-darker);
margin-bottom: 1rem;
}
.hinweis a { color: var(--c-warning-darker); }
.hint { color: var(--c-text-hint); 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: var(--c-bg-card);
border: 1px solid var(--c-border);
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: var(--c-text); }
.karte-beschr { font-size: 0.78rem; color: var(--c-text-hint); }
.karte-meta { font-size: 0.82rem; color: var(--c-text-secondary); }
.karte-aktionen {
display: flex;
align-items: center;
gap: 0.4rem;
flex-shrink: 0;
}
.btn-sepa {
padding: 0.35rem 0.7rem;
background: var(--c-primary-light);
color: var(--c-primary);
border: none;
border-radius: 6px;
font-size: 0.78rem;
font-weight: 700;
cursor: pointer;
transition: background 0.15s;
}
.btn-sepa:hover:not(:disabled) { background: var(--c-primary-200); }
.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 var(--c-border);
border-radius: 6px;
color: var(--c-text-muted);
font-size: 0.9rem;
cursor: pointer;
transition: border-color 0.15s, color 0.15s;
}
.btn-icon:hover { border-color: var(--c-text-hint); color: var(--c-text); }
.btn-icon-red:hover { border-color: var(--c-error-light); color: var(--c-error); }
/* 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: var(--c-bg-card);
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: var(--c-text); margin-bottom: 0.25rem; }
.sepa-sub { font-size: 0.85rem; color: var(--c-text-muted); 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: var(--c-text-secondary); }
.field-hint { font-size: 0.75rem; color: var(--c-text-hint); }
input, select {
padding: 0.65rem 0.85rem;
border: 1.5px solid var(--c-border);
border-radius: 8px;
font-size: 1rem;
background: var(--c-bg-card);
width: 100%;
box-sizing: border-box;
transition: border-color 0.15s;
}
input:focus, select:focus { outline: none; border-color: var(--c-primary); }
.sepa-summary {
background: var(--c-bg-subtle);
border: 1px solid var(--c-border);
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: var(--c-text);
border-bottom: 1px solid var(--c-border);
}
.sepa-row:last-child { border-bottom: none; }
.sepa-warn { color: var(--c-warning-dark); background: var(--c-warning-subtle); }
.sepa-total { font-weight: 700; background: var(--c-primary-bg); }
.error { color: var(--c-error); 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: var(--c-primary);
color: var(--c-bg-card);
border: none;
border-radius: 8px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: background 0.15s;
}
.btn-primary:hover:not(:disabled) { background: var(--c-primary-dark); }
.btn-primary:disabled { opacity: 0.55; cursor: not-allowed; }
.btn-ghost {
padding: 0.75rem 1rem;
background: none;
border: 1.5px solid var(--c-border);
border-radius: 8px;
font-size: 1rem;
color: var(--c-text-muted);
cursor: pointer;
}
.placeholder { color: #94a3b8; font-size: 0.95rem; }
</style>

View file

@ -0,0 +1,570 @@
<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 type { Verein, Gruppe } from '$lib/types';
const isAdmin = () => { const u = get(user); return !u?.rolle || u.rolle === 'admin'; };
let loading = $state(true);
let saving = $state(false);
let error = $state('');
let success = $state('');
// Vereinsprofil
let name = $state('');
let adresse = $state('');
let plz = $state('');
let ort = $state('');
let bundesland = $state('');
let email = $state('');
let telefon = $state('');
let website = $state('');
// SEPA
let glaeubigerid = $state('');
let iban = $state('');
let bic = $state('');
// Plan
let plan = $state<Verein['plan']>('free');
let mitgliederAnz = $state(0);
// Trainer
let trainer = $state<any[]>([]);
let gruppen = $state<Gruppe[]>([]);
let einladungUrl = $state('');
let einladungKopiert = $state(false);
let vereinId = $state('');
const planInfo: Record<string, { label: string; farbe: string; features: string[]; limit: number | null }> = {
free: { label: 'Kostenlos', farbe: 'var(--c-text-muted)', limit: 50, features: ['Bis 50 Mitglieder', 'Termine & Wiederholungen', 'Nachrichten & Push', 'Veranstaltungsorte', 'Durchführende einladen'] },
starter: { label: 'Starter', farbe: 'var(--c-primary)', limit: 150, features: ['Bis 150 Mitglieder', 'SEPA pain.008-Export', 'iCal-Kalender-Abo', 'Alle Free-Features'] },
wachstum:{ label: 'Verband', farbe: 'var(--c-accent)', limit: null, features: ['Unbegrenzte Mitglieder', 'Mehrere Admins', 'Prioritäts-Support', 'Alle Starter-Features'] },
verband: { label: 'Verband', farbe: 'var(--c-accent)', limit: null, features: ['Unbegrenzte Mitglieder', 'Mehrere Admins', 'Prioritäts-Support', 'Alle Starter-Features'] },
};
const istFree = $derived(plan === 'free');
const istStarter = $derived(plan === 'starter' || plan === 'wachstum' || plan === 'verband');
const limitErreicht = $derived(istFree && mitgliederAnz > 50);
const bundeslaender = [
['', '—'],
['BW', 'Baden-Württemberg'], ['BY', 'Bayern'], ['BE', 'Berlin'],
['BB', 'Brandenburg'], ['HB', 'Bremen'], ['HH', 'Hamburg'],
['HE', 'Hessen'], ['MV', 'Mecklenburg-Vorpommern'], ['NI', 'Niedersachsen'],
['NW', 'Nordrhein-Westfalen'], ['RP', 'Rheinland-Pfalz'], ['SL', 'Saarland'],
['SN', 'Sachsen'], ['ST', 'Sachsen-Anhalt'], ['SH', 'Schleswig-Holstein'],
['TH', 'Thüringen'],
];
onMount(async () => {
vereinId = get(user)?.verein_id ?? '';
const [v, alleUser, alleGruppen, mitgliederCount] = await Promise.all([
api.get<Verein>('/vereine'),
isAdmin()
? api.get<any[]>('/users')
: Promise.resolve([]),
api.get<Gruppe[]>('/gruppen', { sort: 'name' }),
api.get<{ total: number }>('/mitglieder/count').catch(() => ({ total: 0 })),
]);
trainer = alleUser.filter((u: any) => u.rolle === 'trainer');
gruppen = alleGruppen;
plan = v.plan ?? 'free';
mitgliederAnz = (mitgliederCount as any).total ?? 0;
name = v.name ?? '';
adresse = v.adresse ?? '';
plz = v.plz ?? '';
ort = v.ort ?? '';
bundesland = v.bundesland ?? '';
email = v.email ?? '';
telefon = v.telefon ?? '';
website = v.website ?? '';
glaeubigerid = v.glaeubigerid ?? '';
iban = v.iban ?? '';
bic = v.bic ?? '';
loading = false;
});
async function speichern() {
if (!name.trim()) { error = 'Vereinsname ist Pflichtfeld.'; return; }
error = ''; success = ''; saving = true;
try {
await api.patch('/vereine', {
name: name.trim(),
adresse: adresse.trim() || null,
plz: plz.trim() || null,
ort: ort.trim() || null,
bundesland: bundesland || null,
email: email.trim() || null,
telefon: telefon.trim() || null,
website: website.trim() || null,
glaeubigerid: glaeubigerid.trim() || null,
iban: iban.trim() || null,
bic: bic.trim() || null,
});
success = 'Gespeichert.';
} catch (e: unknown) {
error = e instanceof Error ? e.message : 'Fehler beim Speichern.';
} finally {
saving = false;
}
}
async function trainerEinladen() {
const inv = await api.post<{ token: string }>('/einladungen', { rolle: 'trainer' });
einladungUrl = `${window.location.origin}/invite/${inv.token}`;
einladungKopiert = false;
}
async function trainerEntfernen(uid: string) {
if (!confirm('Trainer wirklich entfernen?')) return;
await api.del('/users/' + uid);
trainer = trainer.filter(t => t.id !== uid);
}
function trainerGruppen(uid: string): string {
return gruppen
.filter(g => (g.trainer_ids ?? []).includes(uid))
.map(g => g.name)
.join(', ') || '—';
}
// Passwort ändern
let pwAlt = $state('');
let pwNeu = $state('');
let pwWdh = $state('');
let pwError = $state('');
let pwSuccess = $state('');
let pwSaving = $state(false);
async function passwortAendern() {
if (!pwAlt || !pwNeu) { pwError = 'Alle Felder ausfüllen.'; return; }
if (pwNeu.length < 8) { pwError = 'Neues Passwort mindestens 8 Zeichen.'; return; }
if (pwNeu !== pwWdh) { pwError = 'Passwörter stimmen nicht überein.'; return; }
pwError = ''; pwSuccess = ''; pwSaving = true;
try {
const u = get(user);
// Altes Passwort prüfen via Login
await api.post('/auth/login', { email: u?.email, password: pwAlt });
// Neues Passwort setzen
await api.put('/users/' + u?.id, { password: pwNeu });
pwSuccess = 'Passwort geändert.';
pwAlt = ''; pwNeu = ''; pwWdh = '';
} catch {
pwError = 'Altes Passwort falsch oder Fehler beim Ändern.';
} finally {
pwSaving = false;
}
}
function abmelden() {
user.clear();
goto('/login');
}
</script>
<svelte:head><title>Einstellungen — vereins.haus</title></svelte:head>
<h1>Einstellungen</h1>
{#if loading}
<p class="hint">Laden…</p>
{:else}
<!-- Plan-Übersicht -->
{@const pi = planInfo[plan] ?? planInfo.free}
<div class="plan-card">
<div class="plan-header">
<div>
<span class="plan-label" style="color:{pi.farbe}">{pi.label}</span>
{#if pi.limit}
<span class="plan-limit">bis {pi.limit} Mitglieder</span>
{:else}
<span class="plan-limit">unbegrenzte Mitglieder</span>
{/if}
</div>
<span class="plan-mitglieder" class:warn={limitErreicht}>
{mitgliederAnz} Mitglieder
</span>
</div>
{#if limitErreicht}
<div class="plan-warn">
⚠ Du hast das Limit von 50 Mitgliedern überschritten. Neue Mitglieder können nicht angelegt werden.
</div>
{/if}
<ul class="plan-features">
{#each pi.features as f}
<li>{f}</li>
{/each}
{#if istFree}
<li class="gesperrt">✗ SEPA-Export <span class="upgrade-hint">Starter</span></li>
<li class="gesperrt">✗ iCal-Kalender-Abo <span class="upgrade-hint">Starter</span></li>
{/if}
</ul>
{#if istFree}
<a href="mailto:info@vereins.haus?subject=Upgrade%20auf%20Starter&body=Vereins-ID%3A%20{vereinId}" class="btn-upgrade">
Auf Starter upgraden 7 €/Monat →
</a>
{/if}
</div>
<form onsubmit={(e) => { e.preventDefault(); speichern(); }}>
<section>
<h2>Vereinsprofil</h2>
<div class="field">
<label for="name">Vereinsname *</label>
<input id="name" type="text" bind:value={name} required />
</div>
<div class="field">
<label for="email">E-Mail</label>
<input id="email" type="email" bind:value={email} placeholder="info@meinverein.de" />
</div>
<div class="field">
<label for="telefon">Telefon</label>
<input id="telefon" type="tel" bind:value={telefon} />
</div>
<div class="field">
<label for="website">Website</label>
<input id="website" type="url" bind:value={website} placeholder="https://meinverein.de" />
</div>
</section>
<section>
<h2>Anschrift</h2>
<div class="field">
<label for="adresse">Straße & Hausnummer</label>
<input id="adresse" type="text" bind:value={adresse} />
</div>
<div class="row">
<div class="field" style="flex: 0 0 5.5rem">
<label for="plz">PLZ</label>
<input id="plz" type="text" inputmode="numeric" bind:value={plz} />
</div>
<div class="field">
<label for="ort">Ort</label>
<input id="ort" type="text" bind:value={ort} />
</div>
</div>
<div class="field">
<label for="bundesland">Bundesland</label>
<select id="bundesland" bind:value={bundesland}>
{#each bundeslaender as [val, label]}
<option value={val}>{label}</option>
{/each}
</select>
</div>
</section>
<section>
<h2>SEPA-Lastschrift</h2>
<p class="sepa-hint">
Für den SEPA-XML-Export. Gläubiger-ID beim Finanzamt oder der Bundesbank beantragen.
</p>
<div class="field">
<label for="glaeubigerid">Gläubiger-ID</label>
<input id="glaeubigerid" type="text" bind:value={glaeubigerid} placeholder="DE98ZZZ09999999999" />
</div>
<div class="field">
<label for="iban">IBAN des Vereinskontos</label>
<input id="iban" type="text" bind:value={iban} placeholder="DE12 3456 7890 …" />
</div>
<div class="field">
<label for="bic">BIC</label>
<input id="bic" type="text" bind:value={bic} placeholder="COBADEFFXXX" />
</div>
</section>
<section>
<h2>Kalender-Abo</h2>
<p class="sepa-hint">
Diese URL in Apple Calendar, Google Calendar oder Outlook eintragen Termine erscheinen automatisch im Handy-Kalender.
</p>
<div class="ical-url">{typeof window !== 'undefined' ? window.location.origin : ''}/api/kalender/{vereinId}</div>
<button type="button" class="btn-kopieren" onclick={async () => {
await navigator.clipboard.writeText(`${window.location.origin}/api/kalender/${vereinId}`);
}}>URL kopieren</button>
<p class="sepa-hint" style="margin-top:0.4rem">Für iOS: <strong>webcal://</strong> statt https:// verwenden öffnet direkt den Abonnieren-Dialog.</p>
</section>
{#if isAdmin()}
<section>
<h2>Durchführende</h2>
{#if trainer.length === 0}
<p class="sepa-hint">Noch keine Trainer eingeladen.</p>
{:else}
<ul class="trainer-liste">
{#each trainer as t (t.id)}
<li>
<div class="trainer-info">
<span class="trainer-name">{t.name}</span>
<span class="trainer-gruppen">{trainerGruppen(t.id)}</span>
</div>
<button type="button" class="btn-remove" onclick={() => trainerEntfernen(t.id)}>Entfernen</button>
</li>
{/each}
</ul>
{/if}
<button type="button" class="btn-einladen" onclick={trainerEinladen}>
+ Trainer einladen
</button>
{#if einladungUrl}
<div class="invite-box">
<p class="invite-label">Einladungslink einmalig verwendbar:</p>
<div class="invite-url">{einladungUrl}</div>
<button type="button" class="btn-kopieren" onclick={async () => {
await navigator.clipboard.writeText(einladungUrl);
einladungKopiert = true;
}}>
{einladungKopiert ? 'Kopiert ✓' : 'Link kopieren'}
</button>
</div>
{/if}
</section>
{/if}
{#if error}
<p class="error">{error}</p>
{/if}
{#if success}
<p class="success">{success}</p>
{/if}
<button type="submit" class="btn-primary" disabled={saving}>
{saving ? 'Speichern…' : 'Speichern'}
</button>
</form>
<div class="divider"></div>
<!-- Passwort ändern -->
<section>
<h2>Passwort ändern</h2>
<div class="field">
<label for="pw-alt">Aktuelles Passwort</label>
<input id="pw-alt" type="password" bind:value={pwAlt} autocomplete="current-password" />
</div>
<div class="field">
<label for="pw-neu">Neues Passwort</label>
<input id="pw-neu" type="password" bind:value={pwNeu} autocomplete="new-password" />
</div>
<div class="field">
<label for="pw-wdh">Neues Passwort wiederholen</label>
<input id="pw-wdh" type="password" bind:value={pwWdh} autocomplete="new-password" />
</div>
{#if pwError}<p class="err">{pwError}</p>{/if}
{#if pwSuccess}<p class="ok">{pwSuccess}</p>{/if}
<button class="btn-save" onclick={passwortAendern} disabled={pwSaving}>
{pwSaving ? 'Wird geändert…' : 'Passwort ändern'}
</button>
</section>
<div class="divider"></div>
<a href="/import-export" class="btn-importexport">Import / Export</a>
<div class="divider" style="margin-top:1rem"></div>
<button class="btn-logout" onclick={abmelden}>Abmelden</button>
{/if}
<style>
h1 { font-size: 1.4rem; font-weight: 700; color: var(--c-text); margin-bottom: 1rem; }
/* Plan */
.plan-card {
background: var(--c-bg-card); border: 1.5px solid var(--c-border); border-radius: 12px;
padding: 1rem 1.1rem; margin-bottom: 1.5rem;
}
.plan-header {
display: flex; justify-content: space-between; align-items: baseline;
margin-bottom: 0.6rem;
}
.plan-label { font-size: 1rem; font-weight: 700; }
.plan-limit { font-size: 0.78rem; color: var(--c-text-hint); margin-left: 0.4rem; }
.plan-mitglieder { font-size: 0.82rem; color: var(--c-text-muted); }
.plan-mitglieder.warn { color: var(--c-error); font-weight: 700; }
.plan-warn {
background: var(--c-error-subtle); border: 1px solid var(--c-error-light); border-radius: 8px;
padding: 0.6rem 0.8rem; font-size: 0.82rem; color: var(--c-error); margin-bottom: 0.75rem;
}
.plan-features {
list-style: none; padding: 0; margin: 0 0 0.85rem;
display: flex; flex-direction: column; gap: 0.25rem;
}
.plan-features li { font-size: 0.85rem; color: var(--c-text-secondary); }
.plan-features li.gesperrt { color: var(--c-text-hint); }
.upgrade-hint {
display: inline-block; font-size: 0.7rem; font-weight: 700;
background: var(--c-primary-light); color: var(--c-primary); border-radius: 4px;
padding: 0.05rem 0.35rem; margin-left: 0.3rem;
}
.btn-upgrade {
display: block; width: 100%; padding: 0.65rem;
background: var(--c-primary); color: var(--c-bg-card); border: none; border-radius: 8px;
font-size: 0.9rem; font-weight: 600; text-align: center;
text-decoration: none; transition: background 0.15s;
}
.btn-upgrade:hover { background: var(--c-primary-dark); }
section {
margin-bottom: 1.5rem;
padding-bottom: 1.5rem;
border-bottom: 1px solid var(--c-bg);
}
h2 {
font-size: 0.72rem;
font-weight: 700;
color: var(--c-text-hint);
text-transform: uppercase;
letter-spacing: 0.06em;
margin-bottom: 0.85rem;
}
.sepa-hint {
font-size: 0.8rem;
color: var(--c-text-hint);
margin-bottom: 0.85rem;
line-height: 1.5;
}
.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: var(--c-text-secondary); }
input, select {
padding: 0.65rem 0.85rem;
border: 1.5px solid var(--c-border);
border-radius: 8px;
font-size: 1rem;
background: var(--c-bg-card);
width: 100%;
box-sizing: border-box;
transition: border-color 0.15s;
}
input:focus, select:focus { outline: none; border-color: var(--c-primary); }
.error { color: var(--c-error); font-size: 0.875rem; margin-bottom: 0.75rem; }
.success { color: var(--c-success); font-size: 0.875rem; margin-bottom: 0.75rem; }
.err { color: var(--c-error); font-size: 0.875rem; margin: 0.5rem 0; }
.ok { color: var(--c-success); font-size: 0.875rem; margin: 0.5rem 0; }
.btn-save {
padding: 0.65rem 1.25rem;
background: var(--c-primary); color: var(--c-bg-card);
border: none; border-radius: 8px;
font-size: 0.95rem; font-weight: 600; cursor: pointer;
margin-top: 0.25rem;
}
.btn-save:disabled { opacity: 0.55; cursor: not-allowed; }
.hint { color: var(--c-text-hint); font-size: 0.95rem; }
.btn-primary {
width: 100%;
padding: 0.75rem;
background: var(--c-primary);
color: var(--c-bg-card);
border: none;
border-radius: 8px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: background 0.15s;
margin-bottom: 2rem;
}
.btn-primary:hover:not(:disabled) { background: var(--c-primary-dark); }
.btn-primary:disabled { opacity: 0.55; cursor: not-allowed; }
.divider {
height: 1px;
background: var(--c-bg);
margin-bottom: 1.5rem;
}
.ical-url {
font-family: monospace; font-size: 0.78rem; color: var(--c-text);
background: var(--c-bg-subtle); border: 1px solid var(--c-border); border-radius: 6px;
padding: 0.5rem 0.75rem; word-break: break-all; margin-bottom: 0.5rem;
}
.trainer-liste {
list-style: none; padding: 0; margin: 0 0 0.75rem;
border: 1px solid var(--c-border); border-radius: 8px; overflow: hidden;
}
.trainer-liste li {
display: flex; align-items: center; justify-content: space-between;
gap: 0.75rem; padding: 0.7rem 0.85rem;
border-bottom: 1px solid var(--c-bg);
}
.trainer-liste li:last-child { border-bottom: none; }
.trainer-info { display: flex; flex-direction: column; gap: 0.1rem; }
.trainer-name { font-size: 0.9rem; font-weight: 600; color: var(--c-text); }
.trainer-gruppen { font-size: 0.75rem; color: var(--c-text-hint); }
.btn-remove {
padding: 0.3rem 0.65rem; background: none;
border: 1px solid var(--c-error-light); border-radius: 6px;
font-size: 0.78rem; color: var(--c-error); cursor: pointer; flex-shrink: 0;
}
.btn-einladen {
width: 100%; padding: 0.65rem; background: none;
border: 1.5px dashed var(--c-border); border-radius: 8px;
font-size: 0.9rem; color: var(--c-primary); cursor: pointer;
transition: border-color 0.15s;
}
.btn-einladen:hover { border-color: var(--c-primary); }
.invite-box {
margin-top: 0.75rem; padding: 0.85rem;
background: var(--c-bg-subtle); border: 1px solid var(--c-border); border-radius: 8px;
display: flex; flex-direction: column; gap: 0.5rem;
}
.invite-label { font-size: 0.78rem; color: var(--c-text-muted); margin: 0; }
.invite-url {
font-size: 0.78rem; font-family: monospace;
color: var(--c-text); word-break: break-all;
}
.btn-kopieren {
padding: 0.4rem 0.75rem; background: var(--c-primary); color: var(--c-bg-card);
border: none; border-radius: 6px; font-size: 0.82rem;
cursor: pointer; align-self: flex-start;
}
.btn-importexport {
display: block; width: 100%; padding: 0.75rem;
background: none; border: 1.5px solid var(--c-border); border-radius: 8px;
font-size: 0.95rem; color: var(--c-primary); text-align: center;
text-decoration: none; margin-bottom: 0;
}
.btn-importexport:hover { border-color: var(--c-primary); background: var(--c-primary-bg); }
.btn-logout {
width: 100%;
padding: 0.75rem;
background: none;
border: 1.5px solid var(--c-border);
border-radius: 8px;
font-size: 0.95rem;
color: var(--c-text-muted);
cursor: pointer;
margin-bottom: 5rem;
}
.btn-logout:hover { border-color: var(--c-text-hint); color: var(--c-text); }
</style>

View file

@ -0,0 +1,429 @@
<script lang="ts">
import { api } from '$lib/api';
import { onMount } from 'svelte';
import Papa from 'papaparse';
import type { Mitglied } from '$lib/types';
let loading = $state(true);
let mitglieder = $state<Mitglied[]>([]);
let vereinName = $state('');
// Export
let exportStatus = $state('');
// Import
let csvHeaders = $state<string[]>([]);
let csvRows = $state<Record<string, string>[]>([]);
let feldMapping = $state<Record<string, string>>({});
let importPhase = $state<'idle' | 'mapping' | 'preview' | 'done'>('idle');
let importResult = $state<{ ok: number; fehler: string[] }>({ ok: 0, fehler: [] });
let importLaeuft = $state(false);
// Mögliche Zielfelder
const zielfelder: { key: string; label: string }[] = [
{ key: '', label: '— ignorieren —' },
{ key: 'vorname', label: 'Vorname' },
{ key: 'nachname', label: 'Nachname' },
{ key: 'email', label: 'E-Mail' },
{ key: 'telefon', label: 'Telefon' },
{ key: 'geburtsdatum', label: 'Geburtsdatum (YYYY-MM-DD)' },
{ key: 'eintrittsdatum',label: 'Eintrittsdatum (YYYY-MM-DD)' },
{ key: 'austrittsdatum',label: 'Austrittsdatum (YYYY-MM-DD)' },
{ key: 'strasse', label: 'Straße' },
{ key: 'plz', label: 'PLZ' },
{ key: 'ort', label: 'Ort' },
{ key: 'iban', label: 'IBAN' },
{ key: 'bic', label: 'BIC' },
{ key: 'status', label: 'Status (aktiv/passiv/ausgetreten)' },
{ key: 'notizen', label: 'Notizen' },
];
onMount(async () => {
const [m, v] = await Promise.all([
api.get<Mitglied[]>('/mitglieder', { sort: 'nachname,vorname' }),
api.get<any>('/vereine'),
]);
mitglieder = m;
vereinName = v.name ?? '';
loading = false;
});
// ── EXPORT ──────────────────────────────────────────────────────────────
function toCSV(rows: Record<string, any>[], fields: string[]): string {
const escape = (v: any) => {
const s = String(v ?? '');
return s.includes(',') || s.includes('"') || s.includes('\n')
? `"${s.replace(/"/g, '""')}"` : s;
};
const header = fields.join(',');
const body = rows.map(r => fields.map(f => escape(r[f])).join(','));
return '' + [header, ...body].join('\r\n'); // BOM für Excel
}
function downloadCSV(csv: string, name: string) {
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = name; a.click();
URL.revokeObjectURL(url);
}
function exportiereAlle() {
const felder = ['vorname','nachname','email','telefon','geburtsdatum',
'eintrittsdatum','austrittsdatum','strasse','plz','ort',
'iban','bic','status','notizen'];
downloadCSV(toCSV(mitglieder, felder), `${vereinName}-Mitglieder.csv`);
exportStatus = `${mitglieder.length} Mitglieder exportiert.`;
}
function exportiereAktive() {
const aktive = mitglieder.filter(m => m.status === 'aktiv');
const felder = ['vorname','nachname','email','telefon','strasse','plz','ort','status'];
downloadCSV(toCSV(aktive, felder), `${vereinName}-Aktive.csv`);
exportStatus = `${aktive.length} aktive Mitglieder exportiert.`;
}
function exportiereMitIBAN() {
const sepa = mitglieder.filter(m => m.iban?.trim());
const felder = ['vorname','nachname','iban','bic','mandatsreferenz','mandatsdatum','status'];
downloadCSV(toCSV(sepa, felder), `${vereinName}-SEPA.csv`);
exportStatus = `${sepa.length} Mitglieder mit IBAN exportiert.`;
}
async function exportiereBackup() {
const [verein, mitgl, gruppen, termine, beitraege, nachrichten] = await Promise.all([
api.get<any>('/vereine'),
api.get<any[]>('/mitglieder'),
api.get<any[]>('/gruppen'),
api.get<any[]>('/termine'),
api.get<any[]>('/beitraege'),
api.get<any[]>('/nachrichten'),
]);
const backup = {
exportiert_am: new Date().toISOString(),
version: '1',
verein, mitglieder: mitgl, gruppen, termine, beitraege, nachrichten,
};
const blob = new Blob([JSON.stringify(backup, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = `${vereinName}-Backup-${new Date().toISOString().slice(0,10)}.json`;
a.click(); URL.revokeObjectURL(url);
exportStatus = 'Datensicherung heruntergeladen.';
}
// ── IMPORT ──────────────────────────────────────────────────────────────
// Versuche automatisch Spalten zu Feldern zuzuordnen
function automap(headers: string[]): Record<string, string> {
const normalize = (s: string) => s.toLowerCase().replace(/[^a-z]/g, '');
const aliase: Record<string, string> = {
vorname: 'vorname', firstname: 'vorname', givenname: 'vorname',
nachname: 'nachname', lastname: 'nachname', surname: 'nachname', name: 'nachname',
email: 'email', mail: 'email', emailadresse: 'email',
telefon: 'telefon', phone: 'telefon', tel: 'telefon', mobil: 'telefon',
geburtsdatum: 'geburtsdatum', geburtstag: 'geburtsdatum', birthdate: 'geburtsdatum',
eintrittsdatum: 'eintrittsdatum', eintritt: 'eintrittsdatum', mitgliedseit: 'eintrittsdatum',
strasse: 'strasse', street: 'strasse', adresse: 'strasse',
plz: 'plz', postleitzahl: 'plz', zip: 'plz',
ort: 'ort', city: 'ort', stadt: 'ort',
iban: 'iban', bic: 'bic', swift: 'bic',
status: 'status', mitgliedsstatus: 'status',
notizen: 'notizen', notes: 'notizen', bemerkung: 'notizen', bemerkungen: 'notizen',
};
const mapping: Record<string, string> = {};
for (const h of headers) {
mapping[h] = aliase[normalize(h)] ?? '';
}
return mapping;
}
function handleDateiUpload(e: Event) {
const file = (e.target as HTMLInputElement).files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (ev) => {
const text = ev.target?.result as string;
const result = Papa.parse<Record<string, string>>(text, {
header: true, skipEmptyLines: true,
});
csvHeaders = (result as any).meta?.fields ?? [];
csvRows = (result as any).data ?? [];
feldMapping = automap(csvHeaders);
importPhase = 'mapping';
};
reader.readAsText(file, 'UTF-8');
}
function normalisiereStatus(s: string): Mitglied['status'] {
const v = s.toLowerCase().trim();
if (v === 'passiv' || v === 'passive') return 'passiv';
if (v === 'ausgetreten' || v === 'ex' || v === 'former') return 'ausgetreten';
return 'aktiv';
}
function normalisiereDate(s: string): string | null {
if (!s?.trim()) return null;
// DD.MM.YYYY → YYYY-MM-DD
const de = s.match(/^(\d{1,2})\.(\d{1,2})\.(\d{4})$/);
if (de) return `${de[3]}-${de[2].padStart(2,'0')}-${de[1].padStart(2,'0')}`;
// already YYYY-MM-DD
if (/^\d{4}-\d{2}-\d{2}/.test(s)) return s.slice(0, 10);
return null;
}
async function importStarten() {
importLaeuft = true;
let ok = 0;
const fehler: string[] = [];
for (const row of csvRows) {
const record: Record<string, any> = { status: 'aktiv' };
for (const [csvSpalte, ziel] of Object.entries(feldMapping)) {
if (!ziel) continue;
const wert = row[csvSpalte]?.trim() ?? '';
if (['geburtsdatum','eintrittsdatum','austrittsdatum','mandatsdatum'].includes(ziel)) {
const d = normalisiereDate(wert);
if (d) record[ziel] = d;
} else if (ziel === 'status') {
record[ziel] = normalisiereStatus(wert);
} else if (wert) {
record[ziel] = wert;
}
}
if (!record.vorname || !record.nachname) {
fehler.push(`Zeile übersprungen: Vor- oder Nachname fehlt (${row[csvHeaders[0]] ?? '?'})`);
continue;
}
try {
await api.post('/mitglieder', record);
ok++;
} catch (e: unknown) {
fehler.push(`${record.vorname} ${record.nachname}: ${e instanceof Error ? e.message : 'Fehler'}`);
}
}
// Mitgliederliste aktualisieren
mitglieder = await api.get<Mitglied[]>('/mitglieder', { sort: 'nachname,vorname' });
importResult = { ok, fehler };
importPhase = 'done';
importLaeuft = false;
}
function resetImport() {
csvHeaders = []; csvRows = []; feldMapping = {};
importPhase = 'idle'; importResult = { ok: 0, fehler: [] };
}
</script>
<svelte:head><title>Import / Export — vereins.haus</title></svelte:head>
<div class="top">
<a class="back" href="/einstellungen">← Einstellungen</a>
<h1>Import / Export</h1>
</div>
{#if loading}
<p class="hint">Laden…</p>
{:else}
<!-- ── EXPORT ────────────────────────────────────── -->
<section>
<h2>Mitglieder exportieren</h2>
<div class="export-grid">
<button class="export-btn" onclick={exportiereAlle}>
<span class="export-icon">📋</span>
<span class="export-label">Alle Mitglieder</span>
<span class="export-sub">CSV · alle Felder · {mitglieder.length} Einträge</span>
</button>
<button class="export-btn" onclick={exportiereAktive}>
<span class="export-icon"></span>
<span class="export-label">Aktive Mitglieder</span>
<span class="export-sub">CSV · Kontaktfelder · {mitglieder.filter(m => m.status === 'aktiv').length} Einträge</span>
</button>
<button class="export-btn" onclick={exportiereMitIBAN}>
<span class="export-icon"></span>
<span class="export-label">SEPA-Liste</span>
<span class="export-sub">CSV · IBAN/Mandat · {mitglieder.filter(m => m.iban?.trim()).length} Einträge</span>
</button>
</div>
{#if exportStatus}
<p class="status-ok">{exportStatus}</p>
{/if}
</section>
<section>
<h2>Datensicherung</h2>
<p class="hinweis-text">Vollständige Sicherungskopie aller Vereinsdaten als JSON für Archivierung oder Wechsel der Software (DSGVO Art. 20).</p>
<button class="export-btn breit" onclick={exportiereBackup}>
<span class="export-icon">💾</span>
<span class="export-label">Datensicherung herunterladen</span>
<span class="export-sub">JSON · Mitglieder, Termine, Beiträge, Nachrichten</span>
</button>
</section>
<!-- ── IMPORT ────────────────────────────────────── -->
<section>
<h2>Mitglieder importieren</h2>
<p class="hinweis-text">CSV-Datei hochladen die Spalten werden automatisch erkannt und können vor dem Import angepasst werden.</p>
{#if importPhase === 'idle'}
<label class="upload-label">
<input type="file" accept=".csv,.txt" onchange={handleDateiUpload} class="file-input" />
<span class="upload-icon"></span>
<span>CSV-Datei auswählen</span>
<span class="upload-sub">UTF-8 oder Windows-1252, Komma- oder Semikolon-getrennt</span>
</label>
{:else if importPhase === 'mapping'}
<div class="mapping-header">
<span>{csvRows.length} Zeilen erkannt · Spalten zuordnen:</span>
<button class="btn-ghost-sm" onclick={resetImport}>Abbrechen</button>
</div>
<div class="mapping-tabelle">
<div class="mapping-row header">
<span>CSV-Spalte</span>
<span>→ Vereinshaus-Feld</span>
<span>Vorschau (1. Zeile)</span>
</div>
{#each csvHeaders as h}
<div class="mapping-row">
<span class="col-name">{h}</span>
<select bind:value={feldMapping[h]}>
{#each zielfelder as f}
<option value={f.key}>{f.label}</option>
{/each}
</select>
<span class="col-preview">{csvRows[0]?.[h] ?? ''}</span>
</div>
{/each}
</div>
<div class="mapping-actions">
<p class="hinweis-text">Vor- und Nachname sind Pflichtfelder.</p>
<button class="btn-primary" onclick={importStarten} disabled={importLaeuft}>
{importLaeuft ? `Importiere…` : `${csvRows.length} Mitglieder importieren`}
</button>
</div>
{:else if importPhase === 'done'}
<div class="import-result" class:hat-fehler={importResult.fehler.length > 0}>
<p class="result-ok">{importResult.ok} Mitglieder importiert</p>
{#if importResult.fehler.length > 0}
<p class="result-fehler-titel">{importResult.fehler.length} Probleme:</p>
<ul class="fehler-liste">
{#each importResult.fehler as f}
<li>{f}</li>
{/each}
</ul>
{/if}
<button class="btn-ghost" onclick={resetImport}>Weiteren Import starten</button>
</div>
{/if}
</section>
{/if}
<style>
.top { margin-bottom: 1.25rem; }
.back { font-size: 0.85rem; color: var(--c-primary); text-decoration: none; display: block; margin-bottom: 0.25rem; }
h1 { font-size: 1.4rem; font-weight: 700; color: var(--c-text); }
.hint { color: var(--c-text-hint); text-align: center; margin-top: 3rem; }
section {
margin-bottom: 2rem;
padding-bottom: 1.5rem;
border-bottom: 1px solid var(--c-bg);
}
h2 {
font-size: 0.72rem; font-weight: 700; color: var(--c-text-hint);
text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 0.75rem;
}
.hinweis-text { font-size: 0.85rem; color: var(--c-text-muted); margin-bottom: 0.85rem; line-height: 1.5; }
/* Export */
.export-grid { display: flex; flex-direction: column; gap: 0.5rem; margin-bottom: 0.75rem; }
.export-btn {
display: flex; align-items: center; gap: 0.85rem;
padding: 0.85rem 1rem; background: var(--c-bg-card);
border: 1.5px solid var(--c-border); border-radius: 10px;
cursor: pointer; text-align: left; width: 100%;
transition: border-color 0.15s;
}
.export-btn:hover { border-color: var(--c-primary); }
.export-btn.breit { width: 100%; }
.export-icon { font-size: 1.2rem; flex-shrink: 0; width: 1.5rem; text-align: center; }
.export-label { font-weight: 600; font-size: 0.9rem; color: var(--c-text); flex: 1; }
.export-sub { font-size: 0.75rem; color: var(--c-text-hint); white-space: nowrap; }
.status-ok { font-size: 0.85rem; color: var(--c-success); }
/* Upload */
.upload-label {
display: flex; flex-direction: column; align-items: center; gap: 0.4rem;
padding: 2rem 1rem; border: 2px dashed var(--c-border); border-radius: 10px;
cursor: pointer; color: var(--c-text-muted); transition: border-color 0.15s; text-align: center;
}
.upload-label:hover { border-color: var(--c-primary); color: var(--c-primary); }
.file-input { display: none; }
.upload-icon { font-size: 1.5rem; }
.upload-sub { font-size: 0.75rem; color: var(--c-text-hint); }
/* Mapping */
.mapping-header {
display: flex; justify-content: space-between; align-items: center;
margin-bottom: 0.75rem; font-size: 0.85rem; color: var(--c-text-muted);
}
.btn-ghost-sm {
padding: 0.3rem 0.65rem; background: none;
border: 1px solid var(--c-border); border-radius: 6px;
font-size: 0.8rem; color: var(--c-text-muted); cursor: pointer;
}
.mapping-tabelle {
border: 1px solid var(--c-border); border-radius: 8px; overflow: hidden;
margin-bottom: 1rem; overflow-x: auto;
}
.mapping-row {
display: grid; grid-template-columns: 1fr 1.4fr 1fr;
gap: 0.5rem; padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--c-bg); align-items: center;
}
.mapping-row:last-child { border-bottom: none; }
.mapping-row.header { background: var(--c-bg-subtle); font-size: 0.72rem; font-weight: 700; color: var(--c-text-hint); text-transform: uppercase; }
.col-name { font-size: 0.82rem; color: var(--c-text); font-weight: 500; }
.col-preview { font-size: 0.75rem; color: var(--c-text-hint); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.mapping-row select {
padding: 0.3rem 0.5rem; border: 1px solid var(--c-border); border-radius: 6px;
font-size: 0.8rem; background: var(--c-bg-card); width: 100%;
}
.mapping-actions { display: flex; flex-direction: column; gap: 0.5rem; }
/* Ergebnis */
.import-result {
background: var(--c-success-subtle); border: 1px solid var(--c-success-light); border-radius: 10px;
padding: 1rem; display: flex; flex-direction: column; gap: 0.5rem;
}
.import-result.hat-fehler { background: var(--c-warning-pale); border-color: var(--c-warning-light); }
.result-ok { font-weight: 700; color: var(--c-success); font-size: 0.95rem; }
.result-fehler-titel { font-size: 0.85rem; color: var(--c-warning-dark); font-weight: 600; }
.fehler-liste { margin: 0; padding-left: 1.2rem; }
.fehler-liste li { font-size: 0.78rem; color: var(--c-warning-dark); }
.btn-primary {
padding: 0.75rem; background: var(--c-primary); color: var(--c-bg-card);
border: none; border-radius: 8px; font-size: 0.95rem; font-weight: 600;
cursor: pointer; transition: background 0.15s; width: 100%;
}
.btn-primary:hover:not(:disabled) { background: var(--c-primary-dark); }
.btn-primary:disabled { opacity: 0.55; cursor: not-allowed; }
.btn-ghost {
padding: 0.6rem 1rem; background: none;
border: 1.5px solid var(--c-border); border-radius: 8px;
font-size: 0.9rem; color: var(--c-text-muted); cursor: pointer; align-self: flex-start;
}
</style>

View file

@ -1,27 +1,174 @@
<script lang="ts">
import { api } from '$lib/api';
import { onMount } from 'svelte';
let mitglieder = $state<any[]>([]);
let gruppen = $state<any[]>([]);
let search = $state('');
let loading = $state(true);
onMount(async () => {
[mitglieder, gruppen] = await Promise.all([
api.get<any[]>('/mitglieder', { sort: 'nachname,vorname' }),
api.get<any[]>('/gruppen', { sort: 'name' })
]);
loading = false;
});
let filtered = $derived(
search.trim()
? mitglieder.filter(m =>
`${m.vorname} ${m.nachname} ${m.email}`.toLowerCase().includes(search.toLowerCase())
)
: mitglieder
);
function gruppenLabel(ids: string[]) {
return (ids ?? [])
.map(id => gruppen.find(g => g.id === id)?.name)
.filter(Boolean)
.join(', ');
}
const statusFarbe: Record<string, string> = {
aktiv: 'var(--c-success)',
passiv: 'var(--c-warning)',
ausgetreten: 'var(--c-text-hint)'
};
</script>
<svelte:head><title>Mitglieder — vereins.haus</title></svelte:head>
<div class="page-header">
<div class="top">
<h1>Mitglieder</h1>
<button class="btn-primary">+ Mitglied</button>
<span class="count">{mitglieder.length}</span>
</div>
<p class="placeholder">Mitgliederverwaltung — in Entwicklung</p>
<input
class="search"
type="search"
bind:value={search}
placeholder="Name oder E-Mail suchen…"
/>
{#if loading}
<p class="hint">Laden…</p>
{:else if filtered.length === 0}
<p class="hint">
{search ? 'Keine Treffer.' : 'Noch keine Mitglieder — lege das erste an!'}
</p>
{:else}
<ul>
{#each filtered as m (m.id)}
<li>
<a href="/mitglieder/{m.id}">
<div class="avatar">{m.vorname[0]}{m.nachname[0]}</div>
<div class="info">
<span class="name">{m.vorname} {m.nachname}</span>
<span class="meta">
{[m.email, gruppenLabel(m.gruppe_ids)].filter(Boolean).join(' · ')}
</span>
</div>
<span class="badge" style="color:{statusFarbe[m.status] ?? 'var(--c-text-hint)'}">
{m.status}
</span>
</a>
</li>
{/each}
</ul>
{/if}
<a class="fab" href="/mitglieder/neu" aria-label="Neues Mitglied">+</a>
<style>
.page-header {
.top {
display: flex;
align-items: baseline;
gap: 0.5rem;
margin-bottom: 1rem;
}
h1 { font-size: 1.4rem; font-weight: 700; color: var(--c-text); }
.count { font-size: 1rem; color: var(--c-text-hint); }
.search {
width: 100%;
padding: 0.6rem 0.85rem;
border: 1.5px solid var(--c-border);
border-radius: 8px;
font-size: 1rem;
margin-bottom: 1rem;
background: var(--c-bg-subtle);
box-sizing: border-box;
}
.search:focus { outline: none; border-color: var(--c-primary); background: var(--c-bg-card); }
ul {
list-style: none;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 5rem;
}
a {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1.25rem;
gap: 0.75rem;
padding: 0.85rem;
background: var(--c-bg-card);
border: 1px solid var(--c-border);
border-radius: 10px;
text-decoration: none;
color: inherit;
transition: border-color 0.15s;
}
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;
a:hover { border-color: var(--c-primary); }
.avatar {
width: 2.4rem;
height: 2.4rem;
border-radius: 50%;
background: var(--c-primary-light);
color: var(--c-primary);
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 0.85rem;
flex-shrink: 0;
text-transform: uppercase;
}
.placeholder { color: #94a3b8; font-size: 0.95rem; }
.info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.1rem;
}
.name { font-weight: 600; font-size: 0.95rem; color: var(--c-text); }
.meta {
font-size: 0.78rem;
color: var(--c-text-hint);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.badge { font-size: 0.72rem; font-weight: 600; text-transform: capitalize; flex-shrink: 0; }
.hint { color: var(--c-text-hint); text-align: center; margin-top: 3rem; font-size: 0.95rem; }
.fab {
position: fixed;
bottom: calc(60px + env(safe-area-inset-bottom) + 1.25rem);
right: 1.25rem;
width: 3.5rem;
height: 3.5rem;
background: var(--c-primary);
color: var(--c-bg-card);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 2rem;
line-height: 1;
text-decoration: none;
box-shadow: 0 4px 14px rgba(30, 64, 175, 0.4);
transition: background 0.15s;
}
.fab:hover { background: var(--c-primary-dark); }
</style>

View file

@ -0,0 +1,503 @@
<script lang="ts">
import { api } from '$lib/api';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { onMount } from 'svelte';
import type { Mitglied, Gruppe } from '$lib/types';
const id = $derived($page.params.id as string);
let gruppen = $state<Gruppe[]>([]);
let loading = $state(true);
let saving = $state(false);
let error = $state('');
let editMode = $state(false);
let showDelete = $state(false);
// Felder
let vorname = $state('');
let nachname = $state('');
let email = $state('');
let telefon = $state('');
let geburtsdatum = $state('');
let status = $state('aktiv');
let eintrittsdatum = $state('');
let austrittsdatum = $state('');
let strasse = $state('');
let plz = $state('');
let ort = $state('');
let iban = $state('');
let bic = $state('');
let mandatsreferenz = $state('');
let mandatsdatum = $state('');
let notizen = $state('');
let gruppe_ids = $state<string[]>([]);
function pbDateToInput(val: string | undefined): string {
// PocketBase gibt "2026-05-20 00:00:00.000Z" zurück, input[type=date] braucht "2026-05-20"
if (!val) return '';
return val.slice(0, 10);
}
function loadRecord(m: Mitglied) {
vorname = m.vorname;
nachname = m.nachname;
email = m.email ?? '';
telefon = m.telefon ?? '';
geburtsdatum = pbDateToInput(m.geburtsdatum);
status = m.status;
eintrittsdatum = pbDateToInput(m.eintrittsdatum);
austrittsdatum = pbDateToInput(m.austrittsdatum);
strasse = m.strasse ?? '';
plz = m.plz ?? '';
ort = m.ort ?? '';
iban = m.iban ?? '';
bic = m.bic ?? '';
mandatsreferenz = m.mandatsreferenz ?? '';
mandatsdatum = pbDateToInput(m.mandatsdatum);
notizen = m.notizen ?? '';
gruppe_ids = m.gruppe_ids ?? [];
}
onMount(async () => {
const [m, g] = await Promise.all([
api.get<Mitglied>('/mitglieder/' + id),
api.get<Gruppe[]>('/gruppen', { sort: 'name' }),
]);
loadRecord(m);
gruppen = g;
loading = false;
});
function toggleGruppe(gid: string) {
gruppe_ids = gruppe_ids.includes(gid)
? gruppe_ids.filter(g => g !== gid)
: [...gruppe_ids, gid];
}
async function speichern() {
error = ''; saving = true;
try {
await api.put('/mitglieder/' + id, {
vorname: vorname.trim(),
nachname: nachname.trim(),
email: email.trim() || null,
telefon: telefon.trim() || null,
geburtsdatum: geburtsdatum || null,
status,
eintrittsdatum: eintrittsdatum || null,
austrittsdatum: austrittsdatum || 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,
});
editMode = false;
} catch (e: unknown) {
error = e instanceof Error ? e.message : 'Fehler beim Speichern.';
} finally {
saving = false;
}
}
async function loeschen() {
try {
await api.del('/mitglieder/' + id);
goto('/mitglieder');
} catch (e: unknown) {
error = e instanceof Error ? e.message : 'Fehler beim Löschen.';
showDelete = false;
}
}
function gruppenName(ids: string[]): string {
return (ids ?? [])
.map(gid => gruppen.find(g => g.id === gid)?.name)
.filter(Boolean)
.join(', ');
}
function formatDatum(iso: string): string {
if (!iso) return '—';
return new Date(iso).toLocaleDateString('de-DE');
}
const statusFarbe: Record<string, string> = {
aktiv: 'var(--c-success)', passiv: 'var(--c-warning)', ausgetreten: 'var(--c-text-hint)',
};
</script>
<svelte:head><title>{vorname} {nachname} — vereins.haus</title></svelte:head>
<div class="top">
<a class="back" href="/mitglieder">← Mitglieder</a>
{#if !loading}
<button class="edit-btn" onclick={() => { editMode = !editMode; error = ''; }}>
{editMode ? 'Abbrechen' : 'Bearbeiten'}
</button>
{/if}
</div>
{#if loading}
<p class="hint">Laden…</p>
{:else if !editMode}
<!-- Detailansicht -->
<div class="hero">
<div class="avatar-lg">{vorname[0]}{nachname[0]}</div>
<h1>{vorname} {nachname}</h1>
<span class="status-badge" style="color:{statusFarbe[status] ?? 'var(--c-text-hint)'}">{status}</span>
</div>
<div class="detail-block">
<h2>Kontakt</h2>
{#if email}
<div class="row-detail">
<span class="dl">E-Mail</span>
<a href="mailto:{email}" class="dv link">{email}</a>
</div>
{/if}
{#if telefon}
<div class="row-detail">
<span class="dl">Telefon</span>
<a href="tel:{telefon}" class="dv link">{telefon}</a>
</div>
{/if}
{#if strasse || plz || ort}
<div class="row-detail">
<span class="dl">Adresse</span>
<span class="dv">{[strasse, [plz, ort].filter(Boolean).join(' ')].filter(Boolean).join(', ')}</span>
</div>
{/if}
{#if !email && !telefon && !strasse}
<p class="leer">Keine Kontaktdaten hinterlegt.</p>
{/if}
</div>
<div class="detail-block">
<h2>Mitgliedschaft</h2>
{#if eintrittsdatum}
<div class="row-detail">
<span class="dl">Eintritt</span>
<span class="dv">{formatDatum(eintrittsdatum)}</span>
</div>
{/if}
{#if geburtsdatum}
<div class="row-detail">
<span class="dl">Geburtsdatum</span>
<span class="dv">{formatDatum(geburtsdatum)}</span>
</div>
{/if}
{#if austrittsdatum}
<div class="row-detail">
<span class="dl">Austritt</span>
<span class="dv">{formatDatum(austrittsdatum)}</span>
</div>
{/if}
{#if gruppe_ids?.length}
<div class="row-detail">
<span class="dl">Gruppen</span>
<span class="dv">{gruppenName(gruppe_ids)}</span>
</div>
{/if}
</div>
{#if iban || bic || mandatsreferenz}
<div class="detail-block">
<h2>SEPA-Lastschrift</h2>
{#if iban}
<div class="row-detail">
<span class="dl">IBAN</span>
<span class="dv mono">{iban}</span>
</div>
{/if}
{#if bic}
<div class="row-detail">
<span class="dl">BIC</span>
<span class="dv mono">{bic}</span>
</div>
{/if}
{#if mandatsreferenz}
<div class="row-detail">
<span class="dl">Mandat</span>
<span class="dv mono">{mandatsreferenz}{mandatsdatum ? ' · ' + formatDatum(mandatsdatum) : ''}</span>
</div>
{/if}
</div>
{/if}
{#if notizen}
<div class="detail-block">
<h2>Notizen</h2>
<p class="notiz-text">{notizen}</p>
</div>
{/if}
<button class="btn-delete" onclick={() => showDelete = true}>Mitglied löschen</button>
{:else}
<!-- Bearbeitungsformular -->
<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 />
</div>
<div class="field">
<label for="nachname">Nachname *</label>
<input id="nachname" type="text" bind:value={nachname} required />
</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 class="field">
<label for="austrittsdatum">Austrittsdatum</label>
<input id="austrittsdatum" type="date" bind:value={austrittsdatum} />
</div>
</div>
</section>
<section>
<h2>Kontakt</h2>
<div class="field">
<label for="email">E-Mail</label>
<input id="email" type="email" bind:value={email} />
</div>
<div class="field">
<label for="telefon">Telefon</label>
<input id="telefon" type="tel" bind:value={telefon} />
</div>
<div class="field">
<label for="strasse">Straße & Hausnummer</label>
<input id="strasse" type="text" bind:value={strasse} />
</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} />
</div>
<div class="field">
<label for="ort">Ort</label>
<input id="ort" type="text" bind:value={ort} />
</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 …" />
</div>
<div class="field">
<label for="bic">BIC</label>
<input id="bic" type="text" bind:value={bic} placeholder="COBADEFFXXX" />
</div>
<div class="row">
<div class="field">
<label for="mandatsreferenz">Mandatsreferenz</label>
<input id="mandatsreferenz" type="text" bind:value={mandatsreferenz} placeholder="automatisch" />
</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"></textarea>
</div>
</section>
{#if error}
<p class="error">{error}</p>
{/if}
<div class="actions" style="margin-bottom:5rem">
<button type="submit" class="btn-primary" disabled={saving || !vorname || !nachname}>
{saving ? 'Speichern…' : 'Änderungen speichern'}
</button>
</div>
</form>
{/if}
<!-- Lösch-Dialog -->
{#if showDelete}
<div class="overlay" role="dialog" aria-modal="true">
<div class="dialog">
<p><strong>{vorname} {nachname}</strong> wirklich löschen?</p>
<p class="dialog-sub">Diese Aktion kann nicht rückgängig gemacht werden.</p>
<div class="dialog-actions">
<button class="btn-ghost" onclick={() => showDelete = false}>Abbrechen</button>
<button class="btn-danger" onclick={loeschen}>Löschen</button>
</div>
</div>
</div>
{/if}
<style>
.top { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.5rem; }
.back { font-size: 0.9rem; color: var(--c-primary); text-decoration: none; }
.edit-btn {
background: none; border: 1.5px solid var(--c-border); border-radius: 8px;
padding: 0.4rem 0.85rem; font-size: 0.875rem; color: var(--c-text-secondary); cursor: pointer;
}
h1 { font-size: 1.3rem; font-weight: 700; color: var(--c-text); }
.hint { color: var(--c-text-hint); text-align: center; margin-top: 3rem; }
.hero {
display: flex; flex-direction: column; align-items: center; gap: 0.4rem;
padding: 1.5rem 1rem; background: var(--c-bg-card);
border: 1px solid var(--c-border); border-radius: 12px; margin-bottom: 1rem;
}
.avatar-lg {
width: 4rem; height: 4rem; border-radius: 50%;
background: var(--c-primary-light); color: var(--c-primary);
display: flex; align-items: center; justify-content: center;
font-weight: 700; font-size: 1.3rem; text-transform: uppercase;
}
.status-badge { font-size: 0.8rem; font-weight: 600; text-transform: capitalize; }
.detail-block {
background: var(--c-bg-card); border: 1px solid var(--c-border); border-radius: 12px;
overflow: hidden; margin-bottom: 0.75rem; padding: 0.75rem 1rem;
}
.detail-block h2 {
font-size: 0.72rem; font-weight: 700; color: var(--c-text-hint);
text-transform: uppercase; letter-spacing: 0.06em;
margin-bottom: 0.6rem;
}
.row-detail {
display: flex; justify-content: space-between; align-items: baseline;
gap: 1rem; padding: 0.45rem 0;
border-bottom: 1px solid var(--c-bg);
}
.row-detail:last-child { border-bottom: none; }
.dl { font-size: 0.82rem; color: var(--c-text-hint); flex-shrink: 0; }
.dv { font-size: 0.88rem; color: var(--c-text); text-align: right; }
.dv.link { color: var(--c-primary); text-decoration: none; }
.dv.mono { font-family: monospace; font-size: 0.82rem; }
.leer { font-size: 0.85rem; color: var(--c-text-hint); }
.notiz-text { font-size: 0.875rem; color: var(--c-text-secondary); white-space: pre-wrap; }
.btn-delete {
width: 100%; padding: 0.75rem; background: none;
border: 1.5px solid var(--c-error-light); border-radius: 8px;
color: var(--c-error); font-size: 0.9rem; cursor: pointer;
transition: background 0.15s; margin-bottom: 5rem;
}
.btn-delete:hover { background: var(--c-error-subtle); }
/* Formular */
section {
margin-bottom: 1.5rem; padding-bottom: 1.5rem;
border-bottom: 1px solid var(--c-bg);
}
section:last-of-type { border-bottom: none; }
section h2 {
font-size: 0.72rem; font-weight: 700; color: var(--c-text-hint);
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: var(--c-text-secondary); }
input, select, textarea {
padding: 0.65rem 0.85rem;
border: 1.5px solid var(--c-border); border-radius: 8px;
font-size: 1rem; background: var(--c-bg-card); 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: var(--c-primary); }
.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 var(--c-border); border-radius: 20px;
font-size: 0.875rem; cursor: pointer;
transition: border-color 0.15s, background 0.15s;
}
.check-label.active { border-color: var(--c-primary); background: var(--c-primary-light); color: var(--c-primary); }
.check-label input { display: none; }
.error { color: var(--c-error); font-size: 0.875rem; margin-bottom: 0.75rem; }
.actions { display: flex; gap: 0.75rem; margin-top: 1rem; }
.btn-primary {
flex: 1; padding: 0.75rem; background: var(--c-primary); color: var(--c-bg-card);
border: none; border-radius: 8px; font-size: 1rem; font-weight: 600;
cursor: pointer; transition: background 0.15s;
}
.btn-primary:hover:not(:disabled) { background: var(--c-primary-dark); }
.btn-primary:disabled { opacity: 0.55; cursor: not-allowed; }
.btn-ghost {
padding: 0.75rem 1rem; background: none;
border: 1.5px solid var(--c-border); border-radius: 8px;
font-size: 1rem; color: var(--c-text-muted); cursor: pointer;
}
.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));
}
.dialog {
background: var(--c-bg-card); border-radius: 16px; padding: 1.5rem;
width: 100%; max-width: 400px;
}
.dialog p { font-size: 1rem; color: var(--c-text); margin-bottom: 0.5rem; }
.dialog-sub { font-size: 0.875rem !important; color: var(--c-text-hint); }
.dialog-actions { display: flex; gap: 0.75rem; margin-top: 1.25rem; }
.btn-danger {
flex: 1; padding: 0.75rem; background: var(--c-error); color: var(--c-bg-card);
border: none; border-radius: 8px; font-size: 0.95rem; font-weight: 600;
cursor: pointer; transition: background 0.15s;
}
.btn-danger:hover { background: var(--c-error-dark); }
</style>

View file

@ -0,0 +1,257 @@
<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: var(--c-primary); text-decoration: none; display: block; margin-bottom: 0.5rem; }
h1 { font-size: 1.4rem; font-weight: 700; color: var(--c-text); }
section {
margin-bottom: 1.5rem;
padding-bottom: 1.5rem;
border-bottom: 1px solid var(--c-bg);
}
section:last-of-type { border-bottom: none; }
h2 { font-size: 0.8rem; font-weight: 700; color: var(--c-text-hint); 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: var(--c-text-secondary); }
input, select, textarea {
padding: 0.65rem 0.85rem;
border: 1.5px solid var(--c-border); border-radius: 8px;
font-size: 1rem; background: var(--c-bg-card); 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: var(--c-primary); }
.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 var(--c-border); border-radius: 20px;
font-size: 0.875rem; cursor: pointer;
transition: border-color 0.15s, background 0.15s;
}
.check-label.active { border-color: var(--c-primary); background: var(--c-primary-light); color: var(--c-primary); }
.check-label input { display: none; }
.error { color: var(--c-error); 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: var(--c-primary); color: var(--c-bg-card);
border: none; border-radius: 8px; font-size: 1rem; font-weight: 600;
cursor: pointer; transition: background 0.15s;
}
.btn-primary:hover:not(:disabled) { background: var(--c-primary-dark); }
.btn-primary:disabled { opacity: 0.55; cursor: not-allowed; }
.btn-ghost {
padding: 0.75rem 1rem; background: none;
border: 1.5px solid var(--c-border); border-radius: 8px;
font-size: 1rem; color: var(--c-text-muted); text-decoration: none; text-align: center;
}
</style>

View file

@ -1,27 +1,330 @@
<script lang="ts">
import { api } from '$lib/api';
import { user } from '$lib/user';
import { get } from 'svelte/store';
import { onMount } from 'svelte';
import type { Nachricht, Gruppe } from '$lib/types';
let nachrichten = $state<Nachricht[]>([]);
let gruppen = $state<Gruppe[]>([]);
let loading = $state(true);
// Formular
let showForm = $state(false);
let fBetreff = $state('');
let fText = $state('');
let fGruppeIds = $state<string[]>([]);
let sending = $state(false);
let sendError = $state('');
let sendSuccess = $state('');
onMount(async () => {
[nachrichten, gruppen] = await Promise.all([
api.get<Nachricht[]>('/nachrichten', { sort: '-gesendet_am' }),
api.get<Gruppe[]>('/gruppen', { sort: 'name' }),
]);
loading = false;
});
function stripHtml(html: string): string {
return html.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
}
function toggleGruppe(id: string) {
fGruppeIds = fGruppeIds.includes(id)
? fGruppeIds.filter((g) => g !== id)
: [...fGruppeIds, id];
}
function neueNachricht() {
fBetreff = ''; fText = ''; fGruppeIds = [];
sendError = ''; sendSuccess = ''; showForm = true;
}
async function senden() {
if (!fBetreff.trim() || !fText.trim()) {
sendError = 'Betreff und Nachricht sind Pflichtfelder.';
return;
}
sendError = ''; sending = true;
try {
const record = await api.post<Nachricht>('/nachrichten', {
betreff: fBetreff.trim(),
text: fText.trim(),
gruppe_ids: fGruppeIds,
gesendet_am: new Date().toISOString(),
});
nachrichten = [record, ...nachrichten];
showForm = false;
// Push-Benachrichtigung an alle abonnierten Geräte im Verein
fetch('/api/push/senden', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${get(user)?.token ?? ''}`,
},
body: JSON.stringify({
titel: fBetreff.trim(),
body: fText.trim().slice(0, 120),
url: '/nachrichten',
}),
}).catch(() => {});
sendSuccess = 'Nachricht wurde gespeichert und per E-Mail versendet.';
} catch (e: unknown) {
sendError = e instanceof Error ? e.message : 'Fehler beim Senden.';
} finally {
sending = false;
}
}
function formatDatum(iso: string | undefined): string {
if (!iso) return '';
return new Date(iso).toLocaleDateString('de-DE', {
day: '2-digit', month: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit',
});
}
const gruppenLabel = (ids: string[]) =>
ids.length === 0
? 'Alle Mitglieder'
: ids.map((id) => gruppen.find((g) => g.id === id)?.name ?? id).join(', ');
</script>
<svelte:head><title>Nachrichten — vereins.haus</title></svelte:head>
<div class="page-header">
<div class="top">
<h1>Nachrichten</h1>
<button class="btn-primary">+ Nachricht</button>
<button class="btn-primary" onclick={neueNachricht}>+ Nachricht</button>
</div>
<p class="placeholder">Nachrichten & Push-Benachrichtigungen — in Entwicklung</p>
{#if sendSuccess}
<div class="success">{sendSuccess}</div>
{/if}
{#if loading}
<p class="hint">Laden…</p>
{:else if nachrichten.length === 0}
<p class="hint">Noch keine Nachrichten versendet.</p>
{:else}
<ul class="liste">
{#each nachrichten as n (n.id)}
<li class="karte">
<div class="karte-header">
<span class="karte-betreff">{n.betreff}</span>
<span class="karte-datum">{formatDatum(n.gesendet_am)}</span>
</div>
<span class="karte-meta">{gruppenLabel(n.gruppe_ids ?? [])}</span>
{#if n.text}
<p class="karte-vorschau">{stripHtml(n.text).slice(0, 120)}{stripHtml(n.text).length > 120 ? '…' : ''}</p>
{/if}
</li>
{/each}
</ul>
{/if}
<!-- Nachricht verfassen -->
{#if showForm}
<div class="overlay" role="dialog" aria-modal="true">
<div class="sheet">
<h2>Neue Nachricht</h2>
<form onsubmit={(e) => { e.preventDefault(); senden(); }}>
<div class="field">
<label for="fbetreff">Betreff *</label>
<input id="fbetreff" type="text" bind:value={fBetreff} placeholder="Betreff" required />
</div>
<div class="field">
<label for="ftext">Nachricht *</label>
<textarea id="ftext" bind:value={fText} rows="6" placeholder="Text der Nachricht…" required></textarea>
</div>
{#if gruppen.length > 0}
<div class="field">
<span class="field-label">Empfänger</span>
<div class="checkboxes">
<label class="check-label alle" class:active={fGruppeIds.length === 0}>
<input
type="checkbox"
checked={fGruppeIds.length === 0}
onchange={() => (fGruppeIds = [])}
/>
Alle Mitglieder
</label>
{#each gruppen as g (g.id)}
<label class="check-label" class:active={fGruppeIds.includes(g.id)}>
<input
type="checkbox"
checked={fGruppeIds.includes(g.id)}
onchange={() => toggleGruppe(g.id)}
/>
{g.name}
</label>
{/each}
</div>
</div>
{/if}
<p class="versand-info">
Die Nachricht wird an alle aktiven Mitglieder
{fGruppeIds.length > 0 ? 'der gewählten Gruppen' : ''}
mit hinterlegter E-Mail-Adresse gesendet.
</p>
{#if sendError}
<p class="error">{sendError}</p>
{/if}
<div class="actions">
<button type="button" class="btn-ghost" onclick={() => (showForm = false)}>
Abbrechen
</button>
<button type="submit" class="btn-primary" disabled={sending}>
{sending ? 'Senden…' : 'Senden'}
</button>
</div>
</form>
</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;
.top .btn-primary { flex: none; padding: 0.45rem 0.9rem; font-size: 0.875rem; }
h1 { font-size: 1.4rem; font-weight: 700; color: var(--c-text); }
.success {
background: var(--c-success-bg);
border: 1px solid var(--c-success-light);
border-radius: 8px;
padding: 0.75rem 1rem;
font-size: 0.875rem;
color: var(--c-success-dark);
margin-bottom: 1rem;
}
.hint { color: var(--c-text-hint); 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 {
background: var(--c-bg-card);
border: 1px solid var(--c-border);
border-radius: 10px;
padding: 0.9rem 1rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.karte-header {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 0.5rem;
}
.karte-betreff { font-weight: 600; font-size: 0.95rem; color: var(--c-text); }
.karte-datum { font-size: 0.75rem; color: var(--c-text-hint); flex-shrink: 0; }
.karte-meta { font-size: 0.78rem; color: var(--c-text-muted); }
.karte-vorschau { font-size: 0.85rem; color: var(--c-text-secondary); margin: 0; }
/* 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: var(--c-bg-card);
border-radius: 16px;
padding: 1.5rem;
width: 100%;
max-width: 480px;
max-height: 92dvh;
overflow-y: auto;
}
h2 { font-size: 1.1rem; font-weight: 700; color: var(--c-text); margin-bottom: 1rem; }
.field { display: flex; flex-direction: column; gap: 0.3rem; margin-bottom: 0.9rem; }
label, .field-label { font-size: 0.875rem; font-weight: 500; color: var(--c-text-secondary); }
input, textarea {
padding: 0.65rem 0.85rem;
border: 1.5px solid var(--c-border);
border-radius: 8px;
font-size: 1rem;
background: var(--c-bg-card);
width: 100%;
box-sizing: border-box;
transition: border-color 0.15s;
font-family: inherit;
resize: vertical;
}
input:focus, textarea:focus { outline: none; border-color: var(--c-primary); }
.checkboxes { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-top: 0.25rem; }
.check-label {
display: flex;
align-items: center;
gap: 0.35rem;
padding: 0.35rem 0.7rem;
border: 1.5px solid var(--c-border);
border-radius: 20px;
font-size: 0.82rem;
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
}
.check-label.active { border-color: var(--c-primary); background: var(--c-primary-light); color: var(--c-primary); }
.check-label input { display: none; }
.versand-info {
font-size: 0.8rem;
color: var(--c-text-hint);
margin-bottom: 0.75rem;
}
.error { color: var(--c-error); font-size: 0.875rem; margin-bottom: 0.75rem; }
.actions { display: flex; gap: 0.75rem; margin-top: 0.5rem; }
.btn-primary {
flex: 1;
padding: 0.75rem;
background: var(--c-primary);
color: var(--c-bg-card);
border: none;
border-radius: 8px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: background 0.15s;
}
.btn-primary:hover:not(:disabled) { background: var(--c-primary-dark); }
.btn-primary:disabled { opacity: 0.55; cursor: not-allowed; }
.btn-ghost {
padding: 0.75rem 1rem;
background: none;
border: 1.5px solid var(--c-border);
border-radius: 8px;
font-size: 1rem;
color: var(--c-text-muted);
cursor: pointer;
}
.placeholder { color: #94a3b8; font-size: 0.95rem; }
</style>

View file

@ -0,0 +1,476 @@
<script lang="ts">
import { api } from '$lib/api';
import { user } from '$lib/user';
import { get } from 'svelte/store';
import { onMount } from 'svelte';
import type { Neuigkeit, Gruppe, Termin } from '$lib/types';
const canPost = () => true; // alle eingeloggten User dürfen posten
const userId = () => get(user)?.id as string;
let neuigkeiten = $state<Neuigkeit[]>([]);
let gruppen = $state<Gruppe[]>([]);
let termine = $state<Termin[]>([]);
let reaktionen = $state<Record<string, number>>({}); // beitrag_id → count
let meineReaktion = $state<Record<string, string>>({}); // beitrag_id → reaktion_id
let loading = $state(true);
// Formular
let showForm = $state(false);
let fText = $state('');
let fGruppeIds = $state<string[]>([]);
let fTerminId = $state('');
let fDateien = $state<FileList | null>(null);
let previews = $state<string[]>([]);
let saving = $state(false);
let formError = $state('');
// Lightbox
let lightboxUrl = $state('');
let ladeError = $state('');
onMount(async () => {
try {
// Queries einzeln damit ein Fehler sichtbar wird
const [nList, gList] = await Promise.all([
api.get<Neuigkeit[]>('/neuigkeiten', { sort: '-created' }),
api.get<Gruppe[]>('/gruppen', { sort: 'name' }),
]);
neuigkeiten = nList;
gruppen = gList;
// Termine der letzten 30 Tage + zukünftige
try {
termine = await api.get<Termin[]>('/termine', { sort: '-beginn' });
} catch { termine = []; }
// Reaktionen separat damit Fehler nicht alles blockiert
try {
const [rList, meineList] = await Promise.all([
api.get<any[]>('/reaktionen'),
api.get<any[]>('/reaktionen', { meine: 'true' }),
]);
const counts: Record<string, number> = {};
for (const r of rList) counts[r.beitrag_id] = (counts[r.beitrag_id] ?? 0) + 1;
reaktionen = counts;
const mine: Record<string, string> = {};
for (const r of meineList) mine[r.beitrag_id] = r.id;
meineReaktion = mine;
} catch { /* keine Reaktionen = kein Problem */ }
} catch (e: unknown) {
ladeError = e instanceof Error ? e.message : 'Ladefehler';
} finally {
loading = false;
}
});
function handleDateiAuswahl(e: Event) {
const files = (e.target as HTMLInputElement).files;
fDateien = files;
previews = [];
if (!files) return;
for (const file of Array.from(files)) {
if (file.type.startsWith('image/')) {
previews.push(URL.createObjectURL(file));
} else {
previews.push(''); // Video kein Preview
}
}
}
function toggleGruppe(id: string) {
fGruppeIds = fGruppeIds.includes(id)
? fGruppeIds.filter(g => g !== id)
: [...fGruppeIds, id];
}
async function posten() {
if (!fText.trim() && (!fDateien || fDateien.length === 0)) {
formError = 'Text oder Foto ist Pflicht.';
return;
}
formError = ''; saving = true;
try {
const u = get(user);
const form = new FormData();
form.append('autor_name', u?.name ?? '');
if (fText.trim()) form.append('text', fText.trim());
if (fTerminId) form.append('termin_id', fTerminId);
for (const id of fGruppeIds) form.append('gruppe_ids', id);
if (fDateien) {
for (const file of Array.from(fDateien)) form.append('medien', file);
}
const neu = await api.postForm<Neuigkeit>('/neuigkeiten', form);
neuigkeiten = [neu, ...neuigkeiten];
showForm = false;
fText = ''; fGruppeIds = []; fTerminId = ''; fDateien = null; previews = [];
} catch (e: unknown) {
formError = e instanceof Error ? e.message : 'Fehler beim Posten.';
} finally {
saving = false;
}
}
async function loeschen(n: Neuigkeit) {
if (!confirm('Beitrag löschen?')) return;
await api.del('/neuigkeiten/' + n.id);
neuigkeiten = neuigkeiten.filter(x => x.id !== n.id);
}
async function toggleReaktion(n: Neuigkeit) {
if (meineReaktion[n.id]) {
await api.del('/reaktionen/' + meineReaktion[n.id]);
meineReaktion = { ...meineReaktion, [n.id]: '' };
reaktionen = { ...reaktionen, [n.id]: Math.max(0, (reaktionen[n.id] ?? 1) - 1) };
} else {
const r = await api.post<{ id: string }>('/reaktionen', { beitrag_id: n.id });
meineReaktion = { ...meineReaktion, [n.id]: r.id };
reaktionen = { ...reaktionen, [n.id]: (reaktionen[n.id] ?? 0) + 1 };
}
}
function mediaUrl(n: Neuigkeit, datei: string, thumb = false): string {
return api.fileUrl(n.verein_id, n.id, datei, thumb);
}
function isVideo(datei: string): boolean {
return /\.(mp4|mov|m4v)$/i.test(datei);
}
function zeitAgo(iso: string): string {
const diff = Date.now() - new Date(iso).getTime();
const min = Math.floor(diff / 60000);
if (min < 1) return 'gerade eben';
if (min < 60) return `vor ${min} Min.`;
const h = Math.floor(min / 60);
if (h < 24) return `vor ${h} Std.`;
const d = Math.floor(h / 24);
if (d < 7) return `vor ${d} Tag${d > 1 ? 'en' : ''}`;
return new Date(iso).toLocaleDateString('de-DE', { day: '2-digit', month: 'short' });
}
function autorName(n: Neuigkeit): string {
return n.autor_name || 'Unbekannt';
}
function terminName(id: string): string {
return termine.find(t => t.id === id)?.titel ?? '';
}
</script>
<svelte:head><title>Neuigkeiten — vereins.haus</title></svelte:head>
<div class="top">
<h1>Neuigkeiten</h1>
{#if canPost()}
<button class="btn-primary" onclick={() => showForm = true}>+ Beitrag</button>
{/if}
</div>
{#if loading}
<p class="hint">Laden…</p>
{:else if ladeError}
<p class="hint" style="color:#dc2626">{ladeError}</p>
{:else if neuigkeiten.length === 0}
<p class="hint">Noch keine Beiträge schreib den ersten!</p>
{:else}
<div class="feed">
{#each neuigkeiten as n (n.id)}
<article class="beitrag">
<div class="beitrag-kopf">
<div class="avatar">{autorName(n)[0]?.toUpperCase()}</div>
<div class="beitrag-meta">
<span class="autor">{autorName(n)}</span>
<span class="zeit">{zeitAgo(n.created)}</span>
</div>
{#if n.autor_id === userId()}
<button class="btn-del" onclick={() => loeschen(n)}>✕</button>
{/if}
</div>
{#if n.termin_id}
<div class="termin-tag">📅 {terminName(n.termin_id)}</div>
{/if}
{#if n.text}
<p class="beitrag-text">{n.text}</p>
{/if}
{#if n.medien?.length > 0}
<div class="medien-grid" class:single={n.medien.length === 1}>
{#each n.medien as datei (datei)}
{#if isVideo(datei)}
<video controls class="media-item" preload="metadata">
<source src={mediaUrl(n, datei)} />
</video>
{:else}
<button
class="media-btn"
onclick={() => lightboxUrl = mediaUrl(n, datei)}
>
<img
src={mediaUrl(n, datei, true)}
alt=""
class="media-item"
loading="lazy"
/>
</button>
{/if}
{/each}
</div>
{/if}
<div class="beitrag-fuss">
<button
class="btn-like"
class:aktiv={!!meineReaktion[n.id]}
onclick={() => toggleReaktion(n)}
>
👍 {reaktionen[n.id] ?? 0}
</button>
{#if n.gruppe_ids?.length > 0}
<span class="gruppe-tag">
{n.gruppe_ids.length === 1
? (gruppen.find(g => g.id === n.gruppe_ids[0])?.name ?? '')
: `${n.gruppe_ids.length} Gruppen`}
</span>
{/if}
</div>
</article>
{/each}
</div>
{/if}
<!-- Beitrag verfassen -->
{#if showForm}
<div class="overlay" role="dialog" aria-modal="true">
<div class="sheet">
<h2>Neuer Beitrag</h2>
<form onsubmit={(e) => { e.preventDefault(); posten(); }}>
<div class="field">
<textarea bind:value={fText} rows="4" placeholder="Was gibt's Neues?"></textarea>
</div>
<!-- Foto/Video Upload -->
<div class="upload-area">
<label class="upload-label">
<input
type="file"
accept="image/*,video/mp4,video/quicktime"
multiple
class="file-input"
onchange={handleDateiAuswahl}
/>
📷 Fotos / Videos hinzufügen
</label>
{#if previews.length > 0}
<div class="preview-grid">
{#each previews as p, i}
{#if p}
<img src={p} alt="" class="preview-img" />
{:else}
<div class="preview-video">🎬 Video {i + 1}</div>
{/if}
{/each}
</div>
{/if}
</div>
<!-- Termin verknüpfen -->
{#if termine.length > 0}
<div class="field">
<label for="ftermin">Zum Termin</label>
<select id="ftermin" bind:value={fTerminId}>
<option value="">— keinen verknüpfen —</option>
{#each termine as t (t.id)}
<option value={t.id}>{t.titel} · {new Date(t.beginn).toLocaleDateString('de-DE')}</option>
{/each}
</select>
</div>
{/if}
<!-- Gruppen -->
{#if gruppen.length > 0}
<div class="field">
<span class="field-label">Für Gruppen (leer = alle)</span>
<div class="checkboxes">
{#each gruppen as g (g.id)}
<label class="check-label" class:active={fGruppeIds.includes(g.id)}>
<input type="checkbox" checked={fGruppeIds.includes(g.id)} onchange={() => toggleGruppe(g.id)} />
{g.name}
</label>
{/each}
</div>
</div>
{/if}
{#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-full" disabled={saving}>
{saving ? 'Posten…' : 'Posten'}
</button>
</div>
</form>
</div>
</div>
{/if}
<!-- Lightbox -->
{#if lightboxUrl}
<div class="lightbox" role="button" tabindex="0"
onclick={() => lightboxUrl = ''}
onkeydown={(e) => e.key === 'Escape' && (lightboxUrl = '')}>
<img src={lightboxUrl} alt="" />
</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: var(--c-text); }
.hint { color: var(--c-text-hint); text-align: center; margin-top: 3rem; }
.feed { display: flex; flex-direction: column; gap: 0.75rem; margin-bottom: 5rem; }
.beitrag {
background: var(--c-bg-card); border: 1px solid var(--c-border); border-radius: 12px;
overflow: hidden; padding: 0.9rem 1rem;
}
.beitrag-kopf {
display: flex; align-items: center; gap: 0.65rem; margin-bottom: 0.6rem;
}
.avatar {
width: 2.2rem; height: 2.2rem; border-radius: 50%;
background: var(--c-primary-light); color: var(--c-primary);
display: flex; align-items: center; justify-content: center;
font-weight: 700; font-size: 0.85rem; flex-shrink: 0;
}
.beitrag-meta { flex: 1; display: flex; flex-direction: column; gap: 0.05rem; }
.autor { font-weight: 600; font-size: 0.875rem; color: var(--c-text); }
.zeit { font-size: 0.72rem; color: var(--c-text-hint); }
.btn-del {
background: none; border: none; color: var(--c-text-hint);
font-size: 0.85rem; cursor: pointer; padding: 0.2rem;
}
.btn-del:hover { color: var(--c-error); }
.termin-tag {
font-size: 0.75rem; color: var(--c-primary); background: var(--c-primary-subtle);
border-radius: 20px; padding: 0.2rem 0.65rem;
display: inline-block; margin-bottom: 0.5rem;
}
.beitrag-text {
font-size: 0.92rem; color: var(--c-text); line-height: 1.55;
white-space: pre-wrap; margin: 0 0 0.6rem;
}
/* Medien-Grid */
.medien-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.25rem;
border-radius: 8px;
overflow: hidden;
margin-bottom: 0.6rem;
}
.medien-grid.single { grid-template-columns: 1fr; }
.media-btn {
display: block; padding: 0; background: none; border: none;
cursor: pointer; overflow: hidden;
}
.media-item {
width: 100%; aspect-ratio: 4/3; object-fit: cover; display: block;
transition: opacity 0.15s;
}
.media-btn:hover .media-item { opacity: 0.9; }
.beitrag-fuss {
display: flex; align-items: center; gap: 0.75rem; padding-top: 0.5rem;
border-top: 1px solid var(--c-bg); margin-top: 0.25rem;
}
.btn-like {
background: none; border: 1px solid var(--c-border); border-radius: 20px;
padding: 0.25rem 0.75rem; font-size: 0.82rem; cursor: pointer;
transition: all 0.15s; color: var(--c-text-muted);
}
.btn-like.aktiv { background: var(--c-warning-bg); border-color: var(--c-warning); color: var(--c-warning-dark); }
.gruppe-tag { font-size: 0.72rem; color: var(--c-text-hint); }
/* Formular */
.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: var(--c-bg-card); border-radius: 16px; padding: 1.5rem;
width: 100%; max-width: 480px; max-height: 92dvh; overflow-y: auto;
}
h2 { font-size: 1.1rem; font-weight: 700; color: var(--c-text); margin-bottom: 1rem; }
.field { display: flex; flex-direction: column; gap: 0.3rem; margin-bottom: 0.85rem; }
label, .field-label { font-size: 0.875rem; font-weight: 500; color: var(--c-text-secondary); }
textarea, select {
padding: 0.65rem 0.85rem; border: 1.5px solid var(--c-border); border-radius: 8px;
font-size: 1rem; background: var(--c-bg-card); width: 100%;
box-sizing: border-box; font-family: inherit; resize: vertical;
transition: border-color 0.15s;
}
textarea:focus, select:focus { outline: none; border-color: var(--c-primary); }
.upload-area { margin-bottom: 0.85rem; }
.upload-label {
display: inline-flex; align-items: center; gap: 0.4rem;
padding: 0.5rem 0.85rem; border: 1.5px dashed var(--c-border); border-radius: 8px;
font-size: 0.875rem; color: var(--c-text-muted); cursor: pointer;
transition: border-color 0.15s;
}
.upload-label:hover { border-color: var(--c-primary); color: var(--c-primary); }
.file-input { display: none; }
.preview-grid {
display: flex; flex-wrap: wrap; gap: 0.4rem; margin-top: 0.5rem;
}
.preview-img { width: 72px; height: 72px; object-fit: cover; border-radius: 6px; }
.preview-video {
width: 72px; height: 72px; background: var(--c-bg); border-radius: 6px;
display: flex; align-items: center; justify-content: center;
font-size: 0.75rem; color: var(--c-text-muted);
}
.checkboxes { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-top: 0.25rem; }
.check-label {
display: flex; align-items: center; gap: 0.35rem;
padding: 0.3rem 0.65rem; border: 1.5px solid var(--c-border); border-radius: 20px;
font-size: 0.82rem; cursor: pointer;
}
.check-label.active { border-color: var(--c-primary); background: var(--c-primary-light); color: var(--c-primary); }
.check-label input { display: none; }
.error { color: var(--c-error); font-size: 0.875rem; margin-bottom: 0.5rem; }
.actions { display: flex; gap: 0.75rem; margin-top: 0.5rem; }
.btn-primary-full {
flex: 1; padding: 0.75rem; background: var(--c-primary); color: var(--c-bg-card);
border: none; border-radius: 8px; font-size: 1rem; font-weight: 600; cursor: pointer;
}
.btn-primary-full:disabled { opacity: 0.55; cursor: not-allowed; }
.btn-ghost {
padding: 0.75rem 1rem; background: none;
border: 1.5px solid var(--c-border); border-radius: 8px;
font-size: 1rem; color: var(--c-text-muted); cursor: pointer;
}
.btn-primary { background: var(--c-primary); color: var(--c-bg-card); border: none; border-radius: 8px; font-weight: 600; cursor: pointer; }
/* Lightbox */
.lightbox {
position: fixed; inset: 0; background: rgba(0,0,0,0.9);
display: flex; align-items: center; justify-content: center;
z-index: 200; cursor: zoom-out;
}
.lightbox img { max-width: 95vw; max-height: 90dvh; border-radius: 8px; }
</style>

View file

@ -0,0 +1,345 @@
<script lang="ts">
import { api } from '$lib/api';
import { onMount } from 'svelte';
import type { Veranstaltungsort, OrtAusfall } from '$lib/types';
let orte = $state<Veranstaltungsort[]>([]);
let ausfaelle = $state<OrtAusfall[]>([]);
let loading = $state(true);
// Ort-Formular
let showOrtForm = $state(false);
let editOrtId = $state<string | null>(null);
let fName = $state('');
let fAdresse = $state('');
let fTyp = $state<Veranstaltungsort['typ']>('halle');
let fAktiv = $state(true);
let ortSaving = $state(false);
let ortError = $state('');
// Ausfall-Formular
let showAusfallForm = $state(false);
let aOrtId = $state('');
let aVon = $state('');
let aBis = $state('');
let aGrund = $state('');
let ausfallSaving = $state(false);
let ausfallError = $state('');
const typLabel: Record<string, string> = {
halle: 'Halle', platz: 'Platz', gebaeude: 'Gebäude', sonstiges: 'Sonstiges',
};
onMount(async () => {
[orte, ausfaelle] = await Promise.all([
api.get<Veranstaltungsort[]>('/orte', { sort: 'name' }),
api.get<OrtAusfall[]>('/ort-ausfaelle', { sort: 'von' }),
]);
loading = false;
});
function neuerOrt() {
editOrtId = null; fName = ''; fAdresse = ''; fTyp = 'halle'; fAktiv = true;
ortError = ''; showOrtForm = true;
}
function bearbeitenOrt(o: Veranstaltungsort) {
editOrtId = o.id; fName = o.name; fAdresse = o.adresse ?? '';
fTyp = o.typ ?? 'halle'; fAktiv = o.aktiv;
ortError = ''; showOrtForm = true;
}
async function ortSpeichern() {
if (!fName.trim()) { ortError = 'Name ist Pflichtfeld.'; return; }
ortError = ''; ortSaving = true;
try {
const data = { name: fName.trim(), adresse: fAdresse.trim() || null, typ: fTyp, aktiv: fAktiv };
if (editOrtId) {
const u = await api.put<Veranstaltungsort>('/orte/' + editOrtId, data);
orte = orte.map(o => o.id === editOrtId ? u : o);
} else {
const n = await api.post<Veranstaltungsort>('/orte', data);
orte = [...orte, n].sort((a, b) => a.name.localeCompare(b.name));
}
showOrtForm = false;
} catch (e: unknown) {
ortError = e instanceof Error ? e.message : 'Fehler.';
} finally {
ortSaving = false;
}
}
async function ortLoeschen(id: string) {
if (!confirm('Ort wirklich löschen? Alle verknüpften Termine verlieren die Ortzuordnung.')) return;
await api.del('/orte/' + id);
orte = orte.filter(o => o.id !== id);
ausfaelle = ausfaelle.filter(a => a.ort_id !== id);
}
function ausfallErstellen(ortId: string) {
aOrtId = ortId; aVon = ''; aBis = ''; aGrund = '';
ausfallError = ''; showAusfallForm = true;
}
async function ausfallSpeichern() {
if (!aVon || !aBis) { ausfallError = 'Von und Bis sind Pflichtfelder.'; return; }
if (aVon > aBis) { ausfallError = 'Bis muss nach Von liegen.'; return; }
ausfallError = ''; ausfallSaving = true;
try {
const n = await api.post<OrtAusfall>('/ort-ausfaelle', {
ort_id: aOrtId, von: aVon, bis: aBis, grund: aGrund.trim() || null,
});
ausfaelle = [...ausfaelle, n].sort((a, b) => a.von.localeCompare(b.von));
showAusfallForm = false;
} catch (e: unknown) {
ausfallError = e instanceof Error ? e.message : 'Fehler.';
} finally {
ausfallSaving = false;
}
}
async function ausfallLoeschen(id: string) {
await api.del('/ort-ausfaelle/' + id);
ausfaelle = ausfaelle.filter(a => a.id !== id);
}
function ortName(id: string) {
return orte.find(o => o.id === id)?.name ?? '—';
}
function formatDatum(iso: string) {
return new Date(iso).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
}
// Ort ist aktuell gesperrt?
function istGesperrt(o: Veranstaltungsort): OrtAusfall | undefined {
const heute = new Date().toISOString().slice(0, 10);
return ausfaelle.find(a => a.ort_id === o.id && a.von <= heute && a.bis >= heute);
}
</script>
<svelte:head><title>Veranstaltungsorte — vereins.haus</title></svelte:head>
<div class="top">
<div>
<a class="back" href="/termine">← Termine</a>
<h1>Veranstaltungsorte</h1>
</div>
<button class="btn-primary" onclick={neuerOrt}>+ Ort</button>
</div>
{#if loading}
<p class="hint">Laden…</p>
{:else if orte.length === 0}
<p class="hint">Noch keine Orte angelegt.</p>
{:else}
<ul class="liste">
{#each orte as o (o.id)}
{@const gesperrt = istGesperrt(o)}
<li class="karte" class:inaktiv={!o.aktiv}>
<div class="ort-info">
<div class="ort-name-zeile">
<span class="ort-name">{o.name}</span>
{#if !o.aktiv}
<span class="badge grau">Inaktiv</span>
{:else if gesperrt}
<span class="badge rot">Gesperrt</span>
{:else}
<span class="badge gruen">Verfügbar</span>
{/if}
</div>
{#if o.adresse}<span class="ort-meta">{o.adresse}</span>{/if}
{#if o.typ}<span class="ort-meta">{typLabel[o.typ]}</span>{/if}
{#if gesperrt}
<span class="gesperrt-hinweis">
Gesperrt bis {formatDatum(gesperrt.bis)}{gesperrt.grund ? ' · ' + gesperrt.grund : ''}
</span>
{/if}
</div>
<div class="ort-aktionen">
<button class="btn-ausfall" onclick={() => ausfallErstellen(o.id)} title="Ausfall eintragen">Ausfall</button>
<button class="btn-icon" onclick={() => bearbeitenOrt(o)}>✎</button>
<button class="btn-icon btn-icon-red" onclick={() => ortLoeschen(o.id)}>✕</button>
</div>
</li>
{/each}
</ul>
{/if}
<!-- Ausfälle -->
{#if ausfaelle.length > 0}
<h2 class="section-title">Geplante Ausfälle</h2>
<ul class="liste">
{#each ausfaelle as a (a.id)}
<li class="ausfall-karte">
<div class="ausfall-info">
<span class="ausfall-ort">{ortName(a.ort_id)}</span>
<span class="ausfall-zeitraum">{formatDatum(a.von)} {formatDatum(a.bis)}</span>
{#if a.grund}<span class="ausfall-grund">{a.grund}</span>{/if}
</div>
<button class="btn-icon btn-icon-red" onclick={() => ausfallLoeschen(a.id)}>✕</button>
</li>
{/each}
</ul>
{/if}
<!-- Ort-Formular -->
{#if showOrtForm}
<div class="overlay" role="dialog" aria-modal="true">
<div class="sheet">
<h2>{editOrtId ? 'Ort bearbeiten' : 'Neuer Ort'}</h2>
<form onsubmit={(e) => { e.preventDefault(); ortSpeichern(); }}>
<div class="field">
<label for="fname">Name *</label>
<input id="fname" type="text" bind:value={fName} placeholder="z. B. Turnhalle Schillerschule" required />
</div>
<div class="field">
<label for="fadresse">Adresse</label>
<input id="fadresse" type="text" bind:value={fAdresse} placeholder="Musterstraße 1, 12345 Stadt" />
</div>
<div class="row">
<div class="field">
<label for="ftyp">Typ</label>
<select id="ftyp" bind:value={fTyp}>
<option value="halle">Halle</option>
<option value="platz">Platz</option>
<option value="gebaeude">Gebäude</option>
<option value="sonstiges">Sonstiges</option>
</select>
</div>
<div class="field" style="justify-content:flex-end;padding-bottom:0.85rem">
<label class="toggle-label">
<input type="checkbox" bind:checked={fAktiv} />
Aktiv
</label>
</div>
</div>
{#if ortError}<p class="error">{ortError}</p>{/if}
<div class="actions">
<button type="button" class="btn-ghost" onclick={() => showOrtForm = false}>Abbrechen</button>
<button type="submit" class="btn-primary" disabled={ortSaving}>{ortSaving ? 'Speichern…' : 'Speichern'}</button>
</div>
</form>
</div>
</div>
{/if}
<!-- Ausfall-Formular -->
{#if showAusfallForm}
<div class="overlay" role="dialog" aria-modal="true">
<div class="sheet">
<h2>Ausfall eintragen</h2>
<p class="sheet-sub">{ortName(aOrtId)}</p>
<form onsubmit={(e) => { e.preventDefault(); ausfallSpeichern(); }}>
<div class="row">
<div class="field">
<label for="avon">Von *</label>
<input id="avon" type="date" bind:value={aVon} required />
</div>
<div class="field">
<label for="abis">Bis *</label>
<input id="abis" type="date" bind:value={aBis} required />
</div>
</div>
<div class="field">
<label for="agrund">Grund</label>
<input id="agrund" type="text" bind:value={aGrund} placeholder="z. B. Schulveranstaltung, Wartung" />
</div>
{#if ausfallError}<p class="error">{ausfallError}</p>{/if}
<div class="actions">
<button type="button" class="btn-ghost" onclick={() => showAusfallForm = false}>Abbrechen</button>
<button type="submit" class="btn-primary" disabled={ausfallSaving}>{ausfallSaving ? 'Speichern…' : 'Speichern'}</button>
</div>
</form>
</div>
</div>
{/if}
<style>
.top { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 1.25rem; }
.top .btn-primary { flex: none; padding: 0.45rem 0.9rem; font-size: 0.875rem; }
.back { font-size: 0.85rem; color: var(--c-primary); text-decoration: none; display: block; margin-bottom: 0.25rem; }
h1 { font-size: 1.4rem; font-weight: 700; color: var(--c-text); }
.hint { color: var(--c-text-hint); font-size: 0.95rem; text-align: center; margin-top: 3rem; }
.section-title { font-size: 0.72rem; font-weight: 700; color: var(--c-text-hint); text-transform: uppercase; letter-spacing: 0.06em; margin: 1.25rem 0 0.5rem; }
.liste { list-style: none; padding: 0; display: flex; flex-direction: column; gap: 0.5rem; margin-bottom: 1rem; }
.karte {
display: flex; align-items: flex-start; gap: 0.75rem;
padding: 0.85rem 1rem; background: var(--c-bg-card);
border: 1px solid var(--c-border); border-radius: 10px;
}
.karte.inaktiv { opacity: 0.55; }
.ort-info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 0.2rem; }
.ort-name-zeile { display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; }
.ort-name { font-weight: 600; font-size: 0.95rem; color: var(--c-text); }
.ort-meta { font-size: 0.78rem; color: var(--c-text-muted); }
.gesperrt-hinweis { font-size: 0.75rem; color: var(--c-error); font-weight: 500; }
.badge { font-size: 0.7rem; font-weight: 700; padding: 0.1rem 0.45rem; border-radius: 20px; }
.badge.gruen { background: var(--c-success-bg); color: var(--c-success); }
.badge.rot { background: var(--c-error-bg); color: var(--c-error); }
.badge.grau { background: var(--c-bg); color: var(--c-text-muted); }
.ort-aktionen { display: flex; gap: 0.35rem; flex-shrink: 0; align-items: flex-start; }
.btn-ausfall {
padding: 0.3rem 0.6rem; background: var(--c-warning-bg); border: 1px solid var(--c-warning-light);
border-radius: 6px; font-size: 0.75rem; font-weight: 600; color: var(--c-warning-amber); cursor: pointer;
}
.ausfall-karte {
display: flex; align-items: center; gap: 0.75rem;
padding: 0.7rem 1rem; background: var(--c-bg-card);
border: 1px solid var(--c-error-light); border-radius: 8px;
}
.ausfall-info { flex: 1; display: flex; flex-direction: column; gap: 0.1rem; }
.ausfall-ort { font-weight: 600; font-size: 0.88rem; color: var(--c-text); }
.ausfall-zeitraum { font-size: 0.78rem; color: var(--c-error); }
.ausfall-grund { font-size: 0.75rem; color: var(--c-text-hint); }
.btn-icon {
width: 1.9rem; height: 1.9rem; display: flex; align-items: center;
justify-content: center; background: none; border: 1px solid var(--c-border);
border-radius: 6px; color: var(--c-text-muted); font-size: 0.85rem; cursor: pointer;
}
.btn-icon:hover { border-color: var(--c-text-hint); color: var(--c-text); }
.btn-icon-red:hover { border-color: var(--c-error-light); color: var(--c-error); }
.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: var(--c-bg-card); 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: var(--c-text); margin-bottom: 0.25rem; }
.sheet-sub { font-size: 0.85rem; color: var(--c-text-muted); margin-bottom: 1rem; }
.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: var(--c-text-secondary); }
.toggle-label { display: flex; align-items: center; gap: 0.5rem; font-size: 0.9rem; cursor: pointer; }
input, select {
padding: 0.65rem 0.85rem; border: 1.5px solid var(--c-border); border-radius: 8px;
font-size: 1rem; background: var(--c-bg-card); width: 100%; box-sizing: border-box;
transition: border-color 0.15s;
}
input:focus, select:focus { outline: none; border-color: var(--c-primary); }
.error { color: var(--c-error); font-size: 0.875rem; margin-bottom: 0.75rem; }
.actions { display: flex; gap: 0.75rem; margin-top: 0.5rem; }
.btn-primary {
flex: 1; padding: 0.75rem; background: var(--c-primary); color: var(--c-bg-card);
border: none; border-radius: 8px; font-size: 1rem; font-weight: 600; cursor: pointer;
}
.btn-primary:hover:not(:disabled) { background: var(--c-primary-dark); }
.btn-primary:disabled { opacity: 0.55; cursor: not-allowed; }
.btn-ghost {
padding: 0.75rem 1rem; background: none;
border: 1.5px solid var(--c-border); border-radius: 8px;
font-size: 1rem; color: var(--c-text-muted); cursor: pointer;
}
</style>

View file

@ -1,27 +1,716 @@
<script lang="ts">
import { api } from '$lib/api';
import { user } from '$lib/user';
import { get } from 'svelte/store';
import { onMount } from 'svelte';
import { RRule } from 'rrule';
import { Calendar, TimeGrid, DayGrid } from '@event-calendar/core';
import '@event-calendar/core/index.css';
import type { Termin, Gruppe, Verfuegbarkeit, Veranstaltungsort, OrtAusfall } from '$lib/types';
const isAdmin = () => { const u = get(user); return !u?.rolle || u.rolle === 'admin'; };
const userId = () => get(user)?.id as string;
let termine = $state<Termin[]>([]);
let gruppen = $state<Gruppe[]>([]);
let alleUser = $state<any[]>([]);
let orte = $state<Veranstaltungsort[]>([]);
let ausfaelle = $state<OrtAusfall[]>([]);
let loading = $state(true);
// Ansicht
type Ansicht = 'liste' | 'woche' | 'monat';
let ansicht = $state<Ansicht>('liste');
const calPlugins = [TimeGrid, DayGrid];
// Formular
let showForm = $state(false);
let editId = $state<string | null>(null);
let fTitel = $state('');
let fBeschr = $state('');
let fBeginn = $state('');
let fEnde = $state('');
let fOrt = $state('');
let fGruppeIds = $state<string[]>([]);
let fDurchfuehrenderId = $state('');
let fOrtId = $state('');
let fWiederholung = $state(false);
let fRhythmus = $state<'woechentlich' | 'zweıwoechentlich' | 'monatlich'>('woechentlich');
let fBis = $state('');
let saving = $state(false);
let formError = $state('');
let showDelete = $state<string | null>(null);
let deleteSerieMode = $state(false);
const now = new Date();
const upcoming = $derived(
termine
.filter(t => new Date(t.beginn) >= now)
.sort((a, b) => new Date(a.beginn).getTime() - new Date(b.beginn).getTime())
);
const vergangen = $derived(
termine
.filter(t => new Date(t.beginn) < now)
.sort((a, b) => new Date(b.beginn).getTime() - new Date(a.beginn).getTime())
);
// Admin-Warnung: Termine ohne Bestätigung
const offene = $derived(
upcoming.filter(t => !t.verfuegbarkeit || t.verfuegbarkeit === 'offen' || t.verfuegbarkeit === 'vertretung_gesucht')
);
onMount(async () => {
[termine, gruppen, alleUser, orte, ausfaelle] = await Promise.all([
api.get<Termin[]>('/termine', { sort: 'beginn' }),
api.get<Gruppe[]>('/gruppen', { sort: 'name' }),
isAdmin()
? api.get<any[]>('/users', { rolle: 'trainer' })
: Promise.resolve([]),
api.get<Veranstaltungsort[]>('/orte', { sort: 'name', aktiv: 'true' }),
api.get<OrtAusfall[]>('/ort-ausfaelle', { sort: 'von' }),
]);
loading = false;
});
function toLocal(iso: string | undefined): string {
if (!iso) return '';
const d = new Date(iso);
return new Date(d.getTime() - d.getTimezoneOffset() * 60000).toISOString().slice(0, 16);
}
function fromLocal(local: string): string {
return local ? new Date(local).toISOString() : '';
}
function neuerTermin() {
editId = null; fTitel = ''; fBeschr = ''; fBeginn = ''; fEnde = '';
fOrt = ''; fOrtId = ''; fGruppeIds = []; fDurchfuehrenderId = '';
fWiederholung = false; fRhythmus = 'woechentlich'; fBis = '';
formError = ''; showForm = true;
}
function bearbeiten(t: Termin) {
editId = t.id; fTitel = t.titel; fBeschr = t.beschreibung ?? '';
fBeginn = toLocal(t.beginn); fEnde = toLocal(t.ende);
fOrt = t.ort ?? ''; fOrtId = t.ort_id ?? ''; fGruppeIds = t.gruppe_ids ?? [];
fDurchfuehrenderId = t.durchfuehrender_id ?? '';
fWiederholung = false;
formError = ''; showForm = true;
}
// Ausfall-Check für einen Termin
function ortAusfall(t: Termin): OrtAusfall | undefined {
if (!t.ort_id) return undefined;
const d = t.beginn.slice(0, 10);
return ausfaelle.find(a => a.ort_id === t.ort_id && a.von <= d && a.bis >= d);
}
function ortNameById(id: string | undefined): string {
if (!id) return '';
return orte.find(o => o.id === id)?.name ?? '';
}
function generiereTerminDaten(beginn: Date, rruleStr: string | null) {
return {
titel: fTitel.trim(),
beschreibung: fBeschr.trim() || null,
ort: fOrtId ? null : (fOrt.trim() || null),
ort_id: fOrtId || null,
gruppe_ids: fGruppeIds,
durchfuehrender_id: fDurchfuehrenderId || null,
verfuegbarkeit: fDurchfuehrenderId ? 'offen' : null,
rrule: rruleStr,
};
}
async function speichern() {
if (!fTitel.trim() || !fBeginn) { formError = 'Titel und Beginn sind Pflichtfelder.'; return; }
if (fWiederholung && !fBis) { formError = 'Bitte ein Enddatum für die Wiederholung angeben.'; return; }
formError = ''; saving = true;
try {
if (fWiederholung && !editId) {
// Serie anlegen: rrule generieren, Einzeltermine erstellen
const dtstart = new Date(fBeginn);
const until = new Date(fBis + 'T23:59:59');
const dauer = fEnde ? new Date(fEnde).getTime() - dtstart.getTime() : 60 * 60 * 1000;
const freq = fRhythmus === 'monatlich' ? RRule.MONTHLY : RRule.WEEKLY;
const interval = fRhythmus === 'zweıwoechentlich' ? 2 : 1;
const rule = new RRule({ freq, interval, dtstart, until });
const rruleStr = `FREQ=${fRhythmus === 'monatlich' ? 'MONTHLY' : 'WEEKLY'}${interval === 2 ? ';INTERVAL=2' : ''};UNTIL=${until.toISOString().replace(/[-:]/g, '').slice(0, 15)}Z`;
const serie_id = crypto.randomUUID();
const dates = rule.all();
const neu = await Promise.all(
dates.map(d =>
api.post<Termin>('/termine', {
...generiereTerminDaten(d, rruleStr),
beginn: d.toISOString(),
ende: fEnde ? new Date(d.getTime() + dauer).toISOString() : null,
serie_id,
}),
),
);
termine = [...termine, ...neu];
} else {
const data = {
...generiereTerminDaten(new Date(fBeginn), null),
beginn: fromLocal(fBeginn),
ende: fEnde ? fromLocal(fEnde) : null,
};
if (editId) {
const updated = await api.put<Termin>('/termine/' + editId, data);
termine = termine.map(t => t.id === editId ? updated : t);
} else {
const neu = await api.post<Termin>('/termine', data);
termine = [...termine, neu];
}
}
showForm = false;
} catch (e: unknown) {
formError = e instanceof Error ? e.message : 'Fehler beim Speichern.';
} finally {
saving = false;
}
}
async function loeschen(t: Termin, ganzeSerieLoeschen: boolean) {
if (ganzeSerieLoeschen && t.serie_id) {
const serie = termine.filter(x => x.serie_id === t.serie_id);
await Promise.all(serie.map(s => api.del('/termine/' + s.id)));
termine = termine.filter(x => x.serie_id !== t.serie_id);
} else {
await api.del('/termine/' + t.id);
termine = termine.filter(x => x.id !== t.id);
}
showDelete = null;
}
// Vorschau: wie viele Termine werden erzeugt?
const serieVorschau = $derived(() => {
if (!fWiederholung || !fBeginn || !fBis) return 0;
try {
const dtstart = new Date(fBeginn);
const until = new Date(fBis + 'T23:59:59');
const freq = fRhythmus === 'monatlich' ? RRule.MONTHLY : RRule.WEEKLY;
const interval = fRhythmus === 'zweıwoechentlich' ? 2 : 1;
return new RRule({ freq, interval, dtstart, until }).all().length;
} catch { return 0; }
});
async function setVerfuegbarkeit(t: Termin, v: Verfuegbarkeit) {
const updated = await api.put<Termin>('/termine/' + t.id, { verfuegbarkeit: v });
termine = termine.map(x => x.id === t.id ? updated : x);
}
function toggleGruppe(id: string) {
fGruppeIds = fGruppeIds.includes(id)
? fGruppeIds.filter(g => g !== id)
: [...fGruppeIds, id];
}
function formatDatum(iso: string): string {
return new Date(iso).toLocaleDateString('de-DE', {
weekday: 'short', day: '2-digit', month: 'long', year: 'numeric',
});
}
function formatZeit(iso: string): string {
return new Date(iso).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
}
function gruppenLabel(ids: string[]): string {
return (ids ?? []).map(id => gruppen.find(g => g.id === id)?.name).filter(Boolean).join(', ');
}
function userName(uid: string | undefined): string {
if (!uid) return '';
const u = alleUser.find((u: any) => u.id === uid);
return u?.name ?? '';
}
const verfuegbarkeitConfig: Record<Verfuegbarkeit, { label: string; farbe: string }> = {
offen: { label: 'Offen', farbe: 'var(--c-warning)' },
bestaetigt: { label: 'Bestätigt', farbe: 'var(--c-success)' },
abgesagt: { label: 'Abgesagt', farbe: 'var(--c-error)' },
vertretung_gesucht: { label: 'Vertretung gesucht', farbe: 'var(--c-accent)' },
};
function istMeinTermin(t: Termin): boolean {
return t.durchfuehrender_id === userId();
}
function verfuegbarkeitFarbe(t: Termin): string {
switch (t.verfuegbarkeit) {
case 'bestaetigt': return 'var(--c-success)';
case 'abgesagt': return 'var(--c-error)';
case 'vertretung_gesucht': return 'var(--c-accent)';
default: return 'var(--c-primary)';
}
}
const calEvents = $derived(termine.map(t => ({
id: t.id,
title: t.titel,
start: t.beginn,
end: t.ende ?? new Date(new Date(t.beginn).getTime() + 60 * 60 * 1000).toISOString(),
backgroundColor: verfuegbarkeitFarbe(t),
extendedProps: { termin: t },
})));
const calOptions = $derived({
view: ansicht === 'monat' ? 'dayGridMonth' : 'timeGridWeek',
locale: 'de',
firstDay: 1,
height: ansicht === 'monat' ? 'auto' : '72dvh',
events: calEvents,
slotMinTime: '07:00:00',
slotMaxTime: '23:00:00',
allDaySlot: false,
headerToolbar: {
start: 'prev,next today',
center: 'title',
end: '',
},
buttonText: { today: 'Heute' },
eventClick: (info: any) => {
const t: Termin = info.event.extendedProps.termin;
if (isAdmin()) bearbeiten(t);
},
});
</script>
<svelte:head><title>Termine — vereins.haus</title></svelte:head>
<div class="page-header">
<div class="top">
<h1>Termine</h1>
<button class="btn-primary">+ Termin</button>
<div class="top-rechts">
{#if isAdmin()}
<a href="/orte" class="btn-orte">Orte</a>
{/if}
<div class="ansicht-switcher">
<button class:aktiv={ansicht === 'liste'} onclick={() => ansicht = 'liste'} title="Listenansicht"></button>
<button class:aktiv={ansicht === 'woche'} onclick={() => ansicht = 'woche'} title="Wochenansicht">W</button>
<button class:aktiv={ansicht === 'monat'} onclick={() => ansicht = 'monat'} title="Monatsansicht">M</button>
</div>
{#if isAdmin()}
<button class="btn-primary" onclick={neuerTermin}>+ Termin</button>
{/if}
</div>
</div>
<p class="placeholder">Terminkalender — in Entwicklung</p>
{#if isAdmin() && offene.length > 0}
<div class="warnung">
{offene.length} {offene.length === 1 ? 'Termin benötigt' : 'Termine benötigen'} eine Bestätigung
</div>
{/if}
{#if loading}
<p class="hint">Laden…</p>
{:else if ansicht !== 'liste'}
<div class="kalender-wrap">
<Calendar plugins={calPlugins} options={calOptions} />
</div>
{:else if termine.length === 0}
<p class="hint">Noch keine Termine geplant.</p>
{:else}
{#if upcoming.length > 0}
<ul class="liste">
{#each upcoming as t (t.id)}
<li class="karte">
<div class="karte-datum-col">
<span class="tag">{new Date(t.beginn).toLocaleDateString('de-DE', { weekday: 'short' })}</span>
<span class="tag-zahl">{new Date(t.beginn).getDate()}</span>
<span class="monat">{new Date(t.beginn).toLocaleDateString('de-DE', { month: 'short' })}</span>
</div>
<div class="karte-info">
<div class="karte-titel-zeile">
<span class="karte-titel">{t.titel}</span>
{#if t.serie_id}<span class="serie-badge" title="Wiederholungsserie"></span>{/if}
</div>
<span class="karte-meta">
{formatZeit(t.beginn)}{t.ende ? ' ' + formatZeit(t.ende) : ''}{t.ort ? ' · ' + t.ort : ''}
</span>
{#if t.ort_id || t.ort}
<span class="karte-meta">{ortNameById(t.ort_id) || t.ort}</span>
{/if}
{#if t.gruppe_ids?.length}
<span class="karte-sub">{gruppenLabel(t.gruppe_ids)}</span>
{/if}
{#if ortAusfall(t)}
{@const af = ortAusfall(t)!}
<span class="ausfall-warn">⚠ Ort gesperrt{af.grund ? ': ' + af.grund : ''}</span>
{/if}
{#if t.durchfuehrender_id}
<div class="verfueg-zeile">
{#if t.verfuegbarkeit && t.verfuegbarkeit !== 'offen'}
<span class="verfueg-badge" style="color:{verfuegbarkeitConfig[t.verfuegbarkeit].farbe}">
{verfuegbarkeitConfig[t.verfuegbarkeit].label}
</span>
{:else}
<span class="verfueg-badge" style="color:var(--c-warning)">● Offen</span>
{/if}
{#if isAdmin() && userName(t.durchfuehrender_id)}
<span class="karte-sub">{userName(t.durchfuehrender_id)}</span>
{/if}
</div>
{#if istMeinTermin(t)}
<div class="meine-aktionen">
<button
class="btn-aktion bestaetigen"
class:aktiv={t.verfuegbarkeit === 'bestaetigt'}
onclick={() => setVerfuegbarkeit(t, 'bestaetigt')}
>Ich bin dabei</button>
<button
class="btn-aktion absagen"
class:aktiv={t.verfuegbarkeit === 'abgesagt'}
onclick={() => setVerfuegbarkeit(t, 'abgesagt')}
>Kann nicht</button>
<button
class="btn-aktion vertretung"
class:aktiv={t.verfuegbarkeit === 'vertretung_gesucht'}
onclick={() => setVerfuegbarkeit(t, 'vertretung_gesucht')}
>Vertretung gesucht</button>
</div>
{/if}
{/if}
</div>
{#if isAdmin()}
<div class="karte-aktionen">
<button class="btn-icon" onclick={() => bearbeiten(t)} title="Bearbeiten"></button>
<button class="btn-icon btn-icon-red" onclick={() => { showDelete = t.id; deleteSerieMode = false; }} title="Löschen"></button>
</div>
{/if}
</li>
{/each}
</ul>
{/if}
{#if vergangen.length > 0}
<details class="vergangen">
<summary>Vergangene Termine ({vergangen.length})</summary>
<ul class="liste vergangen-liste">
{#each vergangen as t (t.id)}
<li class="karte karte-grau">
<div class="karte-datum-col">
<span class="tag">{new Date(t.beginn).toLocaleDateString('de-DE', { weekday: 'short' })}</span>
<span class="tag-zahl">{new Date(t.beginn).getDate()}</span>
<span class="monat">{new Date(t.beginn).toLocaleDateString('de-DE', { month: 'short' })}</span>
</div>
<div class="karte-info">
<span class="karte-titel">{t.titel}</span>
<span class="karte-meta">{formatDatum(t.beginn)}{t.ort ? ' · ' + t.ort : ''}</span>
</div>
{#if isAdmin()}
<div class="karte-aktionen">
<button class="btn-icon btn-icon-red" onclick={() => showDelete = t.id} title="Löschen"></button>
</div>
{/if}
</li>
{/each}
</ul>
</details>
{/if}
{/if}
<!-- Termin-Formular (nur Admin) -->
{#if showForm && isAdmin()}
<div class="overlay" role="dialog" aria-modal="true">
<div class="sheet">
<h2>{editId ? 'Termin bearbeiten' : 'Neuer Termin'}</h2>
<form onsubmit={(e) => { e.preventDefault(); speichern(); }}>
<div class="field">
<label for="ftitel">Titel *</label>
<input id="ftitel" type="text" bind:value={fTitel} required />
</div>
<div class="row">
<div class="field">
<label for="fbeginn">Beginn *</label>
<input id="fbeginn" type="datetime-local" bind:value={fBeginn} required />
</div>
<div class="field">
<label for="fende">Ende</label>
<input id="fende" type="datetime-local" bind:value={fEnde} />
</div>
</div>
{#if orte.length > 0}
<div class="field">
<label for="fort-id">Ort</label>
<select id="fort-id" bind:value={fOrtId}>
<option value="">— Freitext —</option>
{#each orte as o (o.id)}
<option value={o.id}>{o.name}</option>
{/each}
</select>
</div>
{#if !fOrtId}
<div class="field">
<label for="fort">Ort (Freitext)</label>
<input id="fort" type="text" bind:value={fOrt} placeholder="z. B. Vereinsheim" />
</div>
{/if}
{:else}
<div class="field">
<label for="fort">Ort <a class="ort-link" href="/orte">Orte verwalten →</a></label>
<input id="fort" type="text" bind:value={fOrt} />
</div>
{/if}
<div class="field">
<label for="fbeschr">Beschreibung</label>
<textarea id="fbeschr" bind:value={fBeschr} rows="2"></textarea>
</div>
<div class="field">
<label for="fdurch">Durchführender</label>
<select id="fdurch" bind:value={fDurchfuehrenderId}>
<option value="">— nicht zugewiesen —</option>
{#each alleUser as u (u.id)}
<option value={u.id}>{u.name}</option>
{/each}
</select>
</div>
{#if !editId}
<div class="field">
<label class="toggle-label">
<input type="checkbox" bind:checked={fWiederholung} />
Wiederholt sich
</label>
</div>
{#if fWiederholung}
<div class="row">
<div class="field">
<label for="frhythmus">Rhythmus</label>
<select id="frhythmus" bind:value={fRhythmus}>
<option value="woechentlich">Wöchentlich</option>
<option value="zweıwoechentlich">Zweiwöchentlich</option>
<option value="monatlich">Monatlich</option>
</select>
</div>
<div class="field">
<label for="fbis">Bis (Datum)</label>
<input id="fbis" type="date" bind:value={fBis} required />
</div>
</div>
{#if serieVorschau() > 0}
<p class="serie-vorschau">{serieVorschau()} Termine werden angelegt</p>
{/if}
{/if}
{/if}
{#if gruppen.length > 0}
<div class="field">
<span class="field-label">Für Gruppen</span>
<div class="checkboxes">
{#each gruppen as g (g.id)}
<label class="check-label" class:active={fGruppeIds.includes(g.id)}>
<input type="checkbox" checked={fGruppeIds.includes(g.id)} onchange={() => toggleGruppe(g.id)} />
{g.name}
</label>
{/each}
</div>
</div>
{/if}
{#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}
<!-- Lösch-Dialog -->
{#if showDelete}
{@const t = termine.find(x => x.id === showDelete)!}
<div class="overlay" role="dialog" aria-modal="true">
<div class="dialog">
<p>Termin löschen?</p>
{#if t?.serie_id}
<div class="radio-group">
<label class="radio-label">
<input type="radio" bind:group={deleteSerieMode} value={false} />
Nur diesen Termin
</label>
<label class="radio-label">
<input type="radio" bind:group={deleteSerieMode} value={true} />
Alle Termine der Serie löschen
</label>
</div>
{:else}
<p class="dialog-sub">Diese Aktion kann nicht rückgängig gemacht werden.</p>
{/if}
<div class="dialog-actions">
<button class="btn-ghost" onclick={() => showDelete = null}>Abbrechen</button>
<button class="btn-danger" onclick={() => loeschen(t, deleteSerieMode)}>Löschen</button>
</div>
</div>
</div>
{/if}
<style>
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1.25rem;
.top { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1rem; }
.top .btn-primary { flex: none; padding: 0.45rem 0.9rem; font-size: 0.875rem; }
.top-rechts { display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; }
h1 { font-size: 1.4rem; font-weight: 700; color: var(--c-text); }
.btn-orte {
padding: 0.5rem 0.85rem; background: none;
border: 1.5px solid var(--c-border); border-radius: 8px;
font-size: 0.85rem; color: var(--c-text-secondary); text-decoration: none;
}
h1 { font-size: 1.4rem; font-weight: 700; color: #1e293b; }
.ansicht-switcher {
display: flex; border: 1.5px solid var(--c-border); border-radius: 8px; overflow: hidden;
}
.ansicht-switcher button {
padding: 0.4rem 0.75rem; background: none; border: none;
font-size: 0.82rem; font-weight: 600; color: var(--c-text-muted); cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.ansicht-switcher button + button { border-left: 1px solid var(--c-border); }
.ansicht-switcher button.aktiv { background: var(--c-primary); color: var(--c-bg-card); }
.kalender-wrap {
margin-bottom: 1rem;
--ec-today-bg-color: var(--c-primary-subtle);
--ec-event-bg-color: var(--c-primary);
--ec-border-color: var(--c-border);
--ec-text-color: var(--c-text);
--ec-button-bg-color: var(--c-bg-card);
--ec-button-border-color: var(--c-border);
--ec-button-text-color: var(--c-text-secondary);
--ec-active-bg-color: var(--c-primary);
--ec-active-text-color: var(--c-bg-card);
}
.ausfall-warn { font-size: 0.75rem; color: var(--c-error); font-weight: 600; }
.ort-link { font-size: 0.75rem; color: var(--c-primary); margin-left: 0.5rem; }
.hint { color: var(--c-text-hint); font-size: 0.95rem; text-align: center; margin-top: 3rem; }
.warnung {
background: var(--c-warning-subtle); border: 1px solid var(--c-warning-light);
border-radius: 8px; padding: 0.65rem 0.9rem;
font-size: 0.85rem; color: var(--c-warning-dark); margin-bottom: 1rem;
}
.liste { list-style: none; padding: 0; display: flex; flex-direction: column; gap: 0.5rem; margin-bottom: 1rem; }
.karte {
display: flex; align-items: flex-start; gap: 0.75rem;
padding: 0.85rem 1rem; background: var(--c-bg-card);
border: 1px solid var(--c-border); border-radius: 10px;
}
.karte-grau { background: var(--c-bg-subtle); opacity: 0.75; }
.karte-datum-col {
display: flex; flex-direction: column; align-items: center;
min-width: 2.2rem; padding-top: 0.1rem;
}
.tag { font-size: 0.65rem; font-weight: 600; color: var(--c-text-hint); text-transform: uppercase; }
.tag-zahl { font-size: 1.4rem; font-weight: 700; color: var(--c-primary); line-height: 1.1; }
.monat { font-size: 0.65rem; color: var(--c-text-hint); text-transform: uppercase; }
.karte-info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 0.2rem; }
.karte-titel-zeile { display: flex; align-items: center; gap: 0.4rem; }
.karte-titel { font-weight: 600; font-size: 0.95rem; color: var(--c-text); }
.serie-badge { font-size: 0.75rem; color: var(--c-primary); font-weight: 700; }
.karte-meta { font-size: 0.78rem; color: var(--c-text-muted); }
.karte-sub { font-size: 0.72rem; color: var(--c-text-hint); }
.verfueg-zeile { display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; margin-top: 0.2rem; }
.verfueg-badge { font-size: 0.75rem; font-weight: 600; }
.meine-aktionen {
display: flex; gap: 0.35rem; flex-wrap: wrap; margin-top: 0.4rem;
}
.btn-aktion {
padding: 0.3rem 0.65rem; border-radius: 20px;
font-size: 0.75rem; font-weight: 600; cursor: pointer;
border: 1.5px solid var(--c-border); background: var(--c-bg-card);
transition: all 0.15s;
}
.btn-aktion.bestaetigen { color: var(--c-success); }
.btn-aktion.bestaetigen.aktiv { background: var(--c-success-bg); border-color: var(--c-success); }
.btn-aktion.absagen { color: var(--c-error); }
.btn-aktion.absagen.aktiv { background: var(--c-error-bg); border-color: var(--c-error); }
.btn-aktion.vertretung { color: var(--c-accent); }
.btn-aktion.vertretung.aktiv { background: var(--c-accent-subtle); border-color: var(--c-accent); }
.karte-aktionen { display: flex; gap: 0.35rem; flex-shrink: 0; }
.btn-icon {
width: 1.9rem; height: 1.9rem; display: flex; align-items: center;
justify-content: center; background: none; border: 1px solid var(--c-border);
border-radius: 6px; color: var(--c-text-muted); font-size: 0.85rem; cursor: pointer;
}
.btn-icon:hover { border-color: var(--c-text-hint); color: var(--c-text); }
.btn-icon-red:hover { border-color: var(--c-error-light); color: var(--c-error); }
.vergangen summary {
font-size: 0.85rem; color: var(--c-text-hint); cursor: pointer;
padding: 0.5rem 0; list-style: none; user-select: none;
}
.vergangen-liste { margin-top: 0.5rem; }
/* 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: var(--c-bg-card); border-radius: 16px; padding: 1.5rem;
width: 100%; max-width: 480px; max-height: 92dvh; overflow-y: auto;
}
h2 { font-size: 1.1rem; font-weight: 700; color: var(--c-text); margin-bottom: 1rem; }
.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, .field-label { font-size: 0.875rem; font-weight: 500; color: var(--c-text-secondary); }
input, textarea, select {
padding: 0.65rem 0.85rem; border: 1.5px solid var(--c-border); border-radius: 8px;
font-size: 1rem; background: var(--c-bg-card); width: 100%;
box-sizing: border-box; font-family: inherit; resize: vertical;
transition: border-color 0.15s;
}
input:focus, textarea:focus, select:focus { outline: none; border-color: var(--c-primary); }
.checkboxes { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-top: 0.2rem; }
.check-label {
display: flex; align-items: center; gap: 0.35rem;
padding: 0.35rem 0.7rem; border: 1.5px solid var(--c-border);
border-radius: 20px; font-size: 0.82rem; cursor: pointer;
}
.check-label.active { border-color: var(--c-primary); background: var(--c-primary-light); color: var(--c-primary); }
.check-label input { display: none; }
.error { color: var(--c-error); font-size: 0.875rem; margin-bottom: 0.75rem; }
.actions { display: flex; gap: 0.75rem; margin-top: 0.5rem; }
.btn-primary {
background: #1e40af; color: #fff; border: none;
border-radius: 8px; padding: 0.5rem 1rem;
font-size: 0.9rem; font-weight: 600;
flex: 1; padding: 0.75rem; background: var(--c-primary); color: var(--c-bg-card);
border: none; border-radius: 8px; font-size: 1rem; font-weight: 600;
cursor: pointer; transition: background 0.15s;
}
.placeholder { color: #94a3b8; font-size: 0.95rem; }
.btn-primary:hover:not(:disabled) { background: var(--c-primary-dark); }
.btn-primary:disabled { opacity: 0.55; cursor: not-allowed; }
.btn-ghost {
padding: 0.75rem 1rem; background: none;
border: 1.5px solid var(--c-border); border-radius: 8px;
font-size: 1rem; color: var(--c-text-muted); cursor: pointer;
}
.dialog {
background: var(--c-bg-card); border-radius: 16px; padding: 1.5rem;
width: 100%; max-width: 400px;
}
.dialog p { font-size: 1rem; color: var(--c-text); margin-bottom: 0.4rem; font-weight: 600; }
.dialog-sub { font-weight: 400 !important; font-size: 0.875rem; color: var(--c-text-hint); }
.radio-group { display: flex; flex-direction: column; gap: 0.5rem; margin: 0.75rem 0; }
.radio-label { display: flex; align-items: center; gap: 0.5rem; font-size: 0.9rem; color: var(--c-text); cursor: pointer; }
.dialog-actions { display: flex; gap: 0.75rem; margin-top: 1.25rem; }
.toggle-label { display: flex; align-items: center; gap: 0.5rem; font-size: 0.9rem; color: var(--c-text-secondary); cursor: pointer; }
.serie-vorschau { font-size: 0.82rem; color: var(--c-primary); margin: -0.4rem 0 0.5rem; }
.btn-danger {
flex: 1; padding: 0.75rem; background: var(--c-error); color: var(--c-bg-card);
border: none; border-radius: 8px; font-size: 0.95rem; font-weight: 600; cursor: pointer;
}
.btn-danger:hover { background: var(--c-error-dark); }
</style>

View file

@ -1,12 +1,13 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import { pb } from '$lib/pb';
import { get } from 'svelte/store';
import { user } from '$lib/user';
let { children } = $props();
onMount(() => {
if (pb.authStore.isValid) {
if (!!get(user)) {
goto('/');
}
});
@ -26,13 +27,13 @@
align-items: center;
justify-content: center;
padding: 1.5rem;
background: #f8fafc;
background: var(--c-bg-subtle);
}
.auth-card {
width: 100%;
max-width: 400px;
background: #fff;
background: var(--c-bg-card);
border-radius: 12px;
padding: 2rem;
box-shadow: 0 1px 3px rgba(0,0,0,.08), 0 4px 16px rgba(0,0,0,.06);
@ -42,7 +43,7 @@
display: block;
font-size: 1.4rem;
font-weight: 700;
color: #1e40af;
color: var(--c-primary);
margin-bottom: 1.5rem;
letter-spacing: -0.02em;
}

View file

@ -1,6 +1,8 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { pb } from '$lib/pb';
import { api } from '$lib/api';
import { user } from '$lib/user';
import type { AppUser } from '$lib/user';
let email = $state('');
let password = $state('');
@ -11,7 +13,8 @@
error = '';
loading = true;
try {
await pb.collection('users').authWithPassword(email, password);
const u = await api.post<AppUser & { token: string }>('/auth/login', { email, password });
user.set(u);
goto('/');
} catch {
error = 'E-Mail oder Passwort falsch.';
@ -68,7 +71,7 @@
font-size: 1.4rem;
font-weight: 700;
margin-bottom: 1.5rem;
color: #1e293b;
color: var(--c-text);
}
.field {
@ -81,25 +84,25 @@
label {
font-size: 0.9rem;
font-weight: 500;
color: #475569;
color: var(--c-text-secondary);
}
input {
padding: 0.65rem 0.85rem;
border: 1.5px solid #e2e8f0;
border: 1.5px solid var(--c-border);
border-radius: 8px;
font-size: 1rem;
transition: border-color .15s;
background: #fff;
background: var(--c-bg-card);
}
input:focus {
outline: none;
border-color: #1e40af;
border-color: var(--c-primary);
}
.error {
color: #dc2626;
color: var(--c-error);
font-size: 0.9rem;
margin-bottom: 0.75rem;
}
@ -107,8 +110,8 @@
button {
width: 100%;
padding: 0.75rem;
background: #1e40af;
color: #fff;
background: var(--c-primary);
color: var(--c-bg-card);
border: none;
border-radius: 8px;
font-size: 1rem;
@ -118,7 +121,7 @@
}
button:hover:not(:disabled) {
background: #1d3a9e;
background: var(--c-primary-dark);
}
button:disabled {
@ -129,12 +132,12 @@
.switch {
margin-top: 1.25rem;
font-size: 0.9rem;
color: #64748b;
color: var(--c-text-muted);
text-align: center;
}
.switch a {
color: #1e40af;
color: var(--c-primary);
font-weight: 500;
}
</style>

View file

@ -1,6 +1,7 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { pb } from '$lib/pb';
import { api } from '$lib/api';
import { user } from '$lib/user';
let vereinsname = $state('');
let email = $state('');
@ -17,20 +18,9 @@
}
loading = true;
try {
const user = await pb.collection('users').create({
email,
password,
passwordConfirm,
name: vereinsname
});
await pb.collection('vereine').create({
name: vereinsname,
plan: 'free',
dosb_mitglied: false,
user_id: user.id
});
await pb.collection('users').authWithPassword(email, password);
goto('/');
const u = await api.post('/auth/register', { vereinName: vereinsname, email, password, name: vereinsname });
user.set(u as any);
goto('/onboarding');
} catch (e: unknown) {
error = e instanceof Error ? e.message : 'Registrierung fehlgeschlagen.';
} finally {
@ -109,13 +99,13 @@
h1 {
font-size: 1.4rem;
font-weight: 700;
color: #1e293b;
color: var(--c-text);
margin-bottom: 0.25rem;
}
.subtitle {
font-size: 0.9rem;
color: #64748b;
color: var(--c-text-muted);
margin-bottom: 1.5rem;
}
@ -129,25 +119,25 @@
label {
font-size: 0.9rem;
font-weight: 500;
color: #475569;
color: var(--c-text-secondary);
}
input {
padding: 0.65rem 0.85rem;
border: 1.5px solid #e2e8f0;
border: 1.5px solid var(--c-border);
border-radius: 8px;
font-size: 1rem;
transition: border-color .15s;
background: #fff;
background: var(--c-bg-card);
}
input:focus {
outline: none;
border-color: #1e40af;
border-color: var(--c-primary);
}
.error {
color: #dc2626;
color: var(--c-error);
font-size: 0.9rem;
margin-bottom: 0.75rem;
}
@ -155,8 +145,8 @@
button {
width: 100%;
padding: 0.75rem;
background: #1e40af;
color: #fff;
background: var(--c-primary);
color: var(--c-bg-card);
border: none;
border-radius: 8px;
font-size: 1rem;
@ -166,7 +156,7 @@
}
button:hover:not(:disabled) {
background: #1d3a9e;
background: var(--c-primary-dark);
}
button:disabled {
@ -177,12 +167,12 @@
.switch {
margin-top: 1.25rem;
font-size: 0.9rem;
color: #64748b;
color: var(--c-text-muted);
text-align: center;
}
.switch a {
color: #1e40af;
color: var(--c-primary);
font-weight: 500;
}
</style>

View file

@ -1,4 +1,5 @@
<script lang="ts">
import '$lib/styles/theme.css';
let { children } = $props();
</script>
@ -31,8 +32,8 @@
:global(body) {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
color: #1e293b;
background: #f8fafc;
color: var(--c-text);
background: var(--c-bg-subtle);
line-height: 1.6;
-webkit-font-smoothing: antialiased;
}

View file

@ -0,0 +1,18 @@
import { json, error } from '@sveltejs/kit';
import { getDb } from '$lib/server/db';
import { checkPassword, signJwt } from '$lib/server/auth';
export async function POST({ request }) {
const { email, password } = await request.json();
if (!email || !password) throw error(400, 'E-Mail und Passwort erforderlich');
const db = getDb();
const u = db.prepare('SELECT * FROM users WHERE email = ?').get(email.toLowerCase()) as any;
if (!u || !(await checkPassword(password, u.password_hash))) throw error(401, 'Ungültige Zugangsdaten');
const token = await signJwt({
sub: u.id, verein_id: u.verein_id, rolle: u.rolle, name: u.name, email: u.email
});
return json({ token, id: u.id, verein_id: u.verein_id, rolle: u.rolle, name: u.name, email: u.email });
}

View file

@ -0,0 +1,11 @@
import { json } from '@sveltejs/kit';
import { requireAuth } from '$lib/server/auth';
import { getDb } from '$lib/server/db';
export async function GET({ request }) {
const u = await requireAuth(request);
const db = getDb();
const row = db.prepare('SELECT id, verein_id, email, name, rolle FROM users WHERE id = ?').get(u.sub) as any;
if (!row) return new Response(null, { status: 401 });
return json(row);
}

View file

@ -0,0 +1,24 @@
import { json, error } from '@sveltejs/kit';
import { getDb, newId } from '$lib/server/db';
import { hashPassword, signJwt } from '$lib/server/auth';
export async function POST({ request }) {
const { vereinName, email, password, name } = await request.json();
if (!vereinName || !email || !password) throw error(400, 'Pflichtfelder fehlen');
if (password.length < 8) throw error(400, 'Passwort mindestens 8 Zeichen');
const db = getDb();
const existing = db.prepare('SELECT id FROM users WHERE email = ?').get(email.toLowerCase());
if (existing) throw error(409, 'E-Mail bereits registriert');
const vereinId = newId();
const userId = newId();
const hash = await hashPassword(password);
db.prepare('INSERT INTO vereine (id, name) VALUES (?, ?)').run(vereinId, vereinName);
db.prepare('INSERT INTO users (id, verein_id, email, password_hash, name, rolle) VALUES (?, ?, ?, ?, ?, NULL)')
.run(userId, vereinId, email.toLowerCase(), hash, name || email.split('@')[0]);
const token = await signJwt({ sub: userId, verein_id: vereinId, rolle: null, name: name || email.split('@')[0], email: email.toLowerCase() });
return json({ token, id: userId, verein_id: vereinId, rolle: null, name: name || email.split('@')[0], email: email.toLowerCase() }, { status: 201 });
}

View file

@ -0,0 +1,37 @@
import { json, error } from '@sveltejs/kit';
import { getDb, newId, rows, row } from '$lib/server/db';
import { requireAuth } from '$lib/server/auth';
export async function GET({ request }) {
const u = await requireAuth(request);
const db = getDb();
const items = db.prepare(
'SELECT * FROM beitraege WHERE verein_id = ? ORDER BY name'
).all(u.verein_id);
return json(rows(items as Record<string, unknown>[]));
}
export async function POST({ request }) {
const u = await requireAuth(request);
const db = getDb();
const body = await request.json();
if (!body.name || body.betrag == null) throw error(400, 'Name und Betrag sind Pflichtfelder');
const id = newId();
db.prepare(`
INSERT INTO beitraege (id, verein_id, name, betrag, rhythmus, beschreibung)
VALUES (?, ?, ?, ?, ?, ?)
`).run(
id,
u.verein_id,
body.name,
body.betrag,
body.rhythmus ?? 'jaehrlich',
body.beschreibung ?? null
);
const beitrag = db.prepare('SELECT * FROM beitraege WHERE id = ?').get(id);
return json(row(beitrag as Record<string, unknown>), { status: 201 });
}

View file

@ -0,0 +1,52 @@
import { json, error } from '@sveltejs/kit';
import { getDb, row } from '$lib/server/db';
import { requireAuth } from '$lib/server/auth';
export async function GET({ request, params }) {
const u = await requireAuth(request);
const db = getDb();
const beitrag = db.prepare(
'SELECT * FROM beitraege WHERE id = ? AND verein_id = ?'
).get(params.id, u.verein_id);
if (!beitrag) throw error(404, 'Beitrag nicht gefunden');
return json(row(beitrag as Record<string, unknown>));
}
export async function PUT({ request, params }) {
const u = await requireAuth(request);
const db = getDb();
const existing = db.prepare(
'SELECT id FROM beitraege WHERE id = ? AND verein_id = ?'
).get(params.id, u.verein_id);
if (!existing) throw error(404, 'Beitrag nicht gefunden');
const body = await request.json();
db.prepare(`
UPDATE beitraege SET
name = ?, betrag = ?, rhythmus = ?, beschreibung = ?,
updated = strftime('%Y-%m-%dT%H:%M:%SZ','now')
WHERE id = ? AND verein_id = ?
`).run(
body.name,
body.betrag,
body.rhythmus ?? 'jaehrlich',
body.beschreibung ?? null,
params.id,
u.verein_id
);
const beitrag = db.prepare('SELECT * FROM beitraege WHERE id = ?').get(params.id);
return json(row(beitrag as Record<string, unknown>));
}
export async function DELETE({ request, params }) {
const u = await requireAuth(request);
const db = getDb();
const result = db.prepare(
'DELETE FROM beitraege WHERE id = ? AND verein_id = ?'
).run(params.id, u.verein_id);
if (result.changes === 0) throw error(404, 'Beitrag nicht gefunden');
return new Response(null, { status: 204 });
}

View file

@ -0,0 +1,23 @@
import { json, error } from '@sveltejs/kit';
import { getDb, newId, row } from '$lib/server/db';
import { requireAuth } from '$lib/server/auth';
export async function POST({ request }) {
const u = await requireAuth(request);
const db = getDb();
const body = await request.json();
const id = newId();
db.prepare(`
INSERT INTO einladungen (id, verein_id, rolle)
VALUES (?, ?, ?)
`).run(
id,
u.verein_id,
body.rolle ?? 'trainer'
);
const einladung = db.prepare('SELECT * FROM einladungen WHERE id = ?').get(id);
return json(row(einladung as Record<string, unknown>), { status: 201 });
}

View file

@ -0,0 +1,62 @@
import { json, error } from '@sveltejs/kit';
import { getDb, newId, row } from '$lib/server/db';
import { hashPassword, signJwt } from '$lib/server/auth';
export async function GET({ params }) {
const db = getDb();
const einladung = db.prepare(`
SELECT e.*, v.name as vereinName
FROM einladungen e JOIN vereine v ON v.id = e.verein_id
WHERE e.token = ? AND e.genutzt = 0
`).get(params.token);
if (!einladung) throw error(404, 'Einladung nicht gefunden oder bereits verwendet');
return json(row(einladung as Record<string, unknown>));
}
export async function POST({ request, params }) {
const db = getDb();
const body = await request.json();
if (!body.email || !body.password || !body.name) throw error(400, 'E-Mail, Passwort und Name sind Pflichtfelder');
if (body.password.length < 8) throw error(400, 'Passwort mindestens 8 Zeichen');
const einladung = db.prepare(
'SELECT * FROM einladungen WHERE token = ? AND genutzt = 0'
).get(params.token) as Record<string, unknown> | undefined;
if (!einladung) throw error(404, 'Einladung nicht gefunden oder bereits verwendet');
const verein_id = einladung.verein_id as string;
const rolle = einladung.rolle as string;
const existing = db.prepare('SELECT id FROM users WHERE email = ?').get(body.email.toLowerCase());
if (existing) throw error(409, 'E-Mail bereits registriert');
const userId = newId();
const hash = await hashPassword(body.password);
db.prepare(`
INSERT INTO users (id, verein_id, email, password_hash, name, rolle)
VALUES (?, ?, ?, ?, ?, ?)
`).run(userId, verein_id, body.email.toLowerCase(), hash, body.name, rolle);
db.prepare(
`UPDATE einladungen SET genutzt = 1, updated = strftime('%Y-%m-%dT%H:%M:%SZ','now') WHERE token = ?`
).run(params.token);
const token = await signJwt({
sub: userId,
verein_id,
rolle,
name: body.name,
email: body.email.toLowerCase()
});
return json(
{ token, id: userId, verein_id, rolle, name: body.name, email: body.email.toLowerCase() },
{ status: 201 }
);
}

View file

@ -0,0 +1,58 @@
import { json, error } from '@sveltejs/kit';
import { getDb, newId, rows } from '$lib/server/db';
import { requireAuth } from '$lib/server/auth';
export async function GET({ request }) {
const u = await requireAuth(request);
const db = getDb();
const items = db.prepare(`
SELECT e.*, m.vorname, m.nachname, b.name as beitrag_name
FROM einzuege e
JOIN mitglieder m ON m.id = e.mitglied_id
JOIN beitraege b ON b.id = e.beitrag_id
WHERE e.verein_id = ?
ORDER BY e.faellig_am
`).all(u.verein_id);
return json(rows(items as Record<string, unknown>[]));
}
export async function POST({ request }) {
const u = await requireAuth(request);
const db = getDb();
const body = await request.json();
const einzuege = Array.isArray(body) ? body : [body];
if (einzuege.length === 0) throw error(400, 'Keine Einzüge angegeben');
const insert = db.prepare(`
INSERT INTO einzuege (id, verein_id, mitglied_id, beitrag_id, betrag, faellig_am, status)
VALUES (?, ?, ?, ?, ?, ?, ?)
`);
const insertMany = db.transaction((items: typeof einzuege) => {
for (const e of items) {
insert.run(
newId(),
u.verein_id,
e.mitglied_id,
e.beitrag_id,
e.betrag,
e.faellig_am ?? null,
e.status ?? 'ausstehend'
);
}
});
insertMany(einzuege);
const created = db.prepare(`
SELECT e.*, m.vorname, m.nachname, b.name as beitrag_name
FROM einzuege e
JOIN mitglieder m ON m.id = e.mitglied_id
JOIN beitraege b ON b.id = e.beitrag_id
WHERE e.verein_id = ?
ORDER BY e.faellig_am
`).all(u.verein_id);
return json(rows(created as Record<string, unknown>[]), { status: 201 });
}

View file

@ -0,0 +1,26 @@
import { readFileSync, existsSync } from 'fs';
import { join } from 'path';
const UPLOAD_DIR = process.env.UPLOAD_DIR || './data/uploads';
export async function GET({ params }) {
const filePath = join(UPLOAD_DIR, params.path);
if (!existsSync(filePath)) return new Response(null, { status: 404 });
const data = readFileSync(filePath);
const ext = filePath.split('.').pop()?.toLowerCase() || '';
const mime: Record<string, string> = {
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
png: 'image/png',
gif: 'image/gif',
webp: 'image/webp',
mp4: 'video/mp4',
mov: 'video/quicktime'
};
return new Response(data, {
headers: {
'Content-Type': mime[ext] || 'application/octet-stream',
'Cache-Control': 'public, max-age=31536000'
}
});
}

View file

@ -0,0 +1,36 @@
import { json, error } from '@sveltejs/kit';
import { getDb, newId, rows, row, toArr } from '$lib/server/db';
import { requireAuth } from '$lib/server/auth';
export async function GET({ request }) {
const u = await requireAuth(request);
const db = getDb();
const items = db.prepare(
'SELECT * FROM gruppen WHERE verein_id = ? ORDER BY name'
).all(u.verein_id);
return json(rows(items as Record<string, unknown>[]));
}
export async function POST({ request }) {
const u = await requireAuth(request);
const db = getDb();
const body = await request.json();
if (!body.name) throw error(400, 'Name ist ein Pflichtfeld');
const id = newId();
db.prepare(`
INSERT INTO gruppen (id, verein_id, name, beschreibung, trainer_ids)
VALUES (?, ?, ?, ?, ?)
`).run(
id,
u.verein_id,
body.name,
body.beschreibung ?? null,
toArr(body.trainer_ids)
);
const gruppe = db.prepare('SELECT * FROM gruppen WHERE id = ?').get(id);
return json(row(gruppe as Record<string, unknown>), { status: 201 });
}

View file

@ -0,0 +1,51 @@
import { json, error } from '@sveltejs/kit';
import { getDb, row, toArr } from '$lib/server/db';
import { requireAuth } from '$lib/server/auth';
export async function GET({ request, params }) {
const u = await requireAuth(request);
const db = getDb();
const gruppe = db.prepare(
'SELECT * FROM gruppen WHERE id = ? AND verein_id = ?'
).get(params.id, u.verein_id);
if (!gruppe) throw error(404, 'Gruppe nicht gefunden');
return json(row(gruppe as Record<string, unknown>));
}
export async function PUT({ request, params }) {
const u = await requireAuth(request);
const db = getDb();
const existing = db.prepare(
'SELECT id FROM gruppen WHERE id = ? AND verein_id = ?'
).get(params.id, u.verein_id);
if (!existing) throw error(404, 'Gruppe nicht gefunden');
const body = await request.json();
db.prepare(`
UPDATE gruppen SET
name = ?, beschreibung = ?, trainer_ids = ?,
updated = strftime('%Y-%m-%dT%H:%M:%SZ','now')
WHERE id = ? AND verein_id = ?
`).run(
body.name,
body.beschreibung ?? null,
toArr(body.trainer_ids),
params.id,
u.verein_id
);
const gruppe = db.prepare('SELECT * FROM gruppen WHERE id = ?').get(params.id);
return json(row(gruppe as Record<string, unknown>));
}
export async function DELETE({ request, params }) {
const u = await requireAuth(request);
const db = getDb();
const result = db.prepare(
'DELETE FROM gruppen WHERE id = ? AND verein_id = ?'
).run(params.id, u.verein_id);
if (result.changes === 0) throw error(404, 'Gruppe nicht gefunden');
return new Response(null, { status: 204 });
}

View file

@ -0,0 +1,53 @@
import ical from 'ical-generator';
import { getDb } from '$lib/server/db';
export async function GET({ params }) {
const { vereinId } = params;
const db = getDb();
const verein = db.prepare('SELECT * FROM vereine WHERE id = ?').get(vereinId) as { name: string } | undefined;
if (!verein) {
return new Response('Verein nicht gefunden.', { status: 404 });
}
// Termine der nächsten 365 Tage laden
const von = new Date().toISOString();
const bis = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString();
const termine = db.prepare(
`SELECT * FROM termine WHERE verein_id = ? AND beginn >= ? AND beginn <= ? ORDER BY beginn LIMIT 500`
).all(vereinId, von, bis) as { id: string; titel: string; beginn: string; ende: string | null; ort: string | null; beschreibung: string | null }[];
// iCal-Kalender aufbauen
const cal = ical({
name: verein.name ?? 'vereins.haus',
prodId: '//vereins.haus//Vereinskalender//DE',
timezone: 'Europe/Berlin',
ttl: 60 * 60, // Clients aktualisieren stündlich
});
for (const t of termine) {
const start = new Date(t.beginn);
const end = t.ende
? new Date(t.ende)
: new Date(start.getTime() + 60 * 60 * 1000); // Default: 1 Stunde
const ev = cal.createEvent({
start,
end,
summary: t.titel,
location: t.ort ?? undefined,
description: t.beschreibung ?? undefined,
});
ev.uid(t.id + '@vereins.haus');
}
return new Response(cal.toString(), {
headers: {
'Content-Type': 'text/calendar; charset=utf-8',
'Content-Disposition': `attachment; filename="${encodeURIComponent(verein.name ?? 'kalender')}.ics"`,
'Cache-Control': 'no-cache',
},
});
}

View file

@ -0,0 +1,65 @@
import { json, error } from '@sveltejs/kit';
import { getDb, newId, rows, row, toArr } from '$lib/server/db';
import { requireAuth } from '$lib/server/auth';
export async function GET({ request, url }) {
const u = await requireAuth(request);
const db = getDb();
const status = url.searchParams.get('status');
let items;
if (status) {
items = db.prepare(
'SELECT * FROM mitglieder WHERE verein_id = ? AND status = ? ORDER BY nachname, vorname'
).all(u.verein_id, status);
} else {
items = db.prepare(
'SELECT * FROM mitglieder WHERE verein_id = ? ORDER BY nachname, vorname'
).all(u.verein_id);
}
return json(rows(items as Record<string, unknown>[]));
}
export async function POST({ request }) {
const u = await requireAuth(request);
const db = getDb();
const body = await request.json();
if (!body.vorname || !body.nachname) throw error(400, 'Vorname und Nachname sind Pflichtfelder');
const id = newId();
db.prepare(`
INSERT INTO mitglieder (
id, verein_id, vorname, nachname, email, telefon,
geburtsdatum, eintrittsdatum, austrittsdatum,
strasse, plz, ort, iban, bic,
gruppe_ids, status, notizen,
mandatsreferenz, mandatsdatum
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
id,
u.verein_id,
body.vorname,
body.nachname,
body.email ?? null,
body.telefon ?? null,
body.geburtsdatum ?? null,
body.eintrittsdatum ?? null,
body.austrittsdatum ?? null,
body.strasse ?? null,
body.plz ?? null,
body.ort ?? null,
body.iban ?? null,
body.bic ?? null,
toArr(body.gruppe_ids),
body.status ?? 'aktiv',
body.notizen ?? null,
body.mandatsreferenz ?? null,
body.mandatsdatum ?? null
);
const mitglied = db.prepare('SELECT * FROM mitglieder WHERE id = ?').get(id);
return json(row(mitglied as Record<string, unknown>), { status: 201 });
}

View file

@ -0,0 +1,69 @@
import { json, error } from '@sveltejs/kit';
import { getDb, row, toArr } from '$lib/server/db';
import { requireAuth } from '$lib/server/auth';
export async function GET({ request, params }) {
const u = await requireAuth(request);
const db = getDb();
const mitglied = db.prepare(
'SELECT * FROM mitglieder WHERE id = ? AND verein_id = ?'
).get(params.id, u.verein_id);
if (!mitglied) throw error(404, 'Mitglied nicht gefunden');
return json(row(mitglied as Record<string, unknown>));
}
export async function PUT({ request, params }) {
const u = await requireAuth(request);
const db = getDb();
const existing = db.prepare(
'SELECT id FROM mitglieder WHERE id = ? AND verein_id = ?'
).get(params.id, u.verein_id);
if (!existing) throw error(404, 'Mitglied nicht gefunden');
const body = await request.json();
db.prepare(`
UPDATE mitglieder SET
vorname = ?, nachname = ?, email = ?, telefon = ?,
geburtsdatum = ?, eintrittsdatum = ?, austrittsdatum = ?,
strasse = ?, plz = ?, ort = ?, iban = ?, bic = ?,
gruppe_ids = ?, status = ?, notizen = ?,
mandatsreferenz = ?, mandatsdatum = ?,
updated = strftime('%Y-%m-%dT%H:%M:%SZ','now')
WHERE id = ? AND verein_id = ?
`).run(
body.vorname,
body.nachname,
body.email ?? null,
body.telefon ?? null,
body.geburtsdatum ?? null,
body.eintrittsdatum ?? null,
body.austrittsdatum ?? null,
body.strasse ?? null,
body.plz ?? null,
body.ort ?? null,
body.iban ?? null,
body.bic ?? null,
toArr(body.gruppe_ids),
body.status ?? 'aktiv',
body.notizen ?? null,
body.mandatsreferenz ?? null,
body.mandatsdatum ?? null,
params.id,
u.verein_id
);
const mitglied = db.prepare('SELECT * FROM mitglieder WHERE id = ?').get(params.id);
return json(row(mitglied as Record<string, unknown>));
}
export async function DELETE({ request, params }) {
const u = await requireAuth(request);
const db = getDb();
const result = db.prepare(
'DELETE FROM mitglieder WHERE id = ? AND verein_id = ?'
).run(params.id, u.verein_id);
if (result.changes === 0) throw error(404, 'Mitglied nicht gefunden');
return new Response(null, { status: 204 });
}

View file

@ -0,0 +1,39 @@
import { json, error } from '@sveltejs/kit';
import { getDb, newId, rows, row, toArr } from '$lib/server/db';
import { requireAuth } from '$lib/server/auth';
export async function GET({ request }) {
const u = await requireAuth(request);
const db = getDb();
const items = db.prepare(
'SELECT * FROM nachrichten WHERE verein_id = ? ORDER BY gesendet_am DESC, created DESC'
).all(u.verein_id);
return json(rows(items as Record<string, unknown>[]));
}
export async function POST({ request }) {
const u = await requireAuth(request);
const db = getDb();
const body = await request.json();
if (!body.betreff) throw error(400, 'Betreff ist ein Pflichtfeld');
const id = newId();
const gesendet_am = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
db.prepare(`
INSERT INTO nachrichten (id, verein_id, autor_id, betreff, text, gruppe_ids, gesendet_am)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(
id,
u.verein_id,
u.sub,
body.betreff,
body.text ?? '',
toArr(body.gruppe_ids),
gesendet_am
);
const nachricht = db.prepare('SELECT * FROM nachrichten WHERE id = ?').get(id);
return json(row(nachricht as Record<string, unknown>), { status: 201 });
}

View file

@ -0,0 +1,13 @@
import { error } from '@sveltejs/kit';
import { getDb } from '$lib/server/db';
import { requireAuth } from '$lib/server/auth';
export async function DELETE({ request, params }) {
const u = await requireAuth(request);
const db = getDb();
const result = db.prepare(
'DELETE FROM nachrichten WHERE id = ? AND verein_id = ?'
).run(params.id, u.verein_id);
if (result.changes === 0) throw error(404, 'Nachricht nicht gefunden');
return new Response(null, { status: 204 });
}

View file

@ -0,0 +1,68 @@
import { json, error } from '@sveltejs/kit';
import { getDb, newId, rows, row } from '$lib/server/db';
import { requireAuth } from '$lib/server/auth';
import { writeFileSync, mkdirSync } from 'fs';
import { join } from 'path';
const UPLOAD_DIR = process.env.UPLOAD_DIR || './data/uploads';
export async function GET({ request }) {
const u = await requireAuth(request);
const db = getDb();
const items = db.prepare(
'SELECT * FROM neuigkeiten WHERE verein_id = ? ORDER BY created DESC'
).all(u.verein_id);
return json(rows(items as Record<string, unknown>[]));
}
export async function POST({ request }) {
const u = await requireAuth(request);
const db = getDb();
const formData = await request.formData();
const text = formData.get('text') as string | null;
const gruppeIdsRaw = formData.get('gruppe_ids') as string | null;
const terminId = formData.get('termin_id') as string | null;
let gruppe_ids: string[] = [];
if (gruppeIdsRaw) {
try { gruppe_ids = JSON.parse(gruppeIdsRaw); } catch { gruppe_ids = []; }
}
const id = newId();
const uploadPath = join(UPLOAD_DIR, u.verein_id, id);
const medien: string[] = [];
const files = formData.getAll('medien') as File[];
if (files.length > 0) {
mkdirSync(uploadPath, { recursive: true });
for (const file of files) {
if (!(file instanceof File)) continue;
const ext = file.name.split('.').pop() || 'bin';
const filename = `${newId()}.${ext}`;
const buffer = Buffer.from(await file.arrayBuffer());
writeFileSync(join(uploadPath, filename), buffer);
medien.push(filename);
}
}
if (!text && medien.length === 0) throw error(400, 'Text oder Medien sind erforderlich');
db.prepare(`
INSERT INTO neuigkeiten (id, verein_id, autor_id, autor_name, text, medien, gruppe_ids, termin_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).run(
id,
u.verein_id,
u.sub,
u.name,
text ?? null,
JSON.stringify(medien),
JSON.stringify(gruppe_ids),
terminId ?? null
);
const neuigkeit = db.prepare('SELECT * FROM neuigkeiten WHERE id = ?').get(id);
return json(row(neuigkeit as Record<string, unknown>), { status: 201 });
}

View file

@ -0,0 +1,27 @@
import { error } from '@sveltejs/kit';
import { getDb, parseArr } from '$lib/server/db';
import { requireAuth } from '$lib/server/auth';
import { rmSync, existsSync } from 'fs';
import { join } from 'path';
const UPLOAD_DIR = process.env.UPLOAD_DIR || './data/uploads';
export async function DELETE({ request, params }) {
const u = await requireAuth(request);
const db = getDb();
const neuigkeit = db.prepare(
'SELECT * FROM neuigkeiten WHERE id = ? AND verein_id = ?'
).get(params.id, u.verein_id) as Record<string, unknown> | undefined;
if (!neuigkeit) throw error(404, 'Neuigkeit nicht gefunden');
const uploadPath = join(UPLOAD_DIR, u.verein_id, params.id);
if (existsSync(uploadPath)) {
rmSync(uploadPath, { recursive: true, force: true });
}
db.prepare('DELETE FROM neuigkeiten WHERE id = ? AND verein_id = ?').run(params.id, u.verein_id);
return new Response(null, { status: 204 });
}

View file

@ -0,0 +1,58 @@
import { json, error } from '@sveltejs/kit';
import { getDb, newId, rows, row } from '$lib/server/db';
import { requireAuth } from '$lib/server/auth';
export async function GET({ request, url }) {
const u = await requireAuth(request);
const db = getDb();
const ort_id = url.searchParams.get('ort_id');
let items;
if (ort_id) {
items = db.prepare(`
SELECT a.* FROM ort_ausfaelle a
JOIN veranstaltungsorte o ON o.id = a.ort_id
WHERE a.ort_id = ? AND o.verein_id = ?
ORDER BY a.von
`).all(ort_id, u.verein_id);
} else {
items = db.prepare(`
SELECT a.* FROM ort_ausfaelle a
JOIN veranstaltungsorte o ON o.id = a.ort_id
WHERE o.verein_id = ?
ORDER BY a.von
`).all(u.verein_id);
}
return json(rows(items as Record<string, unknown>[]));
}
export async function POST({ request }) {
const u = await requireAuth(request);
const db = getDb();
const body = await request.json();
if (!body.ort_id || !body.von || !body.bis) throw error(400, 'ort_id, von und bis sind Pflichtfelder');
const ort = db.prepare(
'SELECT id FROM veranstaltungsorte WHERE id = ? AND verein_id = ?'
).get(body.ort_id, u.verein_id);
if (!ort) throw error(404, 'Ort nicht gefunden');
const id = newId();
db.prepare(`
INSERT INTO ort_ausfaelle (id, ort_id, von, bis, grund)
VALUES (?, ?, ?, ?, ?)
`).run(
id,
body.ort_id,
body.von,
body.bis,
body.grund ?? null
);
const ausfall = db.prepare('SELECT * FROM ort_ausfaelle WHERE id = ?').get(id);
return json(row(ausfall as Record<string, unknown>), { status: 201 });
}

View file

@ -0,0 +1,21 @@
import { error } from '@sveltejs/kit';
import { getDb } from '$lib/server/db';
import { requireAuth } from '$lib/server/auth';
export async function DELETE({ request, params }) {
const u = await requireAuth(request);
const db = getDb();
// Verify the ausfall belongs to an ort in the user's Verein
const ausfall = db.prepare(`
SELECT a.id FROM ort_ausfaelle a
JOIN veranstaltungsorte o ON o.id = a.ort_id
WHERE a.id = ? AND o.verein_id = ?
`).get(params.id, u.verein_id);
if (!ausfall) throw error(404, 'Ausfall nicht gefunden');
db.prepare('DELETE FROM ort_ausfaelle WHERE id = ?').run(params.id);
return new Response(null, { status: 204 });
}

View file

@ -0,0 +1,37 @@
import { json, error } from '@sveltejs/kit';
import { getDb, newId, rows, row } from '$lib/server/db';
import { requireAuth } from '$lib/server/auth';
export async function GET({ request }) {
const u = await requireAuth(request);
const db = getDb();
const items = db.prepare(
'SELECT * FROM veranstaltungsorte WHERE verein_id = ? ORDER BY name'
).all(u.verein_id);
return json(rows(items as Record<string, unknown>[]));
}
export async function POST({ request }) {
const u = await requireAuth(request);
const db = getDb();
const body = await request.json();
if (!body.name) throw error(400, 'Name ist ein Pflichtfeld');
const id = newId();
db.prepare(`
INSERT INTO veranstaltungsorte (id, verein_id, name, adresse, typ, aktiv)
VALUES (?, ?, ?, ?, ?, ?)
`).run(
id,
u.verein_id,
body.name,
body.adresse ?? null,
body.typ ?? 'sonstiges',
body.aktiv !== false ? 1 : 0
);
const ort = db.prepare('SELECT * FROM veranstaltungsorte WHERE id = ?').get(id);
return json(row(ort as Record<string, unknown>), { status: 201 });
}

View file

@ -0,0 +1,52 @@
import { json, error } from '@sveltejs/kit';
import { getDb, row } from '$lib/server/db';
import { requireAuth } from '$lib/server/auth';
export async function GET({ request, params }) {
const u = await requireAuth(request);
const db = getDb();
const ort = db.prepare(
'SELECT * FROM veranstaltungsorte WHERE id = ? AND verein_id = ?'
).get(params.id, u.verein_id);
if (!ort) throw error(404, 'Ort nicht gefunden');
return json(row(ort as Record<string, unknown>));
}
export async function PUT({ request, params }) {
const u = await requireAuth(request);
const db = getDb();
const existing = db.prepare(
'SELECT id FROM veranstaltungsorte WHERE id = ? AND verein_id = ?'
).get(params.id, u.verein_id);
if (!existing) throw error(404, 'Ort nicht gefunden');
const body = await request.json();
db.prepare(`
UPDATE veranstaltungsorte SET
name = ?, adresse = ?, typ = ?, aktiv = ?,
updated = strftime('%Y-%m-%dT%H:%M:%SZ','now')
WHERE id = ? AND verein_id = ?
`).run(
body.name,
body.adresse ?? null,
body.typ ?? 'sonstiges',
body.aktiv !== false ? 1 : 0,
params.id,
u.verein_id
);
const ort = db.prepare('SELECT * FROM veranstaltungsorte WHERE id = ?').get(params.id);
return json(row(ort as Record<string, unknown>));
}
export async function DELETE({ request, params }) {
const u = await requireAuth(request);
const db = getDb();
const result = db.prepare(
'DELETE FROM veranstaltungsorte WHERE id = ? AND verein_id = ?'
).run(params.id, u.verein_id);
if (result.changes === 0) throw error(404, 'Ort nicht gefunden');
return new Response(null, { status: 204 });
}

View file

@ -0,0 +1,6 @@
import { json } from '@sveltejs/kit';
import { env } from '$env/dynamic/private';
export async function GET() {
return json({ publicKey: env.PUBLIC_VAPID_KEY ?? '' });
}

View file

@ -0,0 +1,53 @@
import { json } from '@sveltejs/kit';
import { env } from '$env/dynamic/private';
import webpush from 'web-push';
import { requireAuth } from '$lib/server/auth';
import { getDb } from '$lib/server/db';
export async function POST({ request }) {
const authUser = await requireAuth(request).catch(() => null);
if (!authUser) return json({ error: 'Nicht authentifiziert.' }, { status: 401 });
const { titel, body, url = '/nachrichten' } = await request.json();
if (!titel) return json({ error: 'Titel fehlt.' }, { status: 400 });
const vapidPublic = env.PUBLIC_VAPID_KEY ?? '';
const vapidPrivate = env.VAPID_PRIVATE_KEY ?? '';
const vapidSubject = env.VAPID_SUBJECT ?? 'mailto:info@vereins.haus';
if (!vapidPublic || !vapidPrivate) {
return json({ error: 'VAPID-Keys nicht konfiguriert.' }, { status: 500 });
}
webpush.setVapidDetails(vapidSubject, vapidPublic, vapidPrivate);
const db = getDb();
const items = db.prepare(
'SELECT * FROM push_subscriptions WHERE verein_id = ?'
).all(authUser.verein_id) as { endpoint: string; p256dh: string; auth: string; id: string }[];
if (!items.length) return json({ sent: 0 });
const payload = JSON.stringify({ title: titel, body, url });
let sent = 0;
await Promise.allSettled(
items.map(async (sub) => {
try {
await webpush.sendNotification(
{ endpoint: sub.endpoint, keys: { p256dh: sub.p256dh, auth: sub.auth } },
payload,
);
sent++;
} catch (err: unknown) {
// 410 Gone = Subscription abgelaufen → löschen
if ((err as { statusCode?: number }).statusCode === 410) {
db.prepare('DELETE FROM push_subscriptions WHERE id = ?').run(sub.id);
}
}
}),
);
return json({ sent });
}

View file

@ -0,0 +1,44 @@
import { json } from '@sveltejs/kit';
import { requireAuth } from '$lib/server/auth';
import { getDb, newId } from '$lib/server/db';
export async function POST({ request }) {
const authUser = await requireAuth(request).catch(() => null);
if (!authUser) return json({ error: 'Nicht authentifiziert.' }, { status: 401 });
const { subscription } = await request.json();
if (!subscription?.endpoint) {
return json({ error: 'Ungültige Subscription.' }, { status: 400 });
}
const db = getDb();
// Alte Subscriptions dieses Users löschen (Gerätewechsel)
db.prepare('DELETE FROM push_subscriptions WHERE user_id = ?').run(authUser.sub);
// Neue Subscription speichern
db.prepare(`
INSERT INTO push_subscriptions (id, user_id, verein_id, endpoint, p256dh, auth)
VALUES (?, ?, ?, ?, ?, ?)
`).run(
newId(),
authUser.sub,
authUser.verein_id,
subscription.endpoint,
subscription.keys.p256dh,
subscription.keys.auth,
);
return json({ success: true });
}
export async function DELETE({ request }) {
const authUser = await requireAuth(request).catch(() => null);
if (!authUser) return json({ success: true });
const db = getDb();
db.prepare('DELETE FROM push_subscriptions WHERE user_id = ?').run(authUser.sub);
return json({ success: true });
}

View file

@ -0,0 +1,58 @@
import { json, error } from '@sveltejs/kit';
import { getDb, newId, rows, row } from '$lib/server/db';
import { requireAuth } from '$lib/server/auth';
export async function GET({ request, url }) {
const u = await requireAuth(request);
const db = getDb();
const beitrag_id = url.searchParams.get('beitrag_id');
let items;
if (beitrag_id) {
items = db.prepare(`
SELECT r.* FROM reaktionen r
JOIN neuigkeiten n ON n.id = r.beitrag_id
WHERE r.beitrag_id = ? AND n.verein_id = ?
ORDER BY r.created
`).all(beitrag_id, u.verein_id);
} else {
items = db.prepare(`
SELECT r.* FROM reaktionen r
JOIN neuigkeiten n ON n.id = r.beitrag_id
WHERE n.verein_id = ?
ORDER BY r.created
`).all(u.verein_id);
}
return json(rows(items as Record<string, unknown>[]));
}
export async function POST({ request }) {
const u = await requireAuth(request);
const db = getDb();
const body = await request.json();
if (!body.beitrag_id) throw error(400, 'beitrag_id ist erforderlich');
const beitrag = db.prepare(
'SELECT id FROM neuigkeiten WHERE id = ? AND verein_id = ?'
).get(body.beitrag_id, u.verein_id);
if (!beitrag) throw error(404, 'Beitrag nicht gefunden');
const id = newId();
try {
db.prepare(`
INSERT INTO reaktionen (id, beitrag_id, user_id)
VALUES (?, ?, ?)
`).run(id, body.beitrag_id, u.sub);
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
if (msg.includes('UNIQUE')) throw error(409, 'Reaktion bereits vorhanden');
throw e;
}
const reaktion = db.prepare('SELECT * FROM reaktionen WHERE id = ?').get(id);
return json(row(reaktion as Record<string, unknown>), { status: 201 });
}

View file

@ -0,0 +1,13 @@
import { error } from '@sveltejs/kit';
import { getDb } from '$lib/server/db';
import { requireAuth } from '$lib/server/auth';
export async function DELETE({ request, params }) {
const u = await requireAuth(request);
const db = getDb();
const result = db.prepare(
'DELETE FROM reaktionen WHERE id = ? AND user_id = ?'
).run(params.id, u.sub);
if (result.changes === 0) throw error(404, 'Reaktion nicht gefunden oder keine Berechtigung');
return new Response(null, { status: 204 });
}

View file

@ -0,0 +1,63 @@
import { json, error } from '@sveltejs/kit';
import { getDb, newId, rows, row, toArr } from '$lib/server/db';
import { requireAuth } from '$lib/server/auth';
export async function GET({ request, url }) {
const u = await requireAuth(request);
const db = getDb();
const von = url.searchParams.get('von');
const bis = url.searchParams.get('bis');
let query = 'SELECT * FROM termine WHERE verein_id = ?';
const params: unknown[] = [u.verein_id];
if (von) {
query += ' AND beginn >= ?';
params.push(von);
}
if (bis) {
query += ' AND beginn <= ?';
params.push(bis);
}
query += ' ORDER BY beginn';
const items = db.prepare(query).all(...params);
return json(rows(items as Record<string, unknown>[]));
}
export async function POST({ request }) {
const u = await requireAuth(request);
const db = getDb();
const body = await request.json();
if (!body.titel || !body.beginn) throw error(400, 'Titel und Beginn sind Pflichtfelder');
const id = newId();
db.prepare(`
INSERT INTO termine (
id, verein_id, titel, beschreibung, beginn, ende,
ort, ort_id, gruppe_ids, durchfuehrender_id,
verfuegbarkeit, rrule, serie_id
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
id,
u.verein_id,
body.titel,
body.beschreibung ?? null,
body.beginn,
body.ende ?? null,
body.ort ?? null,
body.ort_id ?? null,
toArr(body.gruppe_ids),
body.durchfuehrender_id ?? null,
body.verfuegbarkeit ?? 'offen',
body.rrule ?? null,
body.serie_id ?? null
);
const termin = db.prepare('SELECT * FROM termine WHERE id = ?').get(id);
return json(row(termin as Record<string, unknown>), { status: 201 });
}

View file

@ -0,0 +1,83 @@
import { json, error } from '@sveltejs/kit';
import { getDb, row, toArr } from '$lib/server/db';
import { requireAuth } from '$lib/server/auth';
export async function GET({ request, params }) {
const u = await requireAuth(request);
const db = getDb();
const termin = db.prepare(
'SELECT * FROM termine WHERE id = ? AND verein_id = ?'
).get(params.id, u.verein_id);
if (!termin) throw error(404, 'Termin nicht gefunden');
return json(row(termin as Record<string, unknown>));
}
export async function PUT({ request, params }) {
const u = await requireAuth(request);
const db = getDb();
const existing = db.prepare(
'SELECT id FROM termine WHERE id = ? AND verein_id = ?'
).get(params.id, u.verein_id);
if (!existing) throw error(404, 'Termin nicht gefunden');
const body = await request.json();
db.prepare(`
UPDATE termine SET
titel = ?, beschreibung = ?, beginn = ?, ende = ?,
ort = ?, ort_id = ?, gruppe_ids = ?, durchfuehrender_id = ?,
verfuegbarkeit = ?, rrule = ?, serie_id = ?,
updated = strftime('%Y-%m-%dT%H:%M:%SZ','now')
WHERE id = ? AND verein_id = ?
`).run(
body.titel,
body.beschreibung ?? null,
body.beginn,
body.ende ?? null,
body.ort ?? null,
body.ort_id ?? null,
toArr(body.gruppe_ids),
body.durchfuehrender_id ?? null,
body.verfuegbarkeit ?? 'offen',
body.rrule ?? null,
body.serie_id ?? null,
params.id,
u.verein_id
);
const termin = db.prepare('SELECT * FROM termine WHERE id = ?').get(params.id);
return json(row(termin as Record<string, unknown>));
}
export async function DELETE({ request, params, url }) {
const u = await requireAuth(request);
const db = getDb();
const deleteSerie = url.searchParams.get('serie') === 'true';
if (deleteSerie) {
const termin = db.prepare(
'SELECT serie_id FROM termine WHERE id = ? AND verein_id = ?'
).get(params.id, u.verein_id) as { serie_id: string | null } | undefined;
if (!termin) throw error(404, 'Termin nicht gefunden');
if (termin.serie_id) {
db.prepare(
'DELETE FROM termine WHERE serie_id = ? AND verein_id = ?'
).run(termin.serie_id, u.verein_id);
} else {
db.prepare(
'DELETE FROM termine WHERE id = ? AND verein_id = ?'
).run(params.id, u.verein_id);
}
} else {
const result = db.prepare(
'DELETE FROM termine WHERE id = ? AND verein_id = ?'
).run(params.id, u.verein_id);
if (result.changes === 0) throw error(404, 'Termin nicht gefunden');
}
return new Response(null, { status: 204 });
}

View file

@ -0,0 +1,18 @@
import { json } from '@sveltejs/kit';
import { getDb, rows } from '$lib/server/db';
import { requireAuth } from '$lib/server/auth';
export async function GET({ request, url }) {
const u = await requireAuth(request);
const db = getDb();
const rolle = url.searchParams.get('rolle');
let query = 'SELECT id, verein_id, email, name, rolle, created FROM users WHERE verein_id = ?';
const params: unknown[] = [u.verein_id];
if (rolle) { query += ' AND rolle = ?'; params.push(rolle); }
query += ' ORDER BY name';
const users = db.prepare(query).all(...params);
return json(users);
}

View file

@ -0,0 +1,47 @@
import { json, error } from '@sveltejs/kit';
import { getDb } from '$lib/server/db';
import { requireAuth, hashPassword } from '$lib/server/auth';
export async function GET({ request, params }) {
const u = await requireAuth(request);
const db = getDb();
const row = db.prepare(
'SELECT id, verein_id, email, name, rolle, created FROM users WHERE id = ? AND verein_id = ?'
).get(params.id, u.verein_id);
if (!row) throw error(404, 'User nicht gefunden');
return json(row);
}
export async function PUT({ request, params }) {
const u = await requireAuth(request);
const db = getDb();
const body = await request.json();
const existing = db.prepare('SELECT id FROM users WHERE id = ? AND verein_id = ?').get(params.id, u.verein_id);
if (!existing) throw error(404, 'User nicht gefunden');
const fields: string[] = [];
const vals: unknown[] = [];
if (body.name !== undefined) { fields.push('name = ?'); vals.push(body.name); }
if (body.email !== undefined) { fields.push('email = ?'); vals.push(body.email.toLowerCase()); }
if (body.rolle !== undefined) { fields.push('rolle = ?'); vals.push(body.rolle || null); }
if (body.password) { fields.push('password_hash = ?'); vals.push(await hashPassword(body.password)); }
if (!fields.length) throw error(400, 'Keine Felder zum Aktualisieren');
fields.push("updated = strftime('%Y-%m-%dT%H:%M:%SZ','now')");
vals.push(params.id, u.verein_id);
db.prepare(`UPDATE users SET ${fields.join(', ')} WHERE id = ? AND verein_id = ?`).run(...vals);
const row = db.prepare('SELECT id, verein_id, email, name, rolle, created FROM users WHERE id = ?').get(params.id);
return json(row);
}
export async function DELETE({ request, params }) {
const u = await requireAuth(request);
if (u.sub === params.id) throw error(400, 'Eigenen Account nicht löschbar');
const db = getDb();
const result = db.prepare('DELETE FROM users WHERE id = ? AND verein_id = ?').run(params.id, u.verein_id);
if (result.changes === 0) throw error(404, 'User nicht gefunden');
return new Response(null, { status: 204 });
}

View file

@ -0,0 +1,39 @@
import { json, error } from '@sveltejs/kit';
import { getDb, row } from '$lib/server/db';
import { requireAuth } from '$lib/server/auth';
export async function GET({ request }) {
const u = await requireAuth(request);
const db = getDb();
const verein = db.prepare('SELECT * FROM vereine WHERE id = ?').get(u.verein_id);
if (!verein) throw error(404, 'Verein nicht gefunden');
return json(row(verein as Record<string, unknown>));
}
export async function PATCH({ request }) {
const u = await requireAuth(request);
const db = getDb();
const body = await request.json();
const allowed = [
'name', 'adresse', 'plz', 'ort', 'bundesland',
'email', 'telefon', 'website',
'glaeubigerid', 'iban', 'bic',
'dosb_mitglied'
];
const fields = Object.keys(body).filter(k => allowed.includes(k));
if (fields.length === 0) throw error(400, 'Keine gültigen Felder');
const sets = fields.map(k => `${k} = ?`).join(', ');
const vals = fields.map(k => {
if (k === 'dosb_mitglied') return body[k] ? 1 : 0;
return body[k];
});
db.prepare(`UPDATE vereine SET ${sets}, updated = strftime('%Y-%m-%dT%H:%M:%SZ','now') WHERE id = ?`)
.run(...vals, u.verein_id);
const verein = db.prepare('SELECT * FROM vereine WHERE id = ?').get(u.verein_id);
return json(row(verein as Record<string, unknown>));
}

View file

@ -0,0 +1,5 @@
import { redirect } from '@sveltejs/kit';
export function GET() {
throw redirect(301, '/favicon.svg');
}

View file

@ -0,0 +1,173 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { onMount } from 'svelte';
import { get } from 'svelte/store';
import { api } from '$lib/api';
import { user } from '$lib/user';
import type { Einladung } from '$lib/types';
const token = $derived($page.params.token as string);
let einladung = $state<Einladung | null>(null);
let vereinName = $state('');
let fehler = $state('');
let loading = $state(true);
let name = $state('');
let email = $state('');
let password = $state('');
let passwordConfirm = $state('');
let saving = $state(false);
let formError = $state('');
onMount(async () => {
if (!!get(user)) {
goto('/');
return;
}
try {
const inv = await api.get<Einladung & { vereinName: string }>('/einladungen/' + token);
einladung = inv;
vereinName = (inv as any).vereinName ?? '';
} catch {
fehler = 'Dieser Einladungslink ist ungültig oder wurde bereits verwendet.';
} finally {
loading = false;
}
});
async function registrieren() {
if (!einladung) return;
if (password !== passwordConfirm) { formError = 'Passwörter stimmen nicht überein.'; return; }
formError = ''; saving = true;
try {
const u = await api.post<any>(`/einladungen/${token}`, {
email: email.trim(),
password,
name: name.trim(),
});
user.set(u);
goto('/');
} catch (e: unknown) {
formError = e instanceof Error ? e.message : 'Registrierung fehlgeschlagen.';
} finally {
saving = false;
}
}
</script>
<svelte:head><title>Einladung — vereins.haus</title></svelte:head>
<div class="shell">
<div class="logo">
<img src="/favicon.svg" alt="" width="36" height="36" />
vereins.haus
</div>
<div class="card">
{#if loading}
<p class="hint">Prüfe Einladung…</p>
{:else if fehler}
<div class="icon-ring error"></div>
<h1>Ungültige Einladung</h1>
<p class="beschr">{fehler}</p>
{:else}
<div class="icon-ring">🎉</div>
<h1>Du wurdest eingeladen!</h1>
<p class="beschr">
Du wirst als <strong>{einladung?.rolle === 'trainer' ? 'Trainer' : 'Admin'}</strong>
zum Verein <strong>{vereinName}"</strong> hinzugefügt.
</p>
<form onsubmit={(e) => { e.preventDefault(); registrieren(); }}>
<div class="field">
<label for="name">Dein Name *</label>
<input id="name" type="text" bind:value={name} required autocomplete="name" />
</div>
<div class="field">
<label for="email">E-Mail *</label>
<input id="email" type="email" bind:value={email} required autocomplete="email" />
</div>
<div class="field">
<label for="pw">Passwort *</label>
<input id="pw" type="password" bind:value={password} required minlength="8" autocomplete="new-password" />
</div>
<div class="field">
<label for="pw2">Passwort bestätigen *</label>
<input id="pw2" type="password" bind:value={passwordConfirm} required autocomplete="new-password" />
</div>
{#if formError}
<p class="error">{formError}</p>
{/if}
<button type="submit" class="btn-primary" disabled={saving || !name || !email || !password}>
{saving ? 'Registrieren…' : 'Konto erstellen & beitreten'}
</button>
</form>
{/if}
</div>
</div>
<style>
.shell {
min-height: 100dvh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 1.5rem 1rem;
background: var(--c-bg-subtle);
}
.logo {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 1.2rem;
font-weight: 700;
color: var(--c-dark);
letter-spacing: -0.02em;
margin-bottom: 1.5rem;
}
.card {
background: var(--c-bg-card);
border: 1px solid var(--c-border);
border-radius: 16px;
padding: 2rem 1.5rem;
width: 100%;
max-width: 420px;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.icon-ring {
width: 3.5rem; height: 3.5rem; border-radius: 50%;
background: var(--c-primary-light); display: flex;
align-items: center; justify-content: center;
font-size: 1.5rem; margin: 0 auto;
}
.icon-ring.error { background: var(--c-error-bg); }
h1 { font-size: 1.2rem; font-weight: 700; color: var(--c-text); text-align: center; margin: 0; }
.beschr { font-size: 0.9rem; color: var(--c-text-muted); text-align: center; line-height: 1.55; margin: 0; }
.hint { color: var(--c-text-hint); text-align: center; }
.field { display: flex; flex-direction: column; gap: 0.3rem; margin-bottom: 0.5rem; }
label { font-size: 0.875rem; font-weight: 500; color: var(--c-text-secondary); }
input {
padding: 0.65rem 0.85rem;
border: 1.5px solid var(--c-border); border-radius: 8px;
font-size: 1rem; background: var(--c-bg-card); width: 100%; box-sizing: border-box;
transition: border-color 0.15s;
}
input:focus { outline: none; border-color: var(--c-primary); }
.error { color: var(--c-error); font-size: 0.875rem; margin: 0; }
.btn-primary {
width: 100%; padding: 0.75rem; background: var(--c-primary); color: var(--c-bg-card);
border: none; border-radius: 8px; font-size: 1rem; font-weight: 600;
cursor: pointer; margin-top: 0.25rem; transition: background 0.15s;
}
.btn-primary:hover:not(:disabled) { background: var(--c-primary-dark); }
.btn-primary:disabled { opacity: 0.55; cursor: not-allowed; }
</style>

View file

@ -0,0 +1,334 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import { get } from 'svelte/store';
import { api } from '$lib/api';
import { user } from '$lib/user';
let schritt = $state(1);
let loading = $state(false);
let error = $state('');
// Formular
let vereinsname = $state('');
let ort = $state('');
let fertigName = $state('');
onMount(() => {
const u = get(user);
if (!u) {
goto('/login');
return;
}
if (u.verein_id) {
goto('/');
return;
}
// Vereinsname aus Registration vorbelegen
vereinsname = u.name ?? '';
});
async function vereinAnlegen() {
if (!vereinsname.trim()) return;
error = ''; loading = true;
try {
const updated = await api.post<any>('/onboarding/verein', {
name: vereinsname.trim(),
ort: ort.trim() || null,
});
// user store mit aktualisiertem verein_id updaten
const u = get(user);
if (u) user.set({ ...u, verein_id: updated.verein_id });
fertigName = vereinsname.trim();
schritt = 3;
} catch (e: unknown) {
error = e instanceof Error ? e.message : 'Fehler beim Anlegen.';
} finally {
loading = false;
}
}
</script>
<svelte:head><title>Einrichtung — vereins.haus</title></svelte:head>
<div class="shell">
<div class="logo">
<img src="/favicon.svg" alt="" width="36" height="36" />
vereins.haus
</div>
<div class="card">
<!-- Fortschritt -->
<div class="dots">
{#each [1, 2, 3] as s}
<span class="dot" class:active={schritt >= s}></span>
{/each}
</div>
{#if schritt === 1}
<!-- Willkommen -->
<div class="step">
<div class="icon-ring">👋</div>
<h1>Schön, dass du dabei bist!</h1>
<p class="beschr">
vereins.haus hilft deinem Vorstand, Mitglieder, Termine und
Beiträge zu verwalten übersichtlich, mobil und ohne Excel.
</p>
<p class="beschr">
Die Einrichtung dauert weniger als eine Minute.
</p>
<button class="btn-primary" onclick={() => (schritt = 2)}>
Los geht's →
</button>
</div>
{:else if schritt === 2}
<!-- Verein anlegen -->
<div class="step">
<h1>Dein Verein</h1>
<p class="beschr">Wie heißt dein Verein? Du kannst alle Angaben später noch ändern.</p>
<form onsubmit={(e) => { e.preventDefault(); vereinAnlegen(); }}>
<div class="field">
<label for="vname">Name des Vereins *</label>
<input
id="vname"
type="text"
bind:value={vereinsname}
placeholder="z. B. TSV Musterstadt 1923"
required
/>
</div>
<div class="field">
<label for="vort">Ort</label>
<input
id="vort"
type="text"
bind:value={ort}
placeholder="z. B. Musterstadt"
/>
</div>
{#if error}
<p class="error">{error}</p>
{/if}
<div class="btn-row">
<button type="button" class="btn-ghost" onclick={() => (schritt = 1)}>
← Zurück
</button>
<button type="submit" class="btn-primary" disabled={loading || !vereinsname.trim()}>
{loading ? 'Anlegen…' : 'Weiter →'}
</button>
</div>
</form>
</div>
{:else}
<!-- Fertig -->
<div class="step">
<div class="icon-ring success"></div>
<h1>Alles bereit!</h1>
<p class="beschr">
<strong>{fertigName}"</strong> wurde eingerichtet. Du kannst jetzt loslegen.
</p>
<ul class="naechstes">
<li>
<span class="naechstes-icon">👤</span>
<div>
<strong>Mitglieder anlegen</strong>
<span>Namen, E-Mail und IBAN für den SEPA-Einzug</span>
</div>
</li>
<li>
<span class="naechstes-icon">💶</span>
<div>
<strong>Beitragsarten definieren</strong>
<span>Jahresbeitrag, Sonderumlage mit SEPA-Export</span>
</div>
</li>
<li>
<span class="naechstes-icon">📅</span>
<div>
<strong>Termine eintragen</strong>
<span>Versammlung, Training, Ausflug</span>
</div>
</li>
</ul>
<button class="btn-primary" onclick={() => goto('/')}>
Zum Dashboard →
</button>
</div>
{/if}
</div>
</div>
<style>
.shell {
min-height: 100dvh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 1.5rem 1rem;
background: var(--c-bg-subtle);
}
.logo {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 1.2rem;
font-weight: 700;
color: var(--c-dark);
letter-spacing: -0.02em;
margin-bottom: 1.5rem;
}
.card {
background: var(--c-bg-card);
border: 1px solid var(--c-border);
border-radius: 16px;
padding: 2rem 1.5rem;
width: 100%;
max-width: 420px;
}
.dots {
display: flex;
gap: 0.5rem;
justify-content: center;
margin-bottom: 1.75rem;
}
.dot {
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
background: var(--c-border);
transition: background 0.3s;
}
.dot.active { background: var(--c-primary); }
.step {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.icon-ring {
width: 3.5rem;
height: 3.5rem;
border-radius: 50%;
background: var(--c-primary-light);
display: flex;
align-items: center;
justify-content: center;
font-size: 1.5rem;
margin: 0 auto 0.25rem;
}
.icon-ring.success {
background: var(--c-success-bg);
color: var(--c-success);
font-size: 1.6rem;
font-weight: 700;
}
h1 {
font-size: 1.3rem;
font-weight: 700;
color: var(--c-text);
text-align: center;
margin: 0;
}
.beschr {
font-size: 0.9rem;
color: var(--c-text-muted);
text-align: center;
line-height: 1.55;
margin: 0;
}
.field {
display: flex;
flex-direction: column;
gap: 0.3rem;
margin-bottom: 0.85rem;
}
label { font-size: 0.875rem; font-weight: 500; color: var(--c-text-secondary); }
input {
padding: 0.65rem 0.85rem;
border: 1.5px solid var(--c-border);
border-radius: 8px;
font-size: 1rem;
background: var(--c-bg-card);
width: 100%;
box-sizing: border-box;
transition: border-color 0.15s;
}
input:focus { outline: none; border-color: var(--c-primary); }
.error { color: var(--c-error); font-size: 0.875rem; margin: 0; }
.btn-row {
display: flex;
gap: 0.75rem;
margin-top: 0.5rem;
}
.btn-primary {
flex: 1;
padding: 0.75rem;
background: var(--c-primary);
color: var(--c-bg-card);
border: none;
border-radius: 8px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: background 0.15s;
}
.btn-primary:hover:not(:disabled) { background: var(--c-primary-dark); }
.btn-primary:disabled { opacity: 0.55; cursor: not-allowed; }
.btn-ghost {
padding: 0.75rem 1rem;
background: none;
border: 1.5px solid var(--c-border);
border-radius: 8px;
font-size: 1rem;
color: var(--c-text-muted);
cursor: pointer;
}
.naechstes {
list-style: none;
padding: 0;
display: flex;
flex-direction: column;
gap: 0;
border: 1px solid var(--c-border);
border-radius: 10px;
overflow: hidden;
margin: 0.25rem 0;
}
.naechstes li {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--c-bg);
}
.naechstes li:last-child { border-bottom: none; }
.naechstes-icon { font-size: 1.1rem; flex-shrink: 0; }
.naechstes div {
display: flex;
flex-direction: column;
gap: 0.1rem;
}
.naechstes strong { font-size: 0.875rem; color: var(--c-text); }
.naechstes span { font-size: 0.78rem; color: var(--c-text-hint); }
</style>

45
app/src/sw.ts Normal file
View file

@ -0,0 +1,45 @@
/// <reference lib="webworker" />
import { cleanupOutdatedCaches, precacheAndRoute } from 'workbox-precaching';
import { registerRoute } from 'workbox-routing';
import { NetworkFirst } from 'workbox-strategies';
declare const self: ServiceWorkerGlobalScope;
cleanupOutdatedCaches();
precacheAndRoute(self.__WB_MANIFEST);
// PocketBase API: NetworkFirst (offline-fähig aus Cache)
registerRoute(
({ url }) => url.hostname === 'api.vereins.haus',
new NetworkFirst({
cacheName: 'pocketbase-api',
networkTimeoutSeconds: 5,
}),
);
self.addEventListener('push', (event) => {
const data = event.data?.json() ?? {};
const title = data.title ?? 'vereins.haus';
const options: NotificationOptions = {
body: data.body ?? '',
icon: '/icons/icon-192.png',
badge: '/icons/icon-192.png',
tag: data.tag ?? 'vereinshaus',
data: { url: data.url ?? '/nachrichten' },
};
event.waitUntil(self.registration.showNotification(title, options));
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
const url = (event.notification.data as { url: string })?.url ?? '/';
event.waitUntil(
self.clients
.matchAll({ type: 'window', includeUncontrolled: true })
.then((list) => {
const existing = list.find((c) => c.url.includes(url));
if (existing) return existing.focus();
return self.clients.openWindow(url);
}),
);
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

8
app/static/favicon.svg Normal file
View file

@ -0,0 +1,8 @@
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="64" height="64" rx="16" fill="#0F172A"/>
<path d="M18 30L32 18L46 30V44C46 45.1046 45.1046 46 44 46H20C18.8954 46 18 45.1046 18 44V30Z" fill="white"/>
<circle cx="25" cy="33" r="3" fill="#22C55E"/>
<circle cx="39" cy="33" r="3" fill="#22C55E"/>
<circle cx="32" cy="41" r="3" fill="#22C55E"/>
<path d="M25 33L32 41L39 33" stroke="#22C55E" stroke-width="2" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 508 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View file

@ -0,0 +1,8 @@
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="512" height="512" rx="120" fill="#0F172A"/>
<path d="M140 236L256 140L372 236V340C372 353.255 361.255 364 348 364H164C150.745 364 140 353.255 140 340V236Z" fill="white"/>
<circle cx="196" cy="256" r="20" fill="#22C55E"/>
<circle cx="316" cy="256" r="20" fill="#22C55E"/>
<circle cx="256" cy="324" r="20" fill="#22C55E"/>
<path d="M196 256L256 324L316 256" stroke="#22C55E" stroke-width="12" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 548 B

11
app/static/logo.svg Normal file
View file

@ -0,0 +1,11 @@
<svg width="720" height="220" viewBox="0 0 720 220" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="720" height="220" rx="28" fill="white"/>
<rect x="28" y="28" width="164" height="164" rx="36" fill="#0F172A"/>
<path d="M72 108L110 74L148 108V146C148 150.418 144.418 154 140 154H80C75.582 154 72 150.418 72 146V108Z" fill="white"/>
<circle cx="92" cy="116" r="8" fill="#22C55E"/>
<circle cx="128" cy="116" r="8" fill="#22C55E"/>
<circle cx="110" cy="136" r="8" fill="#22C55E"/>
<path d="M92 116L110 136L128 116" stroke="#22C55E" stroke-width="4" stroke-linecap="round"/>
<text x="230" y="108" fill="#0F172A" font-size="52" font-family="Inter, Arial, sans-serif" font-weight="700">vereins.haus</text>
<text x="232" y="146" fill="#475569" font-size="22" font-family="Inter, Arial, sans-serif" font-weight="500">Die einfache Vereins-App</text>
</svg>

After

Width:  |  Height:  |  Size: 879 B

View file

@ -6,34 +6,28 @@ export default defineConfig({
plugins: [
sveltekit(),
VitePWA({
strategies: 'injectManifest',
srcDir: 'src',
filename: 'sw.ts',
registerType: 'autoUpdate',
manifest: {
name: 'vereins.haus',
short_name: 'vereins.haus',
description: 'Vereinsverwaltung die einfach funktioniert',
theme_color: '#1e40af',
background_color: '#f8fafc',
theme_color: '#0F172A',
background_color: '#0F172A',
display: 'standalone',
start_url: '/',
icons: [
{ src: '/icons/icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
{ src: '/icons/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any maskable' },
{ src: '/icons/apple-touch-icon.png', sizes: '180x180', type: 'image/png' }
{ src: '/icons/icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any maskable' },
{ src: '/apple-touch-icon.png', sizes: '180x180', type: 'image/png', purpose: 'any' },
{ src: '/favicon.svg', sizes: 'any', type: 'image/svg+xml', purpose: 'any' }
]
},
workbox: {
injectManifest: {
globPatterns: ['**/*.{js,css,html,svg,png,ico}'],
runtimeCaching: [
{
urlPattern: /^https:\/\/api\.vereins\.haus\/.*/i,
handler: 'NetworkFirst',
options: {
cacheName: 'pocketbase-api',
expiration: { maxEntries: 100, maxAgeSeconds: 60 * 60 * 24 }
}
}
]
}
})
]
},
}),
],
});

View file

@ -0,0 +1,33 @@
version: "3.8"
services:
app-staging:
build:
context: ./app
dockerfile: Dockerfile
image: vereinshaus-staging-app
container_name: vereinshaus-staging-app
restart: unless-stopped
volumes:
- /volume1/docker/vereinshaus-staging/data:/data
environment:
- TZ=Europe/Berlin
- HOST=0.0.0.0
- PORT=3000
- DB_PATH=/data/vereinshaus.db
- UPLOAD_DIR=/data/uploads
- JWT_SECRET=${JWT_SECRET:-staging-secret-change-me}
- PUBLIC_VAPID_KEY=${PUBLIC_VAPID_KEY}
- VAPID_PRIVATE_KEY=${VAPID_PRIVATE_KEY}
- VAPID_SUBJECT=${VAPID_SUBJECT:-mailto:info@vereins.haus}
- BREVO_KEY=${BREVO_KEY}
- BREVO_SENDER=${BREVO_SENDER:-noreply@vereins.haus}
networks:
default: {}
npm_bridge:
ipv4_address: 172.25.0.13
networks:
npm_bridge:
external: true
name: nginx-proxy-manager_bridge_net

View file

@ -1,21 +1,6 @@
version: "3.8"
services:
pocketbase:
image: ghcr.io/muchobien/pocketbase:latest
container_name: vereinshaus-pocketbase
restart: unless-stopped
volumes:
- /volume1/docker/vereinshaus/pocketbase/data:/pb_data
- /volume1/docker/vereinshaus/pocketbase/storage:/pb_public
- /volume1/docker/vereinshaus/pocketbase/data/pb_hooks:/pb_hooks
environment:
- TZ=Europe/Berlin
- BREVO_KEY=${BREVO_KEY}
networks:
- default
- npm_bridge
app:
build:
context: ./app
@ -23,13 +8,24 @@ services:
image: vereinshaus-app
container_name: vereinshaus-app
restart: unless-stopped
volumes:
- /volume1/docker/vereinshaus/data:/data
environment:
- TZ=Europe/Berlin
- HOST=0.0.0.0
- PORT=3000
- DB_PATH=/data/vereinshaus.db
- UPLOAD_DIR=/data/uploads
- JWT_SECRET=${JWT_SECRET}
- PUBLIC_VAPID_KEY=${PUBLIC_VAPID_KEY}
- VAPID_PRIVATE_KEY=${VAPID_PRIVATE_KEY}
- VAPID_SUBJECT=${VAPID_SUBJECT:-mailto:info@vereins.haus}
- BREVO_KEY=${BREVO_KEY}
- BREVO_SENDER=${BREVO_SENDER:-noreply@vereins.haus}
networks:
- default
- npm_bridge
default: {}
npm_bridge:
ipv4_address: 172.25.0.11
networks:
npm_bridge:

View file

@ -0,0 +1,86 @@
onRecordAfterCreateSuccess(function(e) {
if (!e.record) return;
var key = $os.getenv("BREVO_KEY");
if (!key) {
console.log("[nachrichten] BREVO_KEY nicht gesetzt E-Mail übersprungen.");
return;
}
var vereinId = e.record.getString("verein_id");
var betreff = e.record.getString("betreff");
var text = e.record.getString("text");
var gruppeIds = e.record.getStringSlice("gruppe_ids");
// Vereinsname für Absender
var vereinName = "Ihr Verein";
try {
var verein = $app.findRecordById("vereine", vereinId);
vereinName = verein.getString("name");
} catch(err) {}
// Mitglieder-Filter
var filter = 'verein_id = "' + vereinId + '" && status = "aktiv" && email != ""';
if (gruppeIds && gruppeIds.length > 0) {
var gruppenParts = [];
for (var i = 0; i < gruppeIds.length; i++) {
gruppenParts.push('gruppe_ids ~ "' + gruppeIds[i] + '"');
}
filter += " && (" + gruppenParts.join(" || ") + ")";
}
var mitglieder;
try {
mitglieder = $app.findRecordsByFilter("mitglieder", filter, "nachname", 500, 0);
} catch(err) {
console.error("[nachrichten] Mitglieder laden fehlgeschlagen: " + String(err));
return;
}
var empfaenger = [];
for (var j = 0; j < mitglieder.length; j++) {
var m = mitglieder[j];
var email = m.getString("email");
if (email) {
empfaenger.push({
email: email,
name: m.getString("vorname") + " " + m.getString("nachname")
});
}
}
if (empfaenger.length === 0) {
console.log("[nachrichten] Keine Empfänger mit E-Mail gefunden.");
return;
}
var sender = $os.getenv("BREVO_SENDER") || "noreply@vereins.haus";
var htmlContent = text
? text.replace(/\n/g, "<br>")
: "<p>(Kein Inhalt)</p>";
// In 50er-Batches senden (Brevo-Limit)
var BATCH = 50;
for (var b = 0; b < empfaenger.length; b += BATCH) {
var batch = empfaenger.slice(b, b + BATCH);
var body = JSON.stringify({
sender: { name: vereinName, email: sender },
to: [batch[0]],
bcc: batch.slice(1),
subject: betreff,
htmlContent: htmlContent
});
try {
$http.send({
url: "https://api.brevo.com/v3/smtp/email",
method: "POST",
headers: { "api-key": key, "Content-Type": "application/json" },
body: body
});
console.log("[nachrichten] Batch " + (b / BATCH + 1) + " gesendet (" + batch.length + " Empfänger).");
} catch(err) {
console.error("[nachrichten] Brevo Fehler Batch " + (b / BATCH + 1) + ": " + String(err));
}
}
}, "nachrichten");

View file

@ -0,0 +1,144 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = new Collection({
"createRule": null,
"deleteRule": null,
"fields": [
{
"autogeneratePattern": "[a-z0-9]{15}",
"help": "",
"hidden": false,
"id": "text3208210256",
"max": 15,
"min": 15,
"name": "id",
"pattern": "^[a-z0-9]+$",
"presentable": false,
"primaryKey": true,
"required": true,
"system": true,
"type": "text"
},
{
"autogeneratePattern": "",
"help": "",
"hidden": false,
"id": "text1579384326",
"max": 0,
"min": 0,
"name": "name",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": true,
"system": false,
"type": "text"
},
{
"autogeneratePattern": "",
"help": "",
"hidden": false,
"id": "text3525840331",
"max": 0,
"min": 0,
"name": "plz",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"autogeneratePattern": "",
"help": "",
"hidden": false,
"id": "text4138466142",
"max": 0,
"min": 0,
"name": "ort",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"help": "",
"hidden": false,
"id": "select1497100012",
"maxSelect": 1,
"name": "bundesland",
"presentable": false,
"required": false,
"system": false,
"type": "select",
"values": [
"BW",
"BY",
"BE",
"BB",
"HB",
"HH",
"HE",
"MV",
"NI",
"NW",
"RP",
"SL",
"SN",
"ST",
"SH",
"TH"
]
},
{
"help": "",
"hidden": false,
"id": "select3713686397",
"maxSelect": 1,
"name": "plan",
"presentable": false,
"required": false,
"system": false,
"type": "select",
"values": [
"free",
"starter",
"wachstum",
"verband"
]
},
{
"autogeneratePattern": "",
"help": "",
"hidden": false,
"id": "text1888339527",
"max": 0,
"min": 0,
"name": "stripe_customer_id",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
}
],
"id": "pbc_3589557411",
"indexes": [],
"listRule": "@request.auth.verein_id = id",
"name": "vereine",
"system": false,
"type": "base",
"updateRule": "@request.auth.verein_id = id",
"viewRule": "@request.auth.verein_id = id"
});
return app.save(collection);
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_3589557411");
return app.delete(collection);
})

View file

@ -0,0 +1,67 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = new Collection({
"createRule": "@request.auth.verein_id = verein_id",
"deleteRule": "@request.auth.verein_id = verein_id",
"fields": [
{
"autogeneratePattern": "[a-z0-9]{15}",
"help": "",
"hidden": false,
"id": "text3208210256",
"max": 15,
"min": 15,
"name": "id",
"pattern": "^[a-z0-9]+$",
"presentable": false,
"primaryKey": true,
"required": true,
"system": true,
"type": "text"
},
{
"cascadeDelete": true,
"collectionId": "pbc_3589557411",
"help": "",
"hidden": false,
"id": "relation145676011",
"maxSelect": 1,
"minSelect": 0,
"name": "verein_id",
"presentable": false,
"required": true,
"system": false,
"type": "relation"
},
{
"autogeneratePattern": "",
"help": "",
"hidden": false,
"id": "text1579384326",
"max": 0,
"min": 0,
"name": "name",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": true,
"system": false,
"type": "text"
}
],
"id": "pbc_3099069179",
"indexes": [],
"listRule": "@request.auth.verein_id = verein_id",
"name": "gruppen",
"system": false,
"type": "base",
"updateRule": "@request.auth.verein_id = verein_id",
"viewRule": "@request.auth.verein_id = verein_id"
});
return app.save(collection);
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_3099069179");
return app.delete(collection);
})

View file

@ -0,0 +1,29 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = app.findCollectionByNameOrId("_pb_users_auth_")
// add field
collection.fields.addAt(10, new Field({
"cascadeDelete": false,
"collectionId": "pbc_3589557411",
"help": "",
"hidden": false,
"id": "relation145676011",
"maxSelect": 1,
"minSelect": 0,
"name": "verein_id",
"presentable": false,
"required": false,
"system": false,
"type": "relation"
}))
return app.save(collection)
}, (app) => {
const collection = app.findCollectionByNameOrId("_pb_users_auth_")
// remove field
collection.fields.removeById("relation145676011")
return app.save(collection)
})

View file

@ -0,0 +1,97 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = new Collection({
"createRule": "@request.auth.verein_id = verein_id",
"deleteRule": "@request.auth.verein_id = verein_id",
"fields": [
{
"autogeneratePattern": "[a-z0-9]{15}",
"help": "",
"hidden": false,
"id": "text3208210256",
"max": 15,
"min": 15,
"name": "id",
"pattern": "^[a-z0-9]+$",
"presentable": false,
"primaryKey": true,
"required": true,
"system": true,
"type": "text"
},
{
"cascadeDelete": true,
"collectionId": "pbc_3589557411",
"help": "",
"hidden": false,
"id": "relation145676011",
"maxSelect": 1,
"minSelect": 0,
"name": "verein_id",
"presentable": false,
"required": true,
"system": false,
"type": "relation"
},
{
"autogeneratePattern": "",
"help": "",
"hidden": false,
"id": "text1579384326",
"max": 0,
"min": 0,
"name": "name",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": true,
"system": false,
"type": "text"
},
{
"help": "",
"hidden": false,
"id": "number3107246631",
"max": null,
"min": null,
"name": "betrag",
"onlyInt": false,
"presentable": false,
"required": true,
"system": false,
"type": "number"
},
{
"help": "",
"hidden": false,
"id": "select917011370",
"maxSelect": 1,
"name": "rhythmus",
"presentable": false,
"required": true,
"system": false,
"type": "select",
"values": [
"monatlich",
"quartalsweise",
"jaehrlich",
"einmalig"
]
}
],
"id": "pbc_3218207135",
"indexes": [],
"listRule": "@request.auth.verein_id = verein_id",
"name": "beitraege",
"system": false,
"type": "base",
"updateRule": "@request.auth.verein_id = verein_id",
"viewRule": "@request.auth.verein_id = verein_id"
});
return app.save(collection);
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_3218207135");
return app.delete(collection);
})

View file

@ -0,0 +1,139 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = new Collection({
"createRule": "@request.auth.verein_id = verein_id",
"deleteRule": "@request.auth.verein_id = verein_id",
"fields": [
{
"autogeneratePattern": "[a-z0-9]{15}",
"help": "",
"hidden": false,
"id": "text3208210256",
"max": 15,
"min": 15,
"name": "id",
"pattern": "^[a-z0-9]+$",
"presentable": false,
"primaryKey": true,
"required": true,
"system": true,
"type": "text"
},
{
"cascadeDelete": true,
"collectionId": "pbc_3589557411",
"help": "",
"hidden": false,
"id": "relation145676011",
"maxSelect": 1,
"minSelect": 0,
"name": "verein_id",
"presentable": false,
"required": true,
"system": false,
"type": "relation"
},
{
"autogeneratePattern": "",
"help": "",
"hidden": false,
"id": "text386110805",
"max": 0,
"min": 0,
"name": "vorname",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": true,
"system": false,
"type": "text"
},
{
"autogeneratePattern": "",
"help": "",
"hidden": false,
"id": "text3586640595",
"max": 0,
"min": 0,
"name": "nachname",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": true,
"system": false,
"type": "text"
},
{
"exceptDomains": null,
"help": "",
"hidden": false,
"id": "email3885137012",
"name": "email",
"onlyDomains": null,
"presentable": false,
"required": false,
"system": false,
"type": "email"
},
{
"autogeneratePattern": "",
"help": "",
"hidden": false,
"id": "text4208291426",
"max": 0,
"min": 0,
"name": "iban",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"cascadeDelete": false,
"collectionId": "pbc_3099069179",
"help": "",
"hidden": false,
"id": "relation1077495665",
"maxSelect": 99,
"minSelect": 0,
"name": "gruppe_ids",
"presentable": false,
"required": false,
"system": false,
"type": "relation"
},
{
"help": "",
"hidden": false,
"id": "select2063623452",
"maxSelect": 1,
"name": "status",
"presentable": false,
"required": false,
"system": false,
"type": "select",
"values": [
"aktiv",
"passiv",
"ausgetreten"
]
}
],
"id": "pbc_2707111162",
"indexes": [],
"listRule": "@request.auth.verein_id = verein_id",
"name": "mitglieder",
"system": false,
"type": "base",
"updateRule": "@request.auth.verein_id = verein_id",
"viewRule": "@request.auth.verein_id = verein_id"
});
return app.save(collection);
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_2707111162");
return app.delete(collection);
})

View file

@ -0,0 +1,123 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = new Collection({
"createRule": "@request.auth.verein_id = mitglied_id.verein_id",
"deleteRule": "@request.auth.verein_id = mitglied_id.verein_id",
"fields": [
{
"autogeneratePattern": "[a-z0-9]{15}",
"help": "",
"hidden": false,
"id": "text3208210256",
"max": 15,
"min": 15,
"name": "id",
"pattern": "^[a-z0-9]+$",
"presentable": false,
"primaryKey": true,
"required": true,
"system": true,
"type": "text"
},
{
"cascadeDelete": true,
"collectionId": "pbc_2707111162",
"help": "",
"hidden": false,
"id": "relation3039789658",
"maxSelect": 1,
"minSelect": 0,
"name": "mitglied_id",
"presentable": false,
"required": true,
"system": false,
"type": "relation"
},
{
"cascadeDelete": false,
"collectionId": "pbc_3218207135",
"help": "",
"hidden": false,
"id": "relation715527895",
"maxSelect": 1,
"minSelect": 0,
"name": "beitrag_id",
"presentable": false,
"required": true,
"system": false,
"type": "relation"
},
{
"help": "",
"hidden": false,
"id": "number3107246631",
"max": null,
"min": null,
"name": "betrag",
"onlyInt": false,
"presentable": false,
"required": true,
"system": false,
"type": "number"
},
{
"help": "",
"hidden": false,
"id": "date3314956993",
"max": "",
"min": "",
"name": "faellig_am",
"presentable": false,
"required": false,
"system": false,
"type": "date"
},
{
"help": "",
"hidden": false,
"id": "select2063623452",
"maxSelect": 1,
"name": "status",
"presentable": false,
"required": false,
"system": false,
"type": "select",
"values": [
"ausstehend",
"bezahlt",
"fehlgeschlagen",
"storniert"
]
},
{
"autogeneratePattern": "",
"help": "",
"hidden": false,
"id": "text4235393406",
"max": 0,
"min": 0,
"name": "stripe_payment_intent_id",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
}
],
"id": "pbc_659326735",
"indexes": [],
"listRule": "@request.auth.verein_id = mitglied_id.verein_id",
"name": "einzuege",
"system": false,
"type": "base",
"updateRule": "@request.auth.verein_id = mitglied_id.verein_id",
"viewRule": "@request.auth.verein_id = mitglied_id.verein_id"
});
return app.save(collection);
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_659326735");
return app.delete(collection);
})

View file

@ -0,0 +1,105 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = new Collection({
"createRule": "@request.auth.verein_id = verein_id",
"deleteRule": "@request.auth.verein_id = verein_id",
"fields": [
{
"autogeneratePattern": "[a-z0-9]{15}",
"help": "",
"hidden": false,
"id": "text3208210256",
"max": 15,
"min": 15,
"name": "id",
"pattern": "^[a-z0-9]+$",
"presentable": false,
"primaryKey": true,
"required": true,
"system": true,
"type": "text"
},
{
"cascadeDelete": true,
"collectionId": "pbc_3589557411",
"help": "",
"hidden": false,
"id": "relation145676011",
"maxSelect": 1,
"minSelect": 0,
"name": "verein_id",
"presentable": false,
"required": true,
"system": false,
"type": "relation"
},
{
"autogeneratePattern": "",
"help": "",
"hidden": false,
"id": "text3446813636",
"max": 0,
"min": 0,
"name": "betreff",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": true,
"system": false,
"type": "text"
},
{
"convertURLs": false,
"help": "",
"hidden": false,
"id": "editor999008199",
"maxSize": 0,
"name": "text",
"presentable": false,
"required": false,
"system": false,
"type": "editor"
},
{
"cascadeDelete": false,
"collectionId": "pbc_3099069179",
"help": "",
"hidden": false,
"id": "relation1077495665",
"maxSelect": 99,
"minSelect": 0,
"name": "gruppe_ids",
"presentable": false,
"required": false,
"system": false,
"type": "relation"
},
{
"help": "",
"hidden": false,
"id": "date2716153632",
"max": "",
"min": "",
"name": "gesendet_am",
"presentable": false,
"required": false,
"system": false,
"type": "date"
}
],
"id": "pbc_1415911511",
"indexes": [],
"listRule": "@request.auth.verein_id = verein_id",
"name": "nachrichten",
"system": false,
"type": "base",
"updateRule": "@request.auth.verein_id = verein_id",
"viewRule": "@request.auth.verein_id = verein_id"
});
return app.save(collection);
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_1415911511");
return app.delete(collection);
})

View file

@ -0,0 +1,94 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = new Collection({
"createRule": "@request.auth.id != ''",
"deleteRule": "@request.auth.id = user_id",
"fields": [
{
"autogeneratePattern": "[a-z0-9]{15}",
"help": "",
"hidden": false,
"id": "text3208210256",
"max": 15,
"min": 15,
"name": "id",
"pattern": "^[a-z0-9]+$",
"presentable": false,
"primaryKey": true,
"required": true,
"system": true,
"type": "text"
},
{
"cascadeDelete": true,
"collectionId": "_pb_users_auth_",
"help": "",
"hidden": false,
"id": "relation2809058197",
"maxSelect": 1,
"minSelect": 0,
"name": "user_id",
"presentable": false,
"required": true,
"system": false,
"type": "relation"
},
{
"exceptDomains": null,
"help": "",
"hidden": false,
"id": "url3292663675",
"name": "endpoint",
"onlyDomains": null,
"presentable": false,
"required": true,
"system": false,
"type": "url"
},
{
"autogeneratePattern": "",
"help": "",
"hidden": false,
"id": "text3303707132",
"max": 0,
"min": 0,
"name": "p256dh",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": true,
"system": false,
"type": "text"
},
{
"autogeneratePattern": "",
"help": "",
"hidden": false,
"id": "text4175343705",
"max": 0,
"min": 0,
"name": "auth",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": true,
"system": false,
"type": "text"
}
],
"id": "pbc_1438754935",
"indexes": [],
"listRule": "@request.auth.id = user_id",
"name": "push_subscriptions",
"system": false,
"type": "base",
"updateRule": "@request.auth.id = user_id",
"viewRule": "@request.auth.id = user_id"
});
return app.save(collection);
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_1438754935");
return app.delete(collection);
})

View file

@ -0,0 +1,120 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = new Collection({
"createRule": "@request.auth.verein_id = verein_id",
"deleteRule": "@request.auth.verein_id = verein_id",
"fields": [
{
"autogeneratePattern": "[a-z0-9]{15}",
"help": "",
"hidden": false,
"id": "text3208210256",
"max": 15,
"min": 15,
"name": "id",
"pattern": "^[a-z0-9]+$",
"presentable": false,
"primaryKey": true,
"required": true,
"system": true,
"type": "text"
},
{
"cascadeDelete": true,
"collectionId": "pbc_3589557411",
"help": "",
"hidden": false,
"id": "relation145676011",
"maxSelect": 1,
"minSelect": 0,
"name": "verein_id",
"presentable": false,
"required": true,
"system": false,
"type": "relation"
},
{
"autogeneratePattern": "",
"help": "",
"hidden": false,
"id": "text2200468358",
"max": 0,
"min": 0,
"name": "titel",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": true,
"system": false,
"type": "text"
},
{
"help": "",
"hidden": false,
"id": "date363840172",
"max": "",
"min": "",
"name": "beginn",
"presentable": false,
"required": true,
"system": false,
"type": "date"
},
{
"help": "",
"hidden": false,
"id": "date1404831091",
"max": "",
"min": "",
"name": "ende",
"presentable": false,
"required": false,
"system": false,
"type": "date"
},
{
"autogeneratePattern": "",
"help": "",
"hidden": false,
"id": "text4138466142",
"max": 0,
"min": 0,
"name": "ort",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"cascadeDelete": false,
"collectionId": "pbc_3099069179",
"help": "",
"hidden": false,
"id": "relation1077495665",
"maxSelect": 99,
"minSelect": 0,
"name": "gruppe_ids",
"presentable": false,
"required": false,
"system": false,
"type": "relation"
}
],
"id": "pbc_2279568741",
"indexes": [],
"listRule": "@request.auth.verein_id = verein_id",
"name": "termine",
"system": false,
"type": "base",
"updateRule": "@request.auth.verein_id = verein_id",
"viewRule": "@request.auth.verein_id = verein_id"
});
return app.save(collection);
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_2279568741");
return app.delete(collection);
})

View file

@ -0,0 +1,194 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
// vereine: +adresse, +dosb_mitglied; -stripe_customer_id
{
const c = app.findCollectionByNameOrId("pbc_3589557411")
c.fields.removeById("text1888339527") // stripe_customer_id
c.fields.addAt(2, new Field({
"type": "text", "id": "text2001000001", "name": "adresse",
"help": "", "hidden": false, "presentable": false, "required": false, "system": false,
"autogeneratePattern": "", "min": 0, "max": 0, "pattern": ""
}))
c.fields.addAt(99, new Field({
"type": "bool", "id": "bool2001000002", "name": "dosb_mitglied",
"help": "", "hidden": false, "presentable": false, "required": false, "system": false
}))
app.save(c)
}
// gruppen: +beschreibung
{
const c = app.findCollectionByNameOrId("pbc_3099069179")
c.fields.addAt(99, new Field({
"type": "text", "id": "text2001000003", "name": "beschreibung",
"help": "", "hidden": false, "presentable": false, "required": false, "system": false,
"autogeneratePattern": "", "min": 0, "max": 0, "pattern": ""
}))
app.save(c)
}
// mitglieder: +telefon, geburtsdatum, eintrittsdatum, austrittsdatum, strasse, plz, ort, bic, notizen
{
const c = app.findCollectionByNameOrId("pbc_2707111162")
c.fields.addAt(99, new Field({
"type": "text", "id": "text2001000010", "name": "telefon",
"help": "", "hidden": false, "presentable": false, "required": false, "system": false,
"autogeneratePattern": "", "min": 0, "max": 0, "pattern": ""
}))
c.fields.addAt(99, new Field({
"type": "date", "id": "date2001000011", "name": "geburtsdatum",
"help": "", "hidden": false, "presentable": false, "required": false, "system": false,
"min": "", "max": ""
}))
c.fields.addAt(99, new Field({
"type": "date", "id": "date2001000012", "name": "eintrittsdatum",
"help": "", "hidden": false, "presentable": false, "required": false, "system": false,
"min": "", "max": ""
}))
c.fields.addAt(99, new Field({
"type": "date", "id": "date2001000013", "name": "austrittsdatum",
"help": "", "hidden": false, "presentable": false, "required": false, "system": false,
"min": "", "max": ""
}))
c.fields.addAt(99, new Field({
"type": "text", "id": "text2001000014", "name": "strasse",
"help": "", "hidden": false, "presentable": false, "required": false, "system": false,
"autogeneratePattern": "", "min": 0, "max": 0, "pattern": ""
}))
c.fields.addAt(99, new Field({
"type": "text", "id": "text2001000015", "name": "plz",
"help": "", "hidden": false, "presentable": false, "required": false, "system": false,
"autogeneratePattern": "", "min": 0, "max": 0, "pattern": ""
}))
c.fields.addAt(99, new Field({
"type": "text", "id": "text2001000016", "name": "ort",
"help": "", "hidden": false, "presentable": false, "required": false, "system": false,
"autogeneratePattern": "", "min": 0, "max": 0, "pattern": ""
}))
c.fields.addAt(99, new Field({
"type": "text", "id": "text2001000017", "name": "bic",
"help": "", "hidden": false, "presentable": false, "required": false, "system": false,
"autogeneratePattern": "", "min": 0, "max": 0, "pattern": ""
}))
c.fields.addAt(99, new Field({
"type": "text", "id": "text2001000018", "name": "notizen",
"help": "", "hidden": false, "presentable": false, "required": false, "system": false,
"autogeneratePattern": "", "min": 0, "max": 0, "pattern": ""
}))
app.save(c)
}
// beitraege: +beschreibung; rhythmus +halbjaehrlich (einmalig bleibt)
{
const c = app.findCollectionByNameOrId("pbc_3218207135")
const rhythmus = c.fields.getById("select917011370")
rhythmus.values = ["monatlich", "quartalsweise", "halbjaehrlich", "jaehrlich", "einmalig"]
c.fields.addAt(99, new Field({
"type": "text", "id": "text2001000020", "name": "beschreibung",
"help": "", "hidden": false, "presentable": false, "required": false, "system": false,
"autogeneratePattern": "", "min": 0, "max": 0, "pattern": ""
}))
app.save(c)
}
// einzuege: -stripe_payment_intent_id; status bezahlt→eingezogen
{
const c = app.findCollectionByNameOrId("pbc_659326735")
c.fields.removeById("text4235393406") // stripe_payment_intent_id
const status = c.fields.getById("select2063623452")
status.values = ["ausstehend", "eingezogen", "fehlgeschlagen", "storniert"]
app.save(c)
}
// termine: +beschreibung
{
const c = app.findCollectionByNameOrId("pbc_2279568741")
c.fields.addAt(99, new Field({
"type": "text", "id": "text2001000030", "name": "beschreibung",
"help": "", "hidden": false, "presentable": false, "required": false, "system": false,
"autogeneratePattern": "", "min": 0, "max": 0, "pattern": ""
}))
app.save(c)
}
// nachrichten: +autor_id (relation zu users)
{
const c = app.findCollectionByNameOrId("pbc_1415911511")
c.fields.addAt(2, new Field({
"type": "relation", "id": "relation2001000040", "name": "autor_id",
"help": "", "hidden": false, "presentable": false, "required": false, "system": false,
"cascadeDelete": false, "collectionId": "_pb_users_auth_", "maxSelect": 1, "minSelect": 0
}))
app.save(c)
}
}, (app) => {
// vereine rollback
{
const c = app.findCollectionByNameOrId("pbc_3589557411")
c.fields.removeById("text2001000001")
c.fields.removeById("bool2001000002")
c.fields.addAt(99, new Field({
"type": "text", "id": "text1888339527", "name": "stripe_customer_id",
"help": "", "hidden": false, "presentable": false, "required": false, "system": false,
"autogeneratePattern": "", "min": 0, "max": 0, "pattern": ""
}))
app.save(c)
}
// gruppen rollback
{
const c = app.findCollectionByNameOrId("pbc_3099069179")
c.fields.removeById("text2001000003")
app.save(c)
}
// mitglieder rollback
{
const c = app.findCollectionByNameOrId("pbc_2707111162")
for (const id of ["text2001000010","date2001000011","date2001000012","date2001000013",
"text2001000014","text2001000015","text2001000016","text2001000017","text2001000018"]) {
c.fields.removeById(id)
}
app.save(c)
}
// beitraege rollback
{
const c = app.findCollectionByNameOrId("pbc_3218207135")
const rhythmus = c.fields.getById("select917011370")
rhythmus.values = ["monatlich", "quartalsweise", "jaehrlich", "einmalig"]
c.fields.removeById("text2001000020")
app.save(c)
}
// einzuege rollback
{
const c = app.findCollectionByNameOrId("pbc_659326735")
const status = c.fields.getById("select2063623452")
status.values = ["ausstehend", "bezahlt", "fehlgeschlagen", "storniert"]
c.fields.addAt(99, new Field({
"type": "text", "id": "text4235393406", "name": "stripe_payment_intent_id",
"help": "", "hidden": false, "presentable": false, "required": false, "system": false,
"autogeneratePattern": "", "min": 0, "max": 0, "pattern": ""
}))
app.save(c)
}
// termine rollback
{
const c = app.findCollectionByNameOrId("pbc_2279568741")
c.fields.removeById("text2001000030")
app.save(c)
}
// nachrichten rollback
{
const c = app.findCollectionByNameOrId("pbc_1415911511")
c.fields.removeById("relation2001000040")
app.save(c)
}
})

View file

@ -0,0 +1,58 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
// vereine: +glaeubigerid, +iban, +bic (Vereinskonto für SEPA-Einzug)
{
const c = app.findCollectionByNameOrId("pbc_3589557411")
c.fields.addAt(99, new Field({
"type": "text", "id": "text2001000050", "name": "glaeubigerid",
"help": "", "hidden": false, "presentable": false, "required": false, "system": false,
"autogeneratePattern": "", "min": 0, "max": 0, "pattern": ""
}))
c.fields.addAt(99, new Field({
"type": "text", "id": "text2001000051", "name": "iban",
"help": "", "hidden": false, "presentable": false, "required": false, "system": false,
"autogeneratePattern": "", "min": 0, "max": 0, "pattern": ""
}))
c.fields.addAt(99, new Field({
"type": "text", "id": "text2001000052", "name": "bic",
"help": "", "hidden": false, "presentable": false, "required": false, "system": false,
"autogeneratePattern": "", "min": 0, "max": 0, "pattern": ""
}))
app.save(c)
}
// mitglieder: +mandatsreferenz, +mandatsdatum (SEPA-Mandat des Mitglieds)
{
const c = app.findCollectionByNameOrId("pbc_2707111162")
c.fields.addAt(99, new Field({
"type": "text", "id": "text2001000053", "name": "mandatsreferenz",
"help": "", "hidden": false, "presentable": false, "required": false, "system": false,
"autogeneratePattern": "", "min": 0, "max": 0, "pattern": ""
}))
c.fields.addAt(99, new Field({
"type": "date", "id": "date2001000054", "name": "mandatsdatum",
"help": "", "hidden": false, "presentable": false, "required": false, "system": false,
"min": "", "max": ""
}))
app.save(c)
}
}, (app) => {
{
const c = app.findCollectionByNameOrId("pbc_3589557411")
c.fields.removeById("text2001000050")
c.fields.removeById("text2001000051")
c.fields.removeById("text2001000052")
app.save(c)
}
{
const c = app.findCollectionByNameOrId("pbc_2707111162")
c.fields.removeById("text2001000053")
c.fields.removeById("date2001000054")
app.save(c)
}
})

View file

@ -0,0 +1,14 @@
/// <reference path="../pb_data/types.d.ts" />
// Erlaubt allen Usern desselben Vereins, die Subscriptions ihrer Vereinsmitglieder zu lesen
// (notwendig damit die /api/push/senden Route alle Geräte des Vereins erreicht)
migrate((app) => {
const c = app.findCollectionByNameOrId("pbc_1438754935") // push_subscriptions
c.listRule = '@request.auth.verein_id = user_id.verein_id'
c.viewRule = '@request.auth.id = user_id'
app.save(c)
}, (app) => {
const c = app.findCollectionByNameOrId("pbc_1438754935")
c.listRule = '@request.auth.id = user_id'
c.viewRule = '@request.auth.id = user_id'
app.save(c)
})

View file

@ -0,0 +1,11 @@
/// <reference path="../pb_data/types.d.ts" />
// Erlaubt eingeloggten Nutzern, einen Verein anzulegen (Onboarding)
migrate((app) => {
const c = app.findCollectionByNameOrId("pbc_3589557411") // vereine
c.createRule = "@request.auth.id != ''"
app.save(c)
}, (app) => {
const c = app.findCollectionByNameOrId("pbc_3589557411")
c.createRule = null
app.save(c)
})

View file

@ -0,0 +1,26 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const c = app.findCollectionByNameOrId("pbc_3589557411") // vereine
c.fields.addAt(99, new Field({
"type": "email", "id": "email2001000060", "name": "email",
"help": "", "hidden": false, "presentable": false, "required": false, "system": false,
"exceptDomains": null, "onlyDomains": null
}))
c.fields.addAt(99, new Field({
"type": "text", "id": "text2001000061", "name": "telefon",
"help": "", "hidden": false, "presentable": false, "required": false, "system": false,
"autogeneratePattern": "", "min": 0, "max": 0, "pattern": ""
}))
c.fields.addAt(99, new Field({
"type": "url", "id": "url2001000062", "name": "website",
"help": "", "hidden": false, "presentable": false, "required": false, "system": false,
"exceptDomains": null, "onlyDomains": null
}))
app.save(c)
}, (app) => {
const c = app.findCollectionByNameOrId("pbc_3589557411")
c.fields.removeById("email2001000060")
c.fields.removeById("text2001000061")
c.fields.removeById("url2001000062")
app.save(c)
})

View file

@ -0,0 +1,92 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
// Users: +rolle, listRule für verein-weite Sichtbarkeit
{
const c = app.findCollectionByNameOrId("_pb_users_auth_")
c.fields.addAt(99, new Field({
"type": "select", "id": "select2001000070", "name": "rolle",
"help": "", "hidden": false, "presentable": false, "required": false, "system": false,
"maxSelect": 1, "values": ["admin", "trainer"]
}))
c.listRule = "@request.auth.verein_id = verein_id"
app.save(c)
}
// Gruppen: +trainer_ids (welche User betreuen diese Gruppe)
{
const c = app.findCollectionByNameOrId("pbc_3099069179")
c.fields.addAt(99, new Field({
"type": "relation", "id": "relation2001000071", "name": "trainer_ids",
"help": "", "hidden": false, "presentable": false, "required": false, "system": false,
"cascadeDelete": false, "collectionId": "_pb_users_auth_",
"maxSelect": 99, "minSelect": 0
}))
app.save(c)
}
// Einladungen-Collection
{
const c = new Collection({
"createRule": "@request.auth.verein_id = verein_id",
"deleteRule": "@request.auth.verein_id = verein_id",
"listRule": "@request.auth.verein_id = verein_id",
"viewRule": "",
"updateRule": "@request.auth.verein_id = verein_id",
"fields": [
{
"autogeneratePattern": "[a-z0-9]{15}", "id": "text3208210256",
"max": 15, "min": 15, "name": "id", "pattern": "^[a-z0-9]+$",
"primaryKey": true, "required": true, "system": true, "type": "text",
"help": "", "hidden": false, "presentable": false
},
{
"type": "relation", "id": "relation2001000072", "name": "verein_id",
"help": "", "hidden": false, "presentable": false, "required": true, "system": false,
"cascadeDelete": true, "collectionId": "pbc_3589557411", "maxSelect": 1, "minSelect": 0
},
{
"type": "select", "id": "select2001000073", "name": "rolle",
"help": "", "hidden": false, "presentable": false, "required": true, "system": false,
"maxSelect": 1, "values": ["admin", "trainer"]
},
{
"type": "text", "id": "text2001000074", "name": "token",
"help": "", "hidden": false, "presentable": false, "required": true, "system": false,
"autogeneratePattern": "", "min": 0, "max": 0, "pattern": ""
},
{
"type": "bool", "id": "bool2001000075", "name": "genutzt",
"help": "", "hidden": false, "presentable": false, "required": false, "system": false
}
],
"id": "pbc_einladungen",
"indexes": ["CREATE UNIQUE INDEX idx_einladungen_token ON einladungen (token)"],
"name": "einladungen",
"system": false,
"type": "base"
})
app.save(c)
}
}, (app) => {
{
const c = app.findCollectionByNameOrId("_pb_users_auth_")
c.fields.removeById("select2001000070")
c.listRule = ""
app.save(c)
}
{
const c = app.findCollectionByNameOrId("pbc_3099069179")
c.fields.removeById("relation2001000071")
app.save(c)
}
{
const c = app.findCollectionByNameOrId("pbc_einladungen")
app.delete(c)
}
})

Some files were not shown because too many files have changed in this diff Show more