From 77c6f513b58804359d8646fb53d6ab68dbe98477 Mon Sep 17 00:00:00 2001 From: rene Date: Wed, 20 May 2026 13:01:11 +0200 Subject: [PATCH] =?UTF-8?q?Feature:=20SEPA-Export,=20Push-Notifications,?= =?UTF-8?q?=20Onboarding=20+=20vollst=C3=A4ndige=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- Makefile | 2 +- app/package-lock.json | 188 +++++- app/package.json | 8 +- app/src/lib/components/Icon.svelte | 20 + app/src/lib/icons.ts | 15 + app/src/lib/icons/calendar.svg | 1 + app/src/lib/icons/currency-eur.svg | 1 + app/src/lib/icons/envelope.svg | 1 + app/src/lib/icons/house.svg | 1 + app/src/lib/icons/users.svg | 1 + app/src/lib/sepa.ts | 109 ++++ app/src/lib/types.ts | 34 +- app/src/routes/(app)/+layout.svelte | 74 ++- app/src/routes/(app)/+page.svelte | 14 +- app/src/routes/(app)/beitraege/+page.svelte | 505 +++++++++++++++- .../routes/(app)/mitglieder/[id]/+page.svelte | 553 +++++++++++------- .../routes/(app)/mitglieder/neu/+page.svelte | 259 +++++--- app/src/routes/(app)/nachrichten/+page.svelte | 319 +++++++++- app/src/routes/(app)/termine/+page.svelte | 396 ++++++++++++- app/src/routes/(auth)/register/+page.svelte | 15 +- app/src/routes/api/push/key/+server.ts | 6 + app/src/routes/api/push/senden/+server.ts | 57 ++ app/src/routes/api/push/subscribe/+server.ts | 67 +++ app/src/routes/onboarding/+page.svelte | 331 +++++++++++ app/src/sw.ts | 45 ++ app/vite.config.ts | 21 +- docker-compose.yml | 5 + pocketbase/pb_hooks/nachrichten.pb.js | 86 +++ .../pb_migrations/1779230000_align_schema.js | 194 ++++++ .../pb_migrations/1779230100_sepa_fields.js | 58 ++ ...779230200_push_subscriptions_vereinrule.js | 14 + .../1779230300_vereine_create_rule.js | 11 + 32 files changed, 3012 insertions(+), 399 deletions(-) create mode 100644 app/src/lib/components/Icon.svelte create mode 100644 app/src/lib/icons.ts create mode 100644 app/src/lib/icons/calendar.svg create mode 100644 app/src/lib/icons/currency-eur.svg create mode 100644 app/src/lib/icons/envelope.svg create mode 100644 app/src/lib/icons/house.svg create mode 100644 app/src/lib/icons/users.svg create mode 100644 app/src/lib/sepa.ts create mode 100644 app/src/routes/api/push/key/+server.ts create mode 100644 app/src/routes/api/push/senden/+server.ts create mode 100644 app/src/routes/api/push/subscribe/+server.ts create mode 100644 app/src/routes/onboarding/+page.svelte create mode 100644 app/src/sw.ts create mode 100644 pocketbase/pb_hooks/nachrichten.pb.js create mode 100644 pocketbase/pb_migrations/1779230000_align_schema.js create mode 100644 pocketbase/pb_migrations/1779230100_sepa_fields.js create mode 100644 pocketbase/pb_migrations/1779230200_push_subscriptions_vereinrule.js create mode 100644 pocketbase/pb_migrations/1779230300_vereine_create_rule.js diff --git a/Makefile b/Makefile index 7130961..281632d 100644 --- a/Makefile +++ b/Makefile @@ -65,7 +65,7 @@ deploy: check-ssh @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"; \ + cat .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 \ diff --git a/app/package-lock.json b/app/package-lock.json index 7d728af..a298716 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -8,17 +8,21 @@ "name": "vereinshaus", "version": "0.1.0", "dependencies": { - "pocketbase": "^0.26.9" + "pocketbase": "^0.26.9", + "web-push": "^3.6.7" }, "devDependencies": { "@sveltejs/adapter-node": "^5.5.4", "@sveltejs/kit": "^2.57.0", "@sveltejs/vite-plugin-svelte": "^7.0.0", + "@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" } }, "node_modules/@apideck/better-ajv-errors": { @@ -2639,6 +2643,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/node": { + "version": "25.9.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz", + "integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, "node_modules/@types/resolve": { "version": "1.20.2", "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", @@ -2653,6 +2667,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/web-push": { + "version": "3.6.4", + "resolved": "https://registry.npmjs.org/@types/web-push/-/web-push-3.6.4.tgz", + "integrity": "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -2666,6 +2690,15 @@ "node": ">=0.4.0" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ajv": { "version": "8.20.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", @@ -2732,6 +2765,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/asn1.js": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz", + "integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==", + "license": "MIT", + "dependencies": { + "bn.js": "^4.0.0", + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0", + "safer-buffer": "^2.1.0" + } + }, "node_modules/async": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", @@ -2850,6 +2895,12 @@ "node": ">=6.0.0" } }, + "node_modules/bn.js": { + "version": "4.12.3", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", + "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==", + "license": "MIT" + }, "node_modules/brace-expansion": { "version": "5.0.6", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", @@ -2897,6 +2948,12 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -3139,7 +3196,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -3231,6 +3287,15 @@ "node": ">= 0.4" } }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ejs": { "version": "3.1.10", "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", @@ -3879,6 +3944,28 @@ "node": ">= 0.4" } }, + "node_modules/http_ece": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz", + "integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/idb": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz", @@ -3886,6 +3973,12 @@ "dev": true, "license": "ISC" }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, "node_modules/internal-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", @@ -4421,6 +4514,27 @@ "node": ">=0.10.0" } }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/kleur": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", @@ -4753,6 +4867,12 @@ "node": ">= 0.4" } }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "license": "ISC" + }, "node_modules/minimatch": { "version": "10.2.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", @@ -4769,6 +4889,15 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/minipass": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", @@ -4803,7 +4932,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/nanoid": { @@ -5311,6 +5439,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/safe-push-apply": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", @@ -5346,6 +5494,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, "node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -6022,6 +6176,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "dev": true, + "license": "MIT" + }, "node_modules/unicode-canonical-property-names-ecmascript": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz", @@ -6260,6 +6421,25 @@ } } }, + "node_modules/web-push": { + "version": "3.6.7", + "resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz", + "integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==", + "license": "MPL-2.0", + "dependencies": { + "asn1.js": "^5.3.0", + "http_ece": "1.2.0", + "https-proxy-agent": "^7.0.0", + "jws": "^4.0.0", + "minimist": "^1.2.5" + }, + "bin": { + "web-push": "src/cli.js" + }, + "engines": { + "node": ">= 16" + } + }, "node_modules/webidl-conversions": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz", diff --git a/app/package.json b/app/package.json index e0fc5a5..95ba95e 100644 --- a/app/package.json +++ b/app/package.json @@ -15,13 +15,17 @@ "@sveltejs/adapter-node": "^5.5.4", "@sveltejs/kit": "^2.57.0", "@sveltejs/vite-plugin-svelte": "^7.0.0", + "@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" + "pocketbase": "^0.26.9", + "web-push": "^3.6.7" } } diff --git a/app/src/lib/components/Icon.svelte b/app/src/lib/components/Icon.svelte new file mode 100644 index 0000000..3af0fc2 --- /dev/null +++ b/app/src/lib/components/Icon.svelte @@ -0,0 +1,20 @@ + + + + + diff --git a/app/src/lib/icons.ts b/app/src/lib/icons.ts new file mode 100644 index 0000000..ccac259 --- /dev/null +++ b/app/src/lib/icons.ts @@ -0,0 +1,15 @@ +import house from './icons/house.svg?raw'; +import users from './icons/users.svg?raw'; +import calendar from './icons/calendar.svg?raw'; +import currencyEur from './icons/currency-eur.svg?raw'; +import envelope from './icons/envelope.svg?raw'; + +export const icons = { + house, + users, + calendar, + 'currency-eur': currencyEur, + envelope, +} as const; + +export type IconName = keyof typeof icons; diff --git a/app/src/lib/icons/calendar.svg b/app/src/lib/icons/calendar.svg new file mode 100644 index 0000000..3b91cc4 --- /dev/null +++ b/app/src/lib/icons/calendar.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/src/lib/icons/currency-eur.svg b/app/src/lib/icons/currency-eur.svg new file mode 100644 index 0000000..c23e136 --- /dev/null +++ b/app/src/lib/icons/currency-eur.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/src/lib/icons/envelope.svg b/app/src/lib/icons/envelope.svg new file mode 100644 index 0000000..f07a98b --- /dev/null +++ b/app/src/lib/icons/envelope.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/src/lib/icons/house.svg b/app/src/lib/icons/house.svg new file mode 100644 index 0000000..6346350 --- /dev/null +++ b/app/src/lib/icons/house.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/src/lib/icons/users.svg b/app/src/lib/icons/users.svg new file mode 100644 index 0000000..7f7b6ca --- /dev/null +++ b/app/src/lib/icons/users.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/src/lib/sepa.ts b/app/src/lib/sepa.ts new file mode 100644 index 0000000..a49ff21 --- /dev/null +++ b/app/src/lib/sepa.ts @@ -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, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +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) => ` + + ${x(p.endToEndId)} + ${p.betrag.toFixed(2)} + + + ${x(p.mandatsreferenz)} + ${p.mandatsdatum} + + + ${x(kopf.glaeubigerid)} + SEPA + + + ${p.debitorBic ? `${x(p.debitorBic)}` : 'NOTPROVIDED'} + ${x(p.debitorName)} + ${iban(p.debitorIban)} + ${x(p.verwendungszweck.slice(0, 140))} + `).join(''); + + return ` + + + + ${msgId} + ${now} + ${nbOfTxs} + ${ctrlSum} + ${x(kopf.vereinName)} + + + ${msgId}-PI + DD + ${nbOfTxs} + ${ctrlSum} + + SEPA + CORE + RCUR + + ${kopf.einzugsdatum} + ${x(kopf.vereinName)} + ${iban(kopf.vereinIban)} + ${x(kopf.vereinBic)} + + ${x(kopf.glaeubigerid)} + SEPA + ${txXml} + + +`; +} + +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); +} diff --git a/app/src/lib/types.ts b/app/src/lib/types.ts index 07bad1f..b1d2122 100644 --- a/app/src/lib/types.ts +++ b/app/src/lib/types.ts @@ -1,18 +1,20 @@ export type Plan = 'free' | 'starter' | 'wachstum' | 'verband'; export type MitgliedStatus = 'aktiv' | 'passiv' | 'ausgetreten'; -export type EinzugStatus = 'ausstehend' | 'eingezogen' | 'fehlgeschlagen'; -export type Rhythmus = 'monatlich' | 'quartalsweise' | 'halbjaehrlich' | 'jaehrlich'; +export type EinzugStatus = 'ausstehend' | 'eingezogen' | 'fehlgeschlagen' | 'storniert'; +export type Rhythmus = 'monatlich' | 'quartalsweise' | 'halbjaehrlich' | 'jaehrlich' | 'einmalig'; export interface Verein { id: string; name: string; - adresse: string; - plz: string; - ort: string; - bundesland: string; + adresse?: string; + plz?: string; + ort?: string; + bundesland?: string; plan: Plan; - stripe_customer_id?: string; dosb_mitglied: boolean; + glaeubigerid?: string; + iban?: string; + bic?: string; } export interface Mitglied { @@ -20,19 +22,21 @@ export interface Mitglied { verein_id: string; vorname: string; nachname: string; - email: string; + email?: string; telefon?: string; geburtsdatum?: string; - eintrittsdatum: string; + eintrittsdatum?: string; austrittsdatum?: string; - strasse: string; - plz: string; - ort: string; + strasse?: string; + plz?: string; + ort?: string; iban?: string; bic?: string; gruppe_ids: string[]; status: MitgliedStatus; notizen?: string; + mandatsreferenz?: string; + mandatsdatum?: string; } export interface Gruppe { @@ -53,13 +57,11 @@ export interface Beitrag { export interface Einzug { id: string; - verein_id: string; mitglied_id: string; beitrag_id: string; betrag: number; - faelligkeitsdatum: string; + faellig_am?: string; status: EinzugStatus; - stripe_payment_intent_id?: string; } export interface Termin { @@ -80,5 +82,5 @@ export interface Nachricht { betreff: string; text: string; gruppe_ids: string[]; - gesendet_am: string; + gesendet_am?: string; } diff --git a/app/src/routes/(app)/+layout.svelte b/app/src/routes/(app)/+layout.svelte index b435cee..db37aa6 100644 --- a/app/src/routes/(app)/+layout.svelte +++ b/app/src/routes/(app)/+layout.svelte @@ -3,26 +3,81 @@ import { page } from '$app/stores'; import { onMount } from 'svelte'; import { pb } from '$lib/pb'; + import Icon from '$lib/components/Icon.svelte'; + import type { IconName } from '$lib/icons'; let { children } = $props(); onMount(() => { if (!pb.authStore.isValid) { goto('/login'); + return; } + if (!pb.authStore.record?.verein_id) { + goto('/onboarding'); + return; + } + registerPush(); }); + 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; + + // VAPID public key vom Server holen + const keyRes = await fetch('/api/push/key'); + const { publicKey } = await keyRes.json(); + if (!publicKey) return; + + // Bestehende oder neue Subscription + let sub = await reg.pushManager.getSubscription(); + if (!sub) { + sub = await reg.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array(publicKey) as BufferSource, + }); + } + + // In PocketBase speichern + await fetch('/api/push/subscribe', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: pb.authStore.token, + }, + body: JSON.stringify({ + subscription: sub.toJSON(), + userId: pb.authStore.record?.id, + }), + }); + } catch (e) { + console.warn('[push] Registrierung fehlgeschlagen:', e); + } + } + + 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))); + } + function logout() { pb.authStore.clear(); goto('/login'); } - 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: '✉️' }, + const navItems: { href: string; label: string; icon: IconName }[] = [ + { href: '/', label: 'Übersicht', icon: 'house' }, + { href: '/mitglieder', label: 'Mitglieder', icon: 'users' }, + { href: '/termine', label: 'Termine', icon: 'calendar' }, + { href: '/beitraege', label: 'Beiträge', icon: 'currency-eur' }, + { href: '/nachrichten', label: 'Nachrichten', icon: 'envelope' }, ]; @@ -42,7 +97,7 @@ href={item.href} class:active={$page.url.pathname === item.href} > - {item.icon} + {item.label} {/each} @@ -126,11 +181,6 @@ color: #1e40af; } - .nav-icon { - font-size: 1.3rem; - line-height: 1; - } - .nav-label { font-size: 0.65rem; font-weight: 500; diff --git a/app/src/routes/(app)/+page.svelte b/app/src/routes/(app)/+page.svelte index 32a9972..a4bfea6 100644 --- a/app/src/routes/(app)/+page.svelte +++ b/app/src/routes/(app)/+page.svelte @@ -1,5 +1,6 @@ @@ -12,19 +13,19 @@ @@ -52,6 +53,7 @@ flex-direction: column; align-items: center; gap: 0.5rem; + color: #1e40af; transition: border-color .15s, box-shadow .15s; } @@ -60,10 +62,6 @@ box-shadow: 0 2px 8px rgba(30,64,175,.1); } - .card-icon { - font-size: 2rem; - } - .card-label { font-size: 0.9rem; font-weight: 600; diff --git a/app/src/routes/(app)/beitraege/+page.svelte b/app/src/routes/(app)/beitraege/+page.svelte index d1494b1..0383cba 100644 --- a/app/src/routes/(app)/beitraege/+page.svelte +++ b/app/src/routes/(app)/beitraege/+page.svelte @@ -1,27 +1,514 @@ Beiträge — vereins.haus -