From 59546ad6d2b8f415c6a100e7d7faa18a90b9fe0d Mon Sep 17 00:00:00 2001 From: "T. Zehetbauer" <125989630+4thTomost@users.noreply.github.com> Date: Tue, 31 Mar 2026 22:18:04 +0200 Subject: [PATCH] Add account hierarchy framework with migrations, RLS policies, and UI components --- .env.production.example | 6 + apps/e2e/tests/course-enrollment.spec.ts | 4 +- apps/e2e/tests/member-lifecycle.spec.ts | 4 +- apps/e2e/tests/module-builder.spec.ts | 4 +- apps/e2e/tests/newsletter.spec.ts | 4 +- apps/e2e/tests/sepa-batch.spec.ts | 4 +- apps/web/app/[locale]/(marketing)/page.tsx | 15 +- apps/web/app/[locale]/admin/audit/page.tsx | 7 +- apps/web/app/[locale]/admin/gdpr/page.tsx | 9 +- .../web/app/[locale]/admin/migration/page.tsx | 16 +- apps/web/app/[locale]/admin/modules/page.tsx | 5 +- .../[locale]/club/[slug]/[...page]/page.tsx | 71 +- .../club/[slug]/newsletter/subscribe/page.tsx | 35 +- .../[slug]/newsletter/unsubscribe/page.tsx | 41 +- apps/web/app/[locale]/club/[slug]/page.tsx | 72 +- .../club/[slug]/portal/documents/page.tsx | 106 +- .../club/[slug]/portal/invite/page.tsx | 110 ++- .../app/[locale]/club/[slug]/portal/page.tsx | 90 +- .../_components/portal-linked-accounts.tsx | 244 +++++ .../club/[slug]/portal/profile/page.tsx | 188 +++- .../team-account-layout-mobile-navigation.tsx | 36 +- .../[account]/bookings/[bookingId]/page.tsx | 83 +- .../home/[account]/bookings/calendar/page.tsx | 35 +- .../home/[account]/bookings/guests/page.tsx | 15 +- .../home/[account]/bookings/new/page.tsx | 36 +- .../[locale]/home/[account]/bookings/page.tsx | 49 +- .../home/[account]/bookings/rooms/page.tsx | 30 +- .../courses/[courseId]/attendance/page.tsx | 51 +- .../[account]/courses/[courseId]/page.tsx | 167 +++- .../courses/[courseId]/participants/page.tsx | 36 +- .../home/[account]/courses/calendar/page.tsx | 60 +- .../[account]/courses/categories/page.tsx | 22 +- .../[account]/courses/instructors/page.tsx | 27 +- .../home/[account]/courses/locations/page.tsx | 20 +- .../home/[account]/courses/new/page.tsx | 23 +- .../[locale]/home/[account]/courses/page.tsx | 50 +- .../[account]/courses/statistics/page.tsx | 43 +- .../_components/generate-document-form.tsx | 17 +- .../_lib/server/generate-document.ts | 256 ++++- .../[account]/documents/generate/page.tsx | 2 +- .../home/[account]/documents/page.tsx | 22 +- .../[account]/documents/templates/page.tsx | 8 +- .../home/[account]/events/[eventId]/page.tsx | 56 +- .../[account]/events/holiday-passes/page.tsx | 43 +- .../home/[account]/events/new/page.tsx | 26 +- .../[locale]/home/[account]/events/page.tsx | 82 +- .../[account]/events/registrations/page.tsx | 43 +- .../[account]/finance/invoices/[id]/page.tsx | 34 +- .../[account]/finance/invoices/new/page.tsx | 23 +- .../home/[account]/finance/invoices/page.tsx | 22 +- .../[locale]/home/[account]/finance/page.tsx | 30 +- .../home/[account]/finance/payments/page.tsx | 13 +- .../[account]/finance/sepa/[batchId]/page.tsx | 30 +- .../home/[account]/finance/sepa/new/page.tsx | 10 +- .../home/[account]/finance/sepa/page.tsx | 24 +- .../[account]/fischerei/catch-books/page.tsx | 9 +- .../[account]/fischerei/competitions/page.tsx | 14 +- .../home/[account]/fischerei/leases/page.tsx | 39 +- .../home/[account]/fischerei/page.tsx | 9 +- .../home/[account]/fischerei/permits/page.tsx | 38 +- .../[account]/fischerei/species/new/page.tsx | 7 +- .../home/[account]/fischerei/species/page.tsx | 9 +- .../[account]/fischerei/statistics/page.tsx | 15 +- .../[account]/fischerei/stocking/new/page.tsx | 9 +- .../[account]/fischerei/stocking/page.tsx | 9 +- .../[account]/fischerei/waters/new/page.tsx | 7 +- .../home/[account]/fischerei/waters/page.tsx | 9 +- .../app/[locale]/home/[account]/layout.tsx | 3 +- .../[locale]/home/[account]/meetings/page.tsx | 9 +- .../meetings/protocols/[protocolId]/page.tsx | 55 +- .../[account]/meetings/protocols/new/page.tsx | 7 +- .../[account]/meetings/protocols/page.tsx | 14 +- .../home/[account]/meetings/tasks/page.tsx | 12 +- .../members-cms/[memberId]/edit/page.tsx | 16 +- .../[account]/members-cms/[memberId]/page.tsx | 16 +- .../members-cms/applications/page.tsx | 23 +- .../home/[account]/members-cms/cards/page.tsx | 25 +- .../departments/create-department-dialog.tsx | 26 +- .../members-cms/departments/page.tsx | 31 +- .../home/[account]/members-cms/dues/page.tsx | 17 +- .../[account]/members-cms/import/page.tsx | 17 +- .../home/[account]/members-cms/new/page.tsx | 39 +- .../home/[account]/members-cms/page.tsx | 26 +- .../[account]/members-cms/statistics/page.tsx | 38 +- .../_lib/server/members-page.loader.ts | 14 +- .../modules/[moduleId]/[recordId]/page.tsx | 60 +- .../modules/[moduleId]/import/page.tsx | 66 +- .../[account]/modules/[moduleId]/new/page.tsx | 36 +- .../[account]/modules/[moduleId]/page.tsx | 36 +- .../modules/[moduleId]/settings/page.tsx | 68 +- .../modules/_components/module-toggles.tsx | 11 +- .../modules/_lib/server/toggle-module.ts | 3 +- .../[locale]/home/[account]/modules/page.tsx | 15 +- .../newsletter/[campaignId]/page.tsx | 17 +- .../home/[account]/newsletter/new/page.tsx | 23 +- .../home/[account]/newsletter/page.tsx | 55 +- .../[account]/newsletter/templates/page.tsx | 33 +- apps/web/app/[locale]/home/[account]/page.tsx | 200 ++-- .../site-builder/[pageId]/edit/page.tsx | 21 +- .../home/[account]/site-builder/new/page.tsx | 17 +- .../home/[account]/site-builder/page.tsx | 126 ++- .../[account]/site-builder/posts/new/page.tsx | 23 +- .../[account]/site-builder/posts/page.tsx | 72 +- .../[account]/site-builder/settings/page.tsx | 29 +- .../[account]/verband/clubs/[clubId]/page.tsx | 8 +- .../home/[account]/verband/clubs/new/page.tsx | 7 +- .../home/[account]/verband/clubs/page.tsx | 7 +- .../home/[account]/verband/hierarchy/page.tsx | 48 + .../[locale]/home/[account]/verband/page.tsx | 7 +- .../settings/_components/settings-content.tsx | 108 +- .../home/[account]/verband/settings/page.tsx | 2 +- .../_components/statistics-content.tsx | 10 +- apps/web/app/api/club/accept-invite/route.ts | 87 +- apps/web/app/api/club/contact/route.ts | 33 +- .../web/app/api/club/course-register/route.ts | 13 +- apps/web/app/api/club/event-register/route.ts | 13 +- .../app/api/club/membership-apply/route.ts | 16 +- apps/web/app/api/club/newsletter/route.ts | 54 +- apps/web/app/layout.tsx | 5 +- apps/web/components/account-not-found.tsx | 11 +- apps/web/components/cms-page-shell.tsx | 13 +- apps/web/components/confirm-dialog.tsx | 11 +- apps/web/components/empty-state.tsx | 15 +- apps/web/components/stats-card.tsx | 20 +- apps/web/components/stats-charts.tsx | 76 +- apps/web/config/auth.config.ts | 2 +- apps/web/config/billing-plans.config.ts | 6 +- apps/web/config/feature-flags.config.ts | 10 +- .../config/team-account-navigation.config.tsx | 16 +- apps/web/i18n/messages/de/account.json | 17 +- apps/web/i18n/messages/en/account.json | 17 +- apps/web/lib/status-badges.ts | 40 +- apps/web/styles/globals.css | 7 +- apps/web/supabase/config.toml | 5 + .../20260414000001_account_hierarchy.sql | 154 +++ ...20260414000002_hierarchy_functions_rls.sql | 208 ++++ docker-compose.yml | 121 +-- docker/kong.yml | 2 +- .../mfa/multi-factor-auth-list.tsx | 4 +- .../mfa/passkey-setup-dialog.tsx | 259 +++++ .../src/components/admin-accounts-table.tsx | 22 +- .../services/legacy-migration.service.ts | 97 +- .../multi-factor-challenge-container.tsx | 214 +++- .../src/components/create-booking-form.tsx | 247 +++-- .../src/schema/booking.schema.ts | 9 +- .../src/server/actions/booking-actions.ts | 2 + .../booking-management/src/server/api.ts | 135 ++- .../features/booking-management/tsconfig.json | 4 +- .../src/components/create-course-form.tsx | 298 ++++-- .../src/schema/course.schema.ts | 15 +- .../src/server/actions/course-actions.ts | 18 +- .../course-management/src/server/api.ts | 271 +++-- .../features/course-management/tsconfig.json | 4 +- .../src/schema/document.schema.ts | 36 +- .../document-generator/src/server/api.ts | 21 +- .../features/document-generator/tsconfig.json | 4 +- .../src/components/create-event-form.tsx | 361 +++++-- .../src/schema/event.schema.ts | 9 +- .../src/server/actions/event-actions.ts | 7 +- .../event-management/src/server/api.ts | 139 ++- .../features/event-management/tsconfig.json | 4 +- .../src/components/create-invoice-form.tsx | 274 ++++-- .../src/components/create-sepa-batch-form.tsx | 107 +- .../finance/src/schema/finance.schema.ts | 32 +- .../src/server/actions/finance-actions.ts | 16 +- packages/features/finance/src/server/api.ts | 179 +++- .../services/sepa-xml-generator.service.ts | 2 +- packages/features/finance/tsconfig.json | 4 +- .../src/components/catch-books-data-table.tsx | 36 +- .../components/competitions-data-table.tsx | 48 +- .../src/components/create-species-form.tsx | 43 +- .../src/components/create-stocking-form.tsx | 30 +- .../src/components/create-water-form.tsx | 29 +- .../src/components/fischerei-dashboard.tsx | 72 +- .../components/fischerei-tab-navigation.tsx | 10 +- .../src/components/species-data-table.tsx | 42 +- .../src/components/stocking-data-table.tsx | 67 +- .../src/components/waters-data-table.tsx | 31 +- .../fischerei/src/lib/fischerei-utils.ts | 8 +- .../fischerei/src/schema/fischerei.schema.ts | 36 +- .../src/server/actions/fischerei-actions.ts | 60 +- packages/features/fischerei/src/server/api.ts | 112 +-- packages/features/fischerei/tsconfig.json | 4 +- .../src/components/application-workflow.tsx | 41 +- .../src/components/create-member-form.tsx | 650 +++++++++--- .../src/components/dues-category-manager.tsx | 37 +- .../src/components/edit-member-form.tsx | 482 +++++++-- .../src/components/mandate-manager.tsx | 43 +- .../src/components/member-detail-view.tsx | 114 ++- .../src/components/member-import-wizard.tsx | 268 +++-- .../src/components/members-data-table.tsx | 39 +- .../member-management/src/lib/member-utils.ts | 38 +- .../src/schema/member.schema.ts | 20 +- .../src/server/actions/member-actions.ts | 88 +- .../member-management/src/server/api.ts | 550 ++++++++--- .../server/services/member-card-generator.ts | 130 ++- .../features/member-management/tsconfig.json | 4 +- .../src/components/field-renderer.tsx | 40 +- .../src/components/module-form.tsx | 26 +- .../src/components/module-search.tsx | 58 +- .../src/components/module-table.tsx | 50 +- .../src/components/module-toolbar.tsx | 12 +- .../src/schema/module-field.schema.ts | 23 +- .../src/schema/module-import.schema.ts | 4 +- .../src/schema/module-query.schema.ts | 12 +- .../src/schema/module.schema.ts | 42 +- .../src/server/actions/module-actions.ts | 25 +- .../src/server/actions/record-actions.ts | 40 +- .../features/module-builder/src/server/api.ts | 2 +- .../src/server/services/audit.service.ts | 7 +- .../services/module-definition.service.ts | 60 +- .../server/services/module-query.service.ts | 3 +- .../server/services/record-crud.service.ts | 8 +- .../services/record-validation.service.ts | 16 +- .../src/components/create-newsletter-form.tsx | 139 ++- .../src/schema/newsletter.schema.ts | 20 +- .../src/server/actions/newsletter-actions.ts | 2 + .../features/newsletter/src/server/api.ts | 150 ++- packages/features/newsletter/tsconfig.json | 4 +- .../src/components/create-page-form.tsx | 147 ++- .../src/components/create-post-form.tsx | 158 ++- .../src/components/portal-login-form.tsx | 115 ++- .../src/components/site-editor.tsx | 3 + .../src/components/site-renderer.tsx | 8 +- .../src/components/site-settings-form.tsx | 253 +++-- .../site-builder/src/config/puck-config.tsx | 928 +++++++++++++++--- .../site-builder/src/schema/site.schema.ts | 15 +- .../server/actions/site-builder-actions.ts | 23 +- .../features/site-builder/src/server/api.ts | 210 +++- packages/features/site-builder/tsconfig.json | 4 +- .../src/components/create-protocol-form.tsx | 32 +- .../src/components/meetings-dashboard.tsx | 67 +- .../components/meetings-tab-navigation.tsx | 10 +- .../src/components/open-tasks-view.tsx | 42 +- .../src/components/protocol-items-list.tsx | 81 +- .../src/components/protocols-data-table.tsx | 33 +- .../src/schema/meetings.schema.ts | 8 +- .../src/server/actions/meetings-actions.ts | 65 +- .../sitzungsprotokolle/src/server/api.ts | 42 +- .../features/sitzungsprotokolle/tsconfig.json | 4 +- .../invitations/account-invitations-table.tsx | 5 +- .../members/account-members-table.tsx | 3 +- .../account-invitations-dispatcher.service.ts | 2 - .../src/components/club-contacts-manager.tsx | 151 ++- .../src/components/club-fee-billing-table.tsx | 75 +- .../src/components/club-notes-list.tsx | 40 +- .../src/components/clubs-data-table.tsx | 47 +- .../src/components/create-club-form.tsx | 31 +- .../src/components/hierarchy-tree.tsx | 289 ++++++ .../src/components/index.ts | 1 + .../src/components/verband-dashboard.tsx | 61 +- .../src/components/verband-tab-navigation.tsx | 11 +- .../src/schema/verband.schema.ts | 46 +- .../src/server/actions/hierarchy-actions.ts | 126 +++ .../src/server/actions/verband-actions.ts | 45 +- .../verbandsverwaltung/src/server/api.ts | 369 +++++-- .../features/verbandsverwaltung/tsconfig.json | 4 +- .../sentry/src/sentry.client.config.ts | 7 +- .../sentry/src/sentry.server.config.ts | 4 +- packages/shared/package.json | 5 +- packages/shared/src/dates/index.ts | 149 +++ pnpm-lock.yaml | 9 +- 262 files changed, 11671 insertions(+), 3927 deletions(-) create mode 100644 apps/web/app/[locale]/club/[slug]/portal/profile/_components/portal-linked-accounts.tsx create mode 100644 apps/web/app/[locale]/home/[account]/verband/hierarchy/page.tsx create mode 100644 apps/web/supabase/migrations/20260414000001_account_hierarchy.sql create mode 100644 apps/web/supabase/migrations/20260414000002_hierarchy_functions_rls.sql create mode 100644 packages/features/accounts/src/components/personal-account-settings/mfa/passkey-setup-dialog.tsx create mode 100644 packages/features/verbandsverwaltung/src/components/hierarchy-tree.tsx create mode 100644 packages/features/verbandsverwaltung/src/server/actions/hierarchy-actions.ts create mode 100644 packages/shared/src/dates/index.ts diff --git a/.env.production.example b/.env.production.example index a79d41842..33239e97f 100644 --- a/.env.production.example +++ b/.env.production.example @@ -44,6 +44,12 @@ ADDITIONAL_REDIRECT_URLS= # --- Webhooks --- DB_WEBHOOK_SECRET=your-webhook-secret +# --- Monitoring (Sentry) --- +NEXT_PUBLIC_MONITORING_PROVIDER=sentry +NEXT_PUBLIC_SENTRY_DSN=https://your-dsn@o123456.ingest.sentry.io/123456 +# NEXT_PUBLIC_SENTRY_ENVIRONMENT=production +# SENTRY_AUTH_TOKEN=your-auth-token-for-source-maps + # --- Feature Flags --- # All default to true, set to false to disable # NEXT_PUBLIC_ENABLE_MODULE_BUILDER=true diff --git a/apps/e2e/tests/course-enrollment.spec.ts b/apps/e2e/tests/course-enrollment.spec.ts index 5192ce081..5d874d7c2 100644 --- a/apps/e2e/tests/course-enrollment.spec.ts +++ b/apps/e2e/tests/course-enrollment.spec.ts @@ -4,7 +4,9 @@ import { test, expect } from '@playwright/test'; test.describe('Course Management', () => { - test('create course, enroll participant, check capacity, waitlist', async ({ page }) => { + test('create course, enroll participant, check capacity, waitlist', async ({ + page, + }) => { // Create course with capacity 2 // Enroll participant 1 → status: enrolled // Enroll participant 2 → status: enrolled diff --git a/apps/e2e/tests/member-lifecycle.spec.ts b/apps/e2e/tests/member-lifecycle.spec.ts index 9f1f3d8bd..bff3e6ebe 100644 --- a/apps/e2e/tests/member-lifecycle.spec.ts +++ b/apps/e2e/tests/member-lifecycle.spec.ts @@ -15,7 +15,9 @@ test.describe('Member Management', () => { await expect(page.locator('h1')).toContainText('Mitglieder'); }); - test('application workflow: submit → review → approve → member created', async ({ page }) => { + test('application workflow: submit → review → approve → member created', async ({ + page, + }) => { // Submit application // Review application // Approve → verify member auto-created diff --git a/apps/e2e/tests/module-builder.spec.ts b/apps/e2e/tests/module-builder.spec.ts index 30363fcf2..831923127 100644 --- a/apps/e2e/tests/module-builder.spec.ts +++ b/apps/e2e/tests/module-builder.spec.ts @@ -4,7 +4,9 @@ import { test, expect } from '@playwright/test'; test.describe('Module Builder', () => { - test('create module, add fields, insert record, query, update, soft-delete', async ({ page }) => { + test('create module, add fields, insert record, query, update, soft-delete', async ({ + page, + }) => { // Login await page.goto('/auth/sign-in'); await page.fill('input[name="email"]', 'test@example.com'); diff --git a/apps/e2e/tests/newsletter.spec.ts b/apps/e2e/tests/newsletter.spec.ts index ef4f679f0..2a0fef5c2 100644 --- a/apps/e2e/tests/newsletter.spec.ts +++ b/apps/e2e/tests/newsletter.spec.ts @@ -4,7 +4,9 @@ import { test, expect } from '@playwright/test'; test.describe('Newsletter', () => { - test('create campaign, select recipients from members, preview, send', async ({ page }) => { + test('create campaign, select recipients from members, preview, send', async ({ + page, + }) => { // Create newsletter // Add recipients from member filter (status=active, hasEmail=true) // Preview with variable substitution diff --git a/apps/e2e/tests/sepa-batch.spec.ts b/apps/e2e/tests/sepa-batch.spec.ts index 44ffe5349..3519b6c21 100644 --- a/apps/e2e/tests/sepa-batch.spec.ts +++ b/apps/e2e/tests/sepa-batch.spec.ts @@ -4,7 +4,9 @@ import { test, expect } from '@playwright/test'; test.describe('SEPA / Finance', () => { - test('create SEPA direct debit batch, add items, generate XML', async ({ page }) => { + test('create SEPA direct debit batch, add items, generate XML', async ({ + page, + }) => { // Create batch // Add items with valid IBANs // Generate XML diff --git a/apps/web/app/[locale]/(marketing)/page.tsx b/apps/web/app/[locale]/(marketing)/page.tsx index 7d7f36de9..a79736f12 100644 --- a/apps/web/app/[locale]/(marketing)/page.tsx +++ b/apps/web/app/[locale]/(marketing)/page.tsx @@ -78,7 +78,7 @@ function Home() { {/* Trust Indicators */}
-

+

@@ -89,10 +89,7 @@ function Home() { label="marketing.trustSchools" /> - +
@@ -184,9 +181,7 @@ function Home() { .{' '} - + } @@ -256,7 +251,7 @@ function Home() {
-

+

@@ -316,7 +311,7 @@ function Home() { {/* Final CTA */}

-

+

diff --git a/apps/web/app/[locale]/admin/audit/page.tsx b/apps/web/app/[locale]/admin/audit/page.tsx index 4f3642a16..4c4ec5a98 100644 --- a/apps/web/app/[locale]/admin/audit/page.tsx +++ b/apps/web/app/[locale]/admin/audit/page.tsx @@ -9,10 +9,9 @@ export default async function AdminAuditPage() {

-

- Alle Datenänderungen (Erstellen, Ändern, Löschen, Sperren) - über alle Mandanten hinweg. Filtert nach Zeitraum, Benutzer, - Tabelle und Aktion. +

+ Alle Datenänderungen (Erstellen, Ändern, Löschen, Sperren) über alle + Mandanten hinweg. Filtert nach Zeitraum, Benutzer, Tabelle und Aktion.

diff --git a/apps/web/app/[locale]/admin/gdpr/page.tsx b/apps/web/app/[locale]/admin/gdpr/page.tsx index d4de28230..a38d6217a 100644 --- a/apps/web/app/[locale]/admin/gdpr/page.tsx +++ b/apps/web/app/[locale]/admin/gdpr/page.tsx @@ -9,10 +9,11 @@ export default async function AdminGdprPage() {
-

- Mandantenübergreifende Übersicht aller registrierten Verarbeitungstätigkeiten - gemäß Art. 30 DSGVO. Umfasst Zweck, Rechtsgrundlage, Datenkategorien, - Aufbewahrungsfristen und technisch-organisatorische Maßnahmen. +

+ Mandantenübergreifende Übersicht aller registrierten + Verarbeitungstätigkeiten gemäß Art. 30 DSGVO. Umfasst Zweck, + Rechtsgrundlage, Datenkategorien, Aufbewahrungsfristen und + technisch-organisatorische Maßnahmen.

diff --git a/apps/web/app/[locale]/admin/migration/page.tsx b/apps/web/app/[locale]/admin/migration/page.tsx index b721e8ec9..966333aa4 100644 --- a/apps/web/app/[locale]/admin/migration/page.tsx +++ b/apps/web/app/[locale]/admin/migration/page.tsx @@ -8,23 +8,27 @@ export default async function AdminMigrationPage() {

-
+

Migrationsschritte

-
    +
    1. MySQL-Verbindung konfigurieren
    2. Mandanten (user_profile → team accounts) zuordnen
    3. Benutzer (cms_user → auth.users) migrieren
    4. -
    5. Module (m_module/m_modulfeld → modules/module_fields) übertragen
    6. +
    7. + Module (m_module/m_modulfeld → modules/module_fields) übertragen +
    8. Mitglieder (ve_mitglieder → members) importieren
    9. Kurse (ve_kurse → courses) importieren
    10. Dateien (cms_files → Supabase Storage) hochladen
    11. Daten verifizieren und bereinigen
    -
    +

    - Hinweis: Die Migration erfordert eine MySQL-Verbindung zum Legacy-System. - Stellen Sie sicher, dass mysql2 installiert ist und die Verbindungsdaten korrekt konfiguriert sind. + Hinweis: Die Migration erfordert eine + MySQL-Verbindung zum Legacy-System. Stellen Sie sicher, dass{' '} + mysql2 installiert ist und die Verbindungsdaten korrekt + konfiguriert sind.

    diff --git a/apps/web/app/[locale]/admin/modules/page.tsx b/apps/web/app/[locale]/admin/modules/page.tsx index 44db15c31..ad801f327 100644 --- a/apps/web/app/[locale]/admin/modules/page.tsx +++ b/apps/web/app/[locale]/admin/modules/page.tsx @@ -9,9 +9,10 @@ export default async function AdminModulesPage() {
-

+

Hier werden alle Module über alle Mandanten hinweg angezeigt. - Ermöglicht die zentrale Verwaltung von Modulvorlagen und -konfigurationen. + Ermöglicht die zentrale Verwaltung von Modulvorlagen und + -konfigurationen.

diff --git a/apps/web/app/[locale]/club/[slug]/[...page]/page.tsx b/apps/web/app/[locale]/club/[slug]/[...page]/page.tsx index a750b2d94..ca9a4db69 100644 --- a/apps/web/app/[locale]/club/[slug]/[...page]/page.tsx +++ b/apps/web/app/[locale]/club/[slug]/[...page]/page.tsx @@ -1,9 +1,13 @@ -import { createClient } from '@supabase/supabase-js'; import { notFound } from 'next/navigation'; + +import { createClient } from '@supabase/supabase-js'; + import { SiteRenderer } from '@kit/site-builder/components'; import type { SiteData } from '@kit/site-builder/context'; -interface Props { params: Promise<{ slug: string; page: string[] }> } +interface Props { + params: Promise<{ slug: string; page: string[] }>; +} export default async function ClubSubPage({ params }: Props) { const { slug, page: pagePath } = await params; @@ -14,36 +18,73 @@ export default async function ClubSubPage({ params }: Props) { process.env.NEXT_PUBLIC_SUPABASE_PUBLIC_KEY!, ); - const { data: account } = await supabase.from('accounts').select('id').eq('slug', slug).single(); + const { data: account } = await supabase + .from('accounts') + .select('id') + .eq('slug', slug) + .single(); if (!account) notFound(); - const { data: settings } = await supabase.from('site_settings').select('*').eq('account_id', account.id).eq('is_public', true).maybeSingle(); + const { data: settings } = await supabase + .from('site_settings') + .select('*') + .eq('account_id', account.id) + .eq('is_public', true) + .maybeSingle(); if (!settings) notFound(); - const { data: sitePageData } = await supabase.from('site_pages').select('*') - .eq('account_id', account.id).eq('slug', pageSlug).eq('is_published', true).maybeSingle(); + const { data: sitePageData } = await supabase + .from('site_pages') + .select('*') + .eq('account_id', account.id) + .eq('slug', pageSlug) + .eq('is_published', true) + .maybeSingle(); if (!sitePageData) notFound(); // Pre-fetch CMS data for Puck components const [eventsRes, coursesRes, postsRes] = await Promise.all([ - supabase.from('events').select('id, name, event_date, event_time, location, fee, status') - .eq('account_id', account.id).order('event_date', { ascending: true }).limit(20), - supabase.from('courses').select('id, name, start_date, end_date, fee, capacity, status') - .eq('account_id', account.id).order('start_date', { ascending: true }).limit(20), - supabase.from('cms_posts').select('id, title, excerpt, cover_image, published_at, slug') - .eq('account_id', account.id).eq('status', 'published').order('published_at', { ascending: false }).limit(20), + supabase + .from('events') + .select('id, name, event_date, event_time, location, fee, status') + .eq('account_id', account.id) + .order('event_date', { ascending: true }) + .limit(20), + supabase + .from('courses') + .select('id, name, start_date, end_date, fee, capacity, status') + .eq('account_id', account.id) + .order('start_date', { ascending: true }) + .limit(20), + supabase + .from('cms_posts') + .select('id, title, excerpt, cover_image, published_at, slug') + .eq('account_id', account.id) + .eq('status', 'published') + .order('published_at', { ascending: false }) + .limit(20), ]); const siteData: SiteData = { accountId: account.id, events: eventsRes.data ?? [], - courses: (coursesRes.data ?? []).map(c => ({ ...c, enrolled_count: 0 })), + courses: (coursesRes.data ?? []).map((c) => ({ ...c, enrolled_count: 0 })), posts: postsRes.data ?? [], }; return ( -
- } siteData={siteData} /> +
+ } + siteData={siteData} + />
); } diff --git a/apps/web/app/[locale]/club/[slug]/newsletter/subscribe/page.tsx b/apps/web/app/[locale]/club/[slug]/newsletter/subscribe/page.tsx index 312880318..567d32132 100644 --- a/apps/web/app/[locale]/club/[slug]/newsletter/subscribe/page.tsx +++ b/apps/web/app/[locale]/club/[slug]/newsletter/subscribe/page.tsx @@ -1,23 +1,28 @@ +import { Mail } from 'lucide-react'; + +import { Button } from '@kit/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card'; import { Input } from '@kit/ui/input'; import { Label } from '@kit/ui/label'; -import { Button } from '@kit/ui/button'; -import { Mail } from 'lucide-react'; -interface Props { params: Promise<{ slug: string }> } +interface Props { + params: Promise<{ slug: string }>; +} export default async function NewsletterSubscribePage({ params }: Props) { const { slug } = await params; return ( -
+
-
- +
+
Newsletter abonnieren -

Bleiben Sie über Neuigkeiten informiert.

+

+ Bleiben Sie über Neuigkeiten informiert. +

@@ -27,11 +32,19 @@ export default async function NewsletterSubscribePage({ params }: Props) {
- +
- -

- Sie können sich jederzeit abmelden. Wir senden Ihnen eine Bestätigungs-E-Mail. + +

+ Sie können sich jederzeit abmelden. Wir senden Ihnen eine + Bestätigungs-E-Mail.

diff --git a/apps/web/app/[locale]/club/[slug]/newsletter/unsubscribe/page.tsx b/apps/web/app/[locale]/club/[slug]/newsletter/unsubscribe/page.tsx index deb842f65..e53e13a5f 100644 --- a/apps/web/app/[locale]/club/[slug]/newsletter/unsubscribe/page.tsx +++ b/apps/web/app/[locale]/club/[slug]/newsletter/unsubscribe/page.tsx @@ -1,34 +1,51 @@ -import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card'; -import { Button } from '@kit/ui/button'; -import { MailX } from 'lucide-react'; import Link from 'next/link'; -interface Props { params: Promise<{ slug: string }>; searchParams: Promise<{ token?: string }> } +import { MailX } from 'lucide-react'; -export default async function NewsletterUnsubscribePage({ params, searchParams }: Props) { +import { Button } from '@kit/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card'; + +interface Props { + params: Promise<{ slug: string }>; + searchParams: Promise<{ token?: string }>; +} + +export default async function NewsletterUnsubscribePage({ + params, + searchParams, +}: Props) { const { slug } = await params; const { token } = await searchParams; return ( -
+
-
- +
+
Newsletter abbestellen {token ? ( <> -

Möchten Sie den Newsletter wirklich abbestellen?

- +

+ Möchten Sie den Newsletter wirklich abbestellen? +

+ ) : ( -

Kein gültiger Abmeldelink. Bitte verwenden Sie den Link aus der Newsletter-E-Mail.

+

+ Kein gültiger Abmeldelink. Bitte verwenden Sie den Link aus der + Newsletter-E-Mail. +

)} - +
diff --git a/apps/web/app/[locale]/club/[slug]/page.tsx b/apps/web/app/[locale]/club/[slug]/page.tsx index eea99aa17..47c9ee351 100644 --- a/apps/web/app/[locale]/club/[slug]/page.tsx +++ b/apps/web/app/[locale]/club/[slug]/page.tsx @@ -1,9 +1,13 @@ -import { createClient } from '@supabase/supabase-js'; import { notFound } from 'next/navigation'; + +import { createClient } from '@supabase/supabase-js'; + import { SiteRenderer } from '@kit/site-builder/components'; import type { SiteData } from '@kit/site-builder/context'; -interface Props { params: Promise<{ slug: string }> } +interface Props { + params: Promise<{ slug: string }>; +} export default async function ClubHomePage({ params }: Props) { const { slug } = await params; @@ -13,36 +17,74 @@ export default async function ClubHomePage({ params }: Props) { process.env.NEXT_PUBLIC_SUPABASE_PUBLIC_KEY!, ); - const { data: account } = await supabase.from('accounts').select('id, name').eq('slug', slug).single(); + const { data: account } = await supabase + .from('accounts') + .select('id, name') + .eq('slug', slug) + .single(); if (!account) notFound(); - const { data: settings } = await supabase.from('site_settings').select('*').eq('account_id', account.id).eq('is_public', true).maybeSingle(); + const { data: settings } = await supabase + .from('site_settings') + .select('*') + .eq('account_id', account.id) + .eq('is_public', true) + .maybeSingle(); if (!settings) notFound(); - const { data: page } = await supabase.from('site_pages').select('*') - .eq('account_id', account.id).eq('is_homepage', true).eq('is_published', true).maybeSingle(); + const { data: page } = await supabase + .from('site_pages') + .select('*') + .eq('account_id', account.id) + .eq('is_homepage', true) + .eq('is_published', true) + .maybeSingle(); if (!page) notFound(); // Pre-fetch CMS data for Puck components const [eventsRes, coursesRes, postsRes] = await Promise.all([ - supabase.from('events').select('id, name, event_date, event_time, location, fee, status') - .eq('account_id', account.id).order('event_date', { ascending: true }).limit(20), - supabase.from('courses').select('id, name, start_date, end_date, fee, capacity, status') - .eq('account_id', account.id).order('start_date', { ascending: true }).limit(20), - supabase.from('cms_posts').select('id, title, excerpt, cover_image, published_at, slug') - .eq('account_id', account.id).eq('status', 'published').order('published_at', { ascending: false }).limit(20), + supabase + .from('events') + .select('id, name, event_date, event_time, location, fee, status') + .eq('account_id', account.id) + .order('event_date', { ascending: true }) + .limit(20), + supabase + .from('courses') + .select('id, name, start_date, end_date, fee, capacity, status') + .eq('account_id', account.id) + .order('start_date', { ascending: true }) + .limit(20), + supabase + .from('cms_posts') + .select('id, title, excerpt, cover_image, published_at, slug') + .eq('account_id', account.id) + .eq('status', 'published') + .order('published_at', { ascending: false }) + .limit(20), ]); const siteData: SiteData = { accountId: account.id, events: eventsRes.data ?? [], - courses: (coursesRes.data ?? []).map(c => ({ ...c, enrolled_count: 0 })), + courses: (coursesRes.data ?? []).map((c) => ({ ...c, enrolled_count: 0 })), posts: postsRes.data ?? [], }; return ( -
- } siteData={siteData} /> +
+ } + siteData={siteData} + />
); } diff --git a/apps/web/app/[locale]/club/[slug]/portal/documents/page.tsx b/apps/web/app/[locale]/club/[slug]/portal/documents/page.tsx index d9553a275..6676ff638 100644 --- a/apps/web/app/[locale]/club/[slug]/portal/documents/page.tsx +++ b/apps/web/app/[locale]/club/[slug]/portal/documents/page.tsx @@ -1,10 +1,14 @@ -import { createClient } from '@supabase/supabase-js'; -import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card'; -import { Button } from '@kit/ui/button'; -import { Badge } from '@kit/ui/badge'; -import { FileText, Download, Shield, Receipt, FileCheck } from 'lucide-react'; import Link from 'next/link'; +import { createClient } from '@supabase/supabase-js'; + +import { FileText, Download, Shield, Receipt, FileCheck } from 'lucide-react'; + +import { formatDate } from '@kit/shared/dates'; +import { Badge } from '@kit/ui/badge'; +import { Button } from '@kit/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card'; + interface Props { params: Promise<{ slug: string }>; } @@ -14,77 +18,117 @@ export default async function PortalDocumentsPage({ params }: Props) { const supabase = createClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, - process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.NEXT_PUBLIC_SUPABASE_PUBLIC_KEY!, + process.env.SUPABASE_SERVICE_ROLE_KEY || + process.env.NEXT_PUBLIC_SUPABASE_PUBLIC_KEY!, ); - const { data: account } = await supabase.from('accounts').select('id, name').eq('slug', slug).single(); - if (!account) return
Organisation nicht gefunden
; + const { data: account } = await supabase + .from('accounts') + .select('id, name') + .eq('slug', slug) + .single(); + if (!account) + return
Organisation nicht gefunden
; // Demo documents (in production: query invoices + cms_files for this member) const documents = [ - { id: '1', title: 'Mitgliedsbeitrag 2026', type: 'Rechnung', date: '2026-01-15', status: 'paid' }, - { id: '2', title: 'Mitgliedsbeitrag 2025', type: 'Rechnung', date: '2025-01-10', status: 'paid' }, - { id: '3', title: 'Beitrittserklärung', type: 'Dokument', date: '2020-01-15', status: 'signed' }, + { + id: '1', + title: 'Mitgliedsbeitrag 2026', + type: 'Rechnung', + date: '2026-01-15', + status: 'paid', + }, + { + id: '2', + title: 'Mitgliedsbeitrag 2025', + type: 'Rechnung', + date: '2025-01-10', + status: 'paid', + }, + { + id: '3', + title: 'Beitrittserklärung', + type: 'Dokument', + date: '2020-01-15', + status: 'signed', + }, ]; const getStatusBadge = (status: string) => { switch (status) { - case 'paid': return Bezahlt; - case 'open': return Offen; - case 'signed': return Unterschrieben; - default: return {status}; + case 'paid': + return Bezahlt; + case 'open': + return Offen; + case 'signed': + return Unterschrieben; + default: + return {status}; } }; const getIcon = (type: string) => { switch (type) { - case 'Rechnung': return ; - case 'Dokument': return ; - default: return ; + case 'Rechnung': + return ; + case 'Dokument': + return ; + default: + return ; } }; return ( -
-
-
+
+
+
- +

Meine Dokumente

- +
-
+
Verfügbare Dokumente -

{String(account.name)} — Dokumente und Rechnungen

+

+ {String(account.name)} — Dokumente und Rechnungen +

{documents.length === 0 ? ( -
- +
+

Keine Dokumente vorhanden

) : (
{documents.map((doc) => ( -
+
{getIcon(doc.type)}
-

{doc.title}

-

{doc.type} — {new Date(doc.date).toLocaleDateString('de-DE')}

+

{doc.title}

+

+ {doc.type} — {formatDate(doc.date)} +

{getStatusBadge(doc.status)}
diff --git a/apps/web/app/[locale]/club/[slug]/portal/invite/page.tsx b/apps/web/app/[locale]/club/[slug]/portal/invite/page.tsx index 4343d5812..164f46867 100644 --- a/apps/web/app/[locale]/club/[slug]/portal/invite/page.tsx +++ b/apps/web/app/[locale]/club/[slug]/portal/invite/page.tsx @@ -1,18 +1,25 @@ -import { createClient } from '@supabase/supabase-js'; +import Link from 'next/link'; import { notFound } from 'next/navigation'; -import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card'; + +import { createClient } from '@supabase/supabase-js'; + +import { UserPlus, Shield, CheckCircle } from 'lucide-react'; + +import { formatDate } from '@kit/shared/dates'; import { Button } from '@kit/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card'; import { Input } from '@kit/ui/input'; import { Label } from '@kit/ui/label'; -import { UserPlus, Shield, CheckCircle } from 'lucide-react'; -import Link from 'next/link'; interface Props { params: Promise<{ slug: string }>; searchParams: Promise<{ token?: string }>; } -export default async function PortalInvitePage({ params, searchParams }: Props) { +export default async function PortalInvitePage({ + params, + searchParams, +}: Props) { const { slug } = await params; const { token } = await searchParams; @@ -24,28 +31,35 @@ export default async function PortalInvitePage({ params, searchParams }: Props) ); // Resolve account - const { data: account } = await supabase.from('accounts').select('id, name').eq('slug', slug).single(); + const { data: account } = await supabase + .from('accounts') + .select('id, name') + .eq('slug', slug) + .single(); if (!account) notFound(); // Look up invitation - const { data: invitation } = await supabase.from('member_portal_invitations') + const { data: invitation } = await supabase + .from('member_portal_invitations') .select('id, email, status, expires_at, member_id') .eq('invite_token', token) .maybeSingle(); if (!invitation || invitation.status !== 'pending') { return ( -
+
- +

Einladung ungültig

-

- Diese Einladung ist abgelaufen, wurde bereits verwendet oder ist ungültig. - Bitte wenden Sie sich an Ihren Vereinsadministrator. +

+ Diese Einladung ist abgelaufen, wurde bereits verwendet oder ist + ungültig. Bitte wenden Sie sich an Ihren Vereinsadministrator.

- +
@@ -56,14 +70,14 @@ export default async function PortalInvitePage({ params, searchParams }: Props) const expired = new Date(invitation.expires_at) < new Date(); if (expired) { return ( -
+
- +

Einladung abgelaufen

-

- Diese Einladung ist am {new Date(invitation.expires_at).toLocaleDateString('de-DE')} abgelaufen. - Bitte fordern Sie eine neue Einladung an. +

+ Diese Einladung ist am {formatDate(invitation.expires_at)}{' '} + abgelaufen. Bitte fordern Sie eine neue Einladung an.

@@ -72,41 +86,67 @@ export default async function PortalInvitePage({ params, searchParams }: Props) } return ( -
+
-
- +
+
Einladung zum Mitgliederbereich -

{String(account.name)}

+

+ {String(account.name)} +

-
+

- Sie wurden eingeladen, ein Konto für den Mitgliederbereich zu erstellen. - Damit können Sie Ihr Profil einsehen, Dokumente herunterladen und Ihre Datenschutz-Einstellungen verwalten. + Sie wurden eingeladen, ein Konto für den Mitgliederbereich zu + erstellen. Damit können Sie Ihr Profil einsehen, Dokumente + herunterladen und Ihre Datenschutz-Einstellungen verwalten.

-
+
- -

Ihre E-Mail-Adresse wurde vom Verein vorgegeben.

+ +

+ Ihre E-Mail-Adresse wurde vom Verein vorgegeben. +

- +
- +
-

- Bereits ein Konto? Anmelden +

+ Bereits ein Konto?{' '} + + Anmelden +

diff --git a/apps/web/app/[locale]/club/[slug]/portal/page.tsx b/apps/web/app/[locale]/club/[slug]/portal/page.tsx index cedce27ec..f54a9b211 100644 --- a/apps/web/app/[locale]/club/[slug]/portal/page.tsx +++ b/apps/web/app/[locale]/club/[slug]/portal/page.tsx @@ -1,10 +1,12 @@ -import { createClient } from '@supabase/supabase-js'; -import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card'; -import { Button } from '@kit/ui/button'; -import { UserCircle, FileText, CreditCard, Shield } from 'lucide-react'; import Link from 'next/link'; +import { createClient } from '@supabase/supabase-js'; + +import { UserCircle, FileText, CreditCard, Shield } from 'lucide-react'; + import { PortalLoginForm } from '@kit/site-builder/components'; +import { Button } from '@kit/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card'; interface Props { params: Promise<{ slug: string }>; @@ -18,15 +20,23 @@ export default async function MemberPortalPage({ params }: Props) { process.env.NEXT_PUBLIC_SUPABASE_PUBLIC_KEY!, ); - const { data: account } = await supabase.from('accounts').select('id, name').eq('slug', slug).single(); - if (!account) return
Organisation nicht gefunden
; + const { data: account } = await supabase + .from('accounts') + .select('id, name') + .eq('slug', slug) + .single(); + if (!account) + return
Organisation nicht gefunden
; // Check if user is already logged in - const { data: { user } } = await supabase.auth.getUser(); + const { + data: { user }, + } = await supabase.auth.getUser(); if (user) { // Check if this user is a member of this club - const { data: member } = await supabase.from('members') + const { data: member } = await supabase + .from('members') .select('id, first_name, last_name, status') .eq('account_id', account.id) .eq('user_id', user.id) @@ -35,45 +45,61 @@ export default async function MemberPortalPage({ params }: Props) { if (member) { // Logged in member — show portal dashboard return ( -
-
-
+
+
+
- -

Mitgliederbereich — {String(account.name)}

+ +

+ Mitgliederbereich — {String(account.name)} +

- {String(member.first_name)} {String(member.last_name)} - + + {String(member.first_name)} {String(member.last_name)} + + + +
-
-

Willkommen, {String(member.first_name)}!

+
+

+ Willkommen, {String(member.first_name)}! +

- + - +

Mein Profil

-

Kontaktdaten und Datenschutz

+

+ Kontaktdaten und Datenschutz +

- + - +

Dokumente

-

Rechnungen und Bescheinigungen

+

+ Rechnungen und Bescheinigungen +

- +

Mitgliedsausweis

-

Digital anzeigen

+

+ Digital anzeigen +

@@ -85,14 +111,18 @@ export default async function MemberPortalPage({ params }: Props) { // Not logged in or not a member — show login form return ( -
-
-
+
+
+

Mitgliederbereich

- + + +
-
+
diff --git a/apps/web/app/[locale]/club/[slug]/portal/profile/_components/portal-linked-accounts.tsx b/apps/web/app/[locale]/club/[slug]/portal/profile/_components/portal-linked-accounts.tsx new file mode 100644 index 000000000..761e8c0a2 --- /dev/null +++ b/apps/web/app/[locale]/club/[slug]/portal/profile/_components/portal-linked-accounts.tsx @@ -0,0 +1,244 @@ +'use client'; + +import { useCallback, useEffect, useState } from 'react'; + +import type { Provider, UserIdentity } from '@supabase/supabase-js'; +import { createClient } from '@supabase/supabase-js'; + +import { Link2, Link2Off, Loader2 } from 'lucide-react'; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from '@kit/ui/alert-dialog'; +import { Button } from '@kit/ui/button'; +import { OauthProviderLogoImage } from '@kit/ui/oauth-provider-logo-image'; +import { toast } from '@kit/ui/sonner'; + +const PROVIDERS: Provider[] = ['google', 'apple', 'azure', 'github']; + +const PROVIDER_LABELS: Record = { + google: 'Google', + apple: 'Apple', + azure: 'Microsoft', + github: 'GitHub', +}; + +function getSupabaseClient() { + return createClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_PUBLIC_KEY!, + ); +} + +export function PortalLinkedAccounts({ slug }: { slug: string }) { + const [identities, setIdentities] = useState([]); + const [loading, setLoading] = useState(true); + const [actionLoading, setActionLoading] = useState(null); + + const loadIdentities = useCallback(async () => { + const supabase = getSupabaseClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + + if (user?.identities) { + setIdentities(user.identities); + } + + setLoading(false); + }, []); + + useEffect(() => { + void loadIdentities(); + }, [loadIdentities]); + + const handleLink = async (provider: Provider) => { + setActionLoading(provider); + + try { + const supabase = getSupabaseClient(); + const redirectTo = `${window.location.origin}/club/${slug}/portal/profile`; + + const { error } = await supabase.auth.linkIdentity({ + provider, + options: { redirectTo }, + }); + + if (error) { + toast.error(`Verknüpfung fehlgeschlagen: ${error.message}`); + setActionLoading(null); + } + } catch { + toast.error('Verbindungsfehler. Bitte versuchen Sie es erneut.'); + setActionLoading(null); + } + }; + + const handleUnlink = async (identity: UserIdentity) => { + if (identities.length <= 1) { + toast.error('Sie benötigen mindestens eine Anmeldemethode.'); + return; + } + + setActionLoading(identity.id); + + try { + const supabase = getSupabaseClient(); + const { error } = await supabase.auth.unlinkIdentity(identity); + + if (error) { + toast.error(`Trennung fehlgeschlagen: ${error.message}`); + } else { + toast.success( + `${PROVIDER_LABELS[identity.provider] ?? identity.provider} wurde getrennt.`, + ); + await loadIdentities(); + } + } catch { + toast.error('Verbindungsfehler. Bitte versuchen Sie es erneut.'); + } finally { + setActionLoading(null); + } + }; + + if (loading) { + return ( +
+ +
+ ); + } + + const connectedProviders = identities + .filter((i) => i.provider !== 'email') + .map((i) => i.provider); + + const availableProviders = PROVIDERS.filter( + (p) => !connectedProviders.includes(p), + ); + + return ( +
+ {/* Connected accounts */} + {identities.filter((i) => i.provider !== 'email').length > 0 && ( +
+

+ Verknüpfte Konten +

+ + {identities + .filter((i) => i.provider !== 'email') + .map((identity) => ( +
+
+
+ +
+
+

+ {PROVIDER_LABELS[identity.provider] ?? identity.provider} +

+ {identity.identity_data?.email && ( +

+ {identity.identity_data.email as string} +

+ )} +
+
+ + {identities.length > 1 && ( + + + {actionLoading === identity.id ? ( + + ) : ( + + )} + + } + /> + + + + Konto trennen? + + Möchten Sie die Verknüpfung mit{' '} + {PROVIDER_LABELS[identity.provider] ?? + identity.provider}{' '} + wirklich aufheben? Sie können sich dann nicht mehr + darüber anmelden. + + + + Abbrechen + handleUnlink(identity)} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + Trennen + + + + + )} +
+ ))} +
+ )} + + {/* Available providers to link */} + {availableProviders.length > 0 && ( +
+

+ Konto verknüpfen für schnellere Anmeldung +

+ +
+ {availableProviders.map((provider) => ( + + ))} +
+
+ )} + + {/* Info text when email-only */} + {identities.length <= 1 && availableProviders.length > 0 && ( +

+ Verknüpfen Sie ein Konto, um sich zukünftig schneller und ohne + Passwort anmelden zu können. +

+ )} +
+ ); +} diff --git a/apps/web/app/[locale]/club/[slug]/portal/profile/page.tsx b/apps/web/app/[locale]/club/[slug]/portal/profile/page.tsx index 0ba3b4ee2..f809a1c59 100644 --- a/apps/web/app/[locale]/club/[slug]/portal/profile/page.tsx +++ b/apps/web/app/[locale]/club/[slug]/portal/profile/page.tsx @@ -1,11 +1,25 @@ -import { createClient } from '@supabase/supabase-js'; +import Link from 'next/link'; import { redirect } from 'next/navigation'; -import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card'; + +import { createClient } from '@supabase/supabase-js'; + +import { + UserCircle, + Mail, + MapPin, + Phone, + Shield, + Calendar, + Link2, +} from 'lucide-react'; + +import { formatDate } from '@kit/shared/dates'; import { Button } from '@kit/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card'; import { Input } from '@kit/ui/input'; import { Label } from '@kit/ui/label'; -import { UserCircle, Mail, MapPin, Phone, Shield, Calendar } from 'lucide-react'; -import Link from 'next/link'; + +import { PortalLinkedAccounts } from './_components/portal-linked-accounts'; interface Props { params: Promise<{ slug: string }>; @@ -19,15 +33,23 @@ export default async function PortalProfilePage({ params }: Props) { process.env.NEXT_PUBLIC_SUPABASE_PUBLIC_KEY!, ); - const { data: account } = await supabase.from('accounts').select('id, name').eq('slug', slug).single(); - if (!account) return
Organisation nicht gefunden
; + const { data: account } = await supabase + .from('accounts') + .select('id, name') + .eq('slug', slug) + .single(); + if (!account) + return
Organisation nicht gefunden
; // Get current user - const { data: { user } } = await supabase.auth.getUser(); + const { + data: { user }, + } = await supabase.auth.getUser(); if (!user) redirect(`/club/${slug}/portal`); // Find member linked to this user - const { data: member } = await supabase.from('members') + const { data: member } = await supabase + .from('members') .select('*') .eq('account_id', account.id) .eq('user_id', user.id) @@ -35,17 +57,20 @@ export default async function PortalProfilePage({ params }: Props) { if (!member) { return ( -
+
- +

Kein Mitglied

-

- Ihr Benutzerkonto ist nicht mit einem Mitgliedsprofil in diesem Verein verknüpft. - Bitte wenden Sie sich an Ihren Vereinsadministrator. +

+ Ihr Benutzerkonto ist nicht mit einem Mitgliedsprofil in diesem + Verein verknüpft. Bitte wenden Sie sich an Ihren + Vereinsadministrator.

- +
@@ -56,28 +81,35 @@ export default async function PortalProfilePage({ params }: Props) { const m = member; return ( -
-
-
+
+
+
- +

Mein Profil

- + + +
-
+
-
+
-

{String(m.first_name)} {String(m.last_name)}

-

- Nr. {String(m.member_number ?? '—')} — Mitglied seit {m.entry_date ? new Date(String(m.entry_date)).toLocaleDateString('de-DE') : '—'} +

+ {String(m.first_name)} {String(m.last_name)} +

+

+ Nr. {String(m.member_number ?? '—')} — Mitglied seit{' '} + {formatDate(m.entry_date)}

@@ -85,37 +117,111 @@ export default async function PortalProfilePage({ params }: Props) { - Kontaktdaten + + + + Kontaktdaten + + -
-
-
-
-
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
- Adresse + + + + Adresse + + -
-
-
-
+
+ + +
+
+ + +
+
+ + +
+
+ + +
- Datenschutz-Einwilligungen + + + + Anmeldemethoden + + + + + + + + + + + + Datenschutz-Einwilligungen + + {[ - { key: 'gdpr_newsletter', label: 'Newsletter per E-Mail', value: m.gdpr_newsletter }, - { key: 'gdpr_internet', label: 'Veröffentlichung auf der Homepage', value: m.gdpr_internet }, - { key: 'gdpr_print', label: 'Veröffentlichung in der Vereinszeitung', value: m.gdpr_print }, - { key: 'gdpr_birthday_info', label: 'Geburtstagsinfo an Mitglieder', value: m.gdpr_birthday_info }, + { + key: 'gdpr_newsletter', + label: 'Newsletter per E-Mail', + value: m.gdpr_newsletter, + }, + { + key: 'gdpr_internet', + label: 'Veröffentlichung auf der Homepage', + value: m.gdpr_internet, + }, + { + key: 'gdpr_print', + label: 'Veröffentlichung in der Vereinszeitung', + value: m.gdpr_print, + }, + { + key: 'gdpr_birthday_info', + label: 'Geburtstagsinfo an Mitglieder', + value: m.gdpr_birthday_info, + }, ].map(({ key, label, value }) => ( ))} diff --git a/apps/web/app/[locale]/home/[account]/_components/team-account-layout-mobile-navigation.tsx b/apps/web/app/[locale]/home/[account]/_components/team-account-layout-mobile-navigation.tsx index 70d208c25..477c3892f 100644 --- a/apps/web/app/[locale]/home/[account]/_components/team-account-layout-mobile-navigation.tsx +++ b/apps/web/app/[locale]/home/[account]/_components/team-account-layout-mobile-navigation.tsx @@ -49,26 +49,24 @@ export const TeamAccountLayoutMobileNavigation = ( ) => { const signOut = useSignOut(); - const Links = props.config.routes.map( - (item, index) => { - if ('children' in item) { - return item.children.map((child) => { - return ( - - ); - }); - } + const Links = props.config.routes.map((item, index) => { + if ('children' in item) { + return item.children.map((child) => { + return ( + + ); + }); + } - if ('divider' in item) { - return ; - } - }, - ); + if ('divider' in item) { + return ; + } + }); return ( diff --git a/apps/web/app/[locale]/home/[account]/bookings/[bookingId]/page.tsx b/apps/web/app/[locale]/home/[account]/bookings/[bookingId]/page.tsx index 7fcb11e44..d2bc4a83f 100644 --- a/apps/web/app/[locale]/home/[account]/bookings/[bookingId]/page.tsx +++ b/apps/web/app/[locale]/home/[account]/bookings/[bookingId]/page.tsx @@ -10,6 +10,7 @@ import { User, } from 'lucide-react'; +import { formatDate } from '@kit/shared/dates'; import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { Badge } from '@kit/ui/badge'; import { Button } from '@kit/ui/button'; @@ -21,8 +22,8 @@ import { CardTitle, } from '@kit/ui/card'; -import { CmsPageShell } from '~/components/cms-page-shell'; import { AccountNotFound } from '~/components/account-not-found'; +import { CmsPageShell } from '~/components/cms-page-shell'; interface PageProps { params: Promise<{ account: string; bookingId: string }>; @@ -124,9 +125,7 @@ export default async function BookingDetailPage({ params }: PageProps) { {STATUS_LABEL[status] ?? status}
-

- ID: {bookingId} -

+

ID: {bookingId}

@@ -144,7 +143,7 @@ export default async function BookingDetailPage({ params }: PageProps) { {room ? (
- + Zimmernummer @@ -153,14 +152,14 @@ export default async function BookingDetailPage({ params }: PageProps) {
{room.name && (
- + Name {String(room.name)}
)}
- Typ + Typ {String(room.room_type ?? '—')} @@ -186,29 +185,25 @@ export default async function BookingDetailPage({ params }: PageProps) { {guest ? (
- Name + Name {String(guest.first_name)} {String(guest.last_name)}
{guest.email && (
- + E-Mail - - {String(guest.email)} - + {String(guest.email)}
)} {guest.phone && (
- + Telefon - - {String(guest.phone)} - + {String(guest.phone)}
)}
@@ -231,56 +226,30 @@ export default async function BookingDetailPage({ params }: PageProps) {
- + Check-in - {booking.check_in - ? new Date(String(booking.check_in)).toLocaleDateString( - 'de-DE', - { - weekday: 'short', - day: '2-digit', - month: '2-digit', - year: 'numeric', - }, - ) - : '—'} + {formatDate(booking.check_in)}
- + Check-out - {booking.check_out - ? new Date(String(booking.check_out)).toLocaleDateString( - 'de-DE', - { - weekday: 'short', - day: '2-digit', - month: '2-digit', - year: 'numeric', - }, - ) - : '—'} + {formatDate(booking.check_out)}
- + Erwachsene - - {booking.adults ?? '—'} - + {booking.adults ?? '—'}
- - Kinder - - - {booking.children ?? 0} - + Kinder + {booking.children ?? 0}
@@ -294,7 +263,7 @@ export default async function BookingDetailPage({ params }: PageProps) {
- + Gesamtpreis @@ -305,7 +274,7 @@ export default async function BookingDetailPage({ params }: PageProps) {
{booking.notes && (
- + Notizen

{String(booking.notes)}

@@ -320,9 +289,7 @@ export default async function BookingDetailPage({ params }: PageProps) { Aktionen - - Status der Buchung ändern - + Status der Buchung ändern
@@ -350,10 +317,10 @@ export default async function BookingDetailPage({ params }: PageProps) { )} {status === 'cancelled' || status === 'checked_out' ? ( -

+

Diese Buchung ist{' '} - {status === 'cancelled' ? 'storniert' : 'abgeschlossen'} — keine - weiteren Aktionen verfügbar. + {status === 'cancelled' ? 'storniert' : 'abgeschlossen'} — + keine weiteren Aktionen verfügbar.

) : null}
diff --git a/apps/web/app/[locale]/home/[account]/bookings/calendar/page.tsx b/apps/web/app/[locale]/home/[account]/bookings/calendar/page.tsx index 82221898d..df3e7e21b 100644 --- a/apps/web/app/[locale]/home/[account]/bookings/calendar/page.tsx +++ b/apps/web/app/[locale]/home/[account]/bookings/calendar/page.tsx @@ -2,15 +2,14 @@ import Link from 'next/link'; import { ArrowLeft, ChevronLeft, ChevronRight } from 'lucide-react'; +import { createBookingManagementApi } from '@kit/booking-management/api'; import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { Badge } from '@kit/ui/badge'; import { Button } from '@kit/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card'; -import { createBookingManagementApi } from '@kit/booking-management/api'; - -import { CmsPageShell } from '~/components/cms-page-shell'; import { AccountNotFound } from '~/components/account-not-found'; +import { CmsPageShell } from '~/components/cms-page-shell'; interface PageProps { params: Promise<{ account: string }>; @@ -43,7 +42,11 @@ function getFirstWeekday(year: number, month: number): number { return day === 0 ? 6 : day - 1; } -function isDateInRange(date: string, checkIn: string, checkOut: string): boolean { +function isDateInRange( + date: string, + checkIn: string, + checkOut: string, +): boolean { return date >= checkIn && date < checkOut; } @@ -101,7 +104,11 @@ export default async function BookingCalendarPage({ params }: PageProps) { } // Build calendar grid cells - const cells: Array<{ day: number | null; occupied: boolean; isToday: boolean }> = []; + const cells: Array<{ + day: number | null; + occupied: boolean; + isToday: boolean; + }> = []; // Empty cells before first day for (let i = 0; i < firstWeekday; i++) { @@ -158,11 +165,11 @@ export default async function BookingCalendarPage({ params }: PageProps) { {/* Weekday Header */} -
+
{WEEKDAYS.map((day) => (
{day}
@@ -180,13 +187,13 @@ export default async function BookingCalendarPage({ params }: PageProps) { : cell.occupied ? 'bg-primary/15 text-primary font-semibold' : 'bg-muted/30 hover:bg-muted/50' - } ${cell.isToday ? 'ring-2 ring-primary ring-offset-1' : ''}`} + } ${cell.isToday ? 'ring-primary ring-2 ring-offset-1' : ''}`} > {cell.day !== null && ( <> {cell.day} {cell.occupied && ( - + )} )} @@ -195,17 +202,17 @@ export default async function BookingCalendarPage({ params }: PageProps) {
{/* Legend */} -
+
- + Belegt
- + Frei
- + Heute
@@ -217,7 +224,7 @@ export default async function BookingCalendarPage({ params }: PageProps) {
-

+

Buchungen in diesem Monat

{bookings.data.length}

diff --git a/apps/web/app/[locale]/home/[account]/bookings/guests/page.tsx b/apps/web/app/[locale]/home/[account]/bookings/guests/page.tsx index 653a3a699..1a112fa48 100644 --- a/apps/web/app/[locale]/home/[account]/bookings/guests/page.tsx +++ b/apps/web/app/[locale]/home/[account]/bookings/guests/page.tsx @@ -1,14 +1,13 @@ import { UserCircle, Plus } from 'lucide-react'; +import { createBookingManagementApi } from '@kit/booking-management/api'; import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { Button } from '@kit/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card'; -import { createBookingManagementApi } from '@kit/booking-management/api'; - +import { AccountNotFound } from '~/components/account-not-found'; import { CmsPageShell } from '~/components/cms-page-shell'; import { EmptyState } from '~/components/empty-state'; -import { AccountNotFound } from '~/components/account-not-found'; interface PageProps { params: Promise<{ account: string }>; @@ -62,7 +61,7 @@ export default async function GuestsPage({ params }: PageProps) {
- + @@ -72,9 +71,13 @@ export default async function GuestsPage({ params }: PageProps) { {guests.map((guest: Record) => ( - + diff --git a/apps/web/app/[locale]/home/[account]/bookings/new/page.tsx b/apps/web/app/[locale]/home/[account]/bookings/new/page.tsx index 6cb7c4908..653bcd912 100644 --- a/apps/web/app/[locale]/home/[account]/bookings/new/page.tsx +++ b/apps/web/app/[locale]/home/[account]/bookings/new/page.tsx @@ -1,15 +1,22 @@ -import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { createBookingManagementApi } from '@kit/booking-management/api'; import { CreateBookingForm } from '@kit/booking-management/components'; -import { CmsPageShell } from '~/components/cms-page-shell'; -import { AccountNotFound } from '~/components/account-not-found'; +import { getSupabaseServerClient } from '@kit/supabase/server-client'; -interface Props { params: Promise<{ account: string }> } +import { AccountNotFound } from '~/components/account-not-found'; +import { CmsPageShell } from '~/components/cms-page-shell'; + +interface Props { + params: Promise<{ account: string }>; +} export default async function NewBookingPage({ params }: Props) { const { account } = await params; const client = getSupabaseServerClient(); - const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single(); + const { data: acct } = await client + .from('accounts') + .select('id') + .eq('slug', account) + .single(); if (!acct) { return ( @@ -22,13 +29,20 @@ export default async function NewBookingPage({ params }: Props) { const rooms = await api.listRooms(acct.id); return ( - - + ) => ({ - id: String(r.id), roomNumber: String(r.room_number), name: String(r.name ?? ''), pricePerNight: Number(r.price_per_night ?? 0) - }))} + id: String(r.id), + roomNumber: String(r.room_number), + name: String(r.name ?? ''), + pricePerNight: Number(r.price_per_night ?? 0), + }))} /> ); diff --git a/apps/web/app/[locale]/home/[account]/bookings/page.tsx b/apps/web/app/[locale]/home/[account]/bookings/page.tsx index f5582a6e8..ac2381305 100644 --- a/apps/web/app/[locale]/home/[account]/bookings/page.tsx +++ b/apps/web/app/[locale]/home/[account]/bookings/page.tsx @@ -2,18 +2,18 @@ import Link from 'next/link'; import { BedDouble, CalendarCheck, Plus, Euro, Search } from 'lucide-react'; +import { createBookingManagementApi } from '@kit/booking-management/api'; +import { formatDate } from '@kit/shared/dates'; import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { Badge } from '@kit/ui/badge'; import { Button } from '@kit/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card'; import { Input } from '@kit/ui/input'; -import { createBookingManagementApi } from '@kit/booking-management/api'; - +import { AccountNotFound } from '~/components/account-not-found'; import { CmsPageShell } from '~/components/cms-page-shell'; import { EmptyState } from '~/components/empty-state'; import { StatsCard } from '~/components/stats-card'; -import { AccountNotFound } from '~/components/account-not-found'; interface PageProps { params: Promise<{ account: string }>; @@ -42,7 +42,10 @@ const STATUS_LABEL: Record = { no_show: 'Nicht erschienen', }; -export default async function BookingsPage({ params, searchParams }: PageProps) { +export default async function BookingsPage({ + params, + searchParams, +}: PageProps) { const { account } = await params; const search = await searchParams; const client = getSupabaseServerClient(); @@ -148,7 +151,7 @@ export default async function BookingsPage({ params, searchParams }: PageProps) {/* Search */}
- +
Name E-Mail Telefon
- {String(guest.last_name ?? '')}, {String(guest.first_name ?? '')} + {String(guest.last_name ?? '')},{' '} + {String(guest.first_name ?? '')} {String(guest.email ?? '—')} {String(guest.phone ?? '—')}
- + @@ -211,13 +214,19 @@ export default async function BookingsPage({ params, searchParams }: PageProps) {bookingsData.map((booking) => { - const room = booking.room as Record | null; - const guest = booking.guest as Record | null; + const room = booking.room as Record< + string, + string + > | null; + const guest = booking.guest as Record< + string, + string + > | null; return (
Zimmer Gast Anreise
- {booking.check_in - ? new Date( - String(booking.check_in), - ).toLocaleDateString('de-DE') - : '—'} + {formatDate(booking.check_in)} - {booking.check_out - ? new Date( - String(booking.check_out), - ).toLocaleDateString('de-DE') - : '—'} + {formatDate(booking.check_out)} 1 && !searchQuery && (
-

+

Seite {page} von {totalPages} ({total} Einträge)

{page > 1 ? ( - + @@ -293,9 +292,7 @@ export default async function BookingsPage({ params, searchParams }: PageProps) )} {page < totalPages ? ( - + diff --git a/apps/web/app/[locale]/home/[account]/bookings/rooms/page.tsx b/apps/web/app/[locale]/home/[account]/bookings/rooms/page.tsx index ab05ccb88..853cc6f62 100644 --- a/apps/web/app/[locale]/home/[account]/bookings/rooms/page.tsx +++ b/apps/web/app/[locale]/home/[account]/bookings/rooms/page.tsx @@ -1,15 +1,14 @@ import { BedDouble, Plus } from 'lucide-react'; +import { createBookingManagementApi } from '@kit/booking-management/api'; import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { Badge } from '@kit/ui/badge'; import { Button } from '@kit/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card'; -import { createBookingManagementApi } from '@kit/booking-management/api'; - +import { AccountNotFound } from '~/components/account-not-found'; import { CmsPageShell } from '~/components/cms-page-shell'; import { EmptyState } from '~/components/empty-state'; -import { AccountNotFound } from '~/components/account-not-found'; interface PageProps { params: Promise<{ account: string }>; @@ -63,26 +62,37 @@ export default async function RoomsPage({ params }: PageProps) {
- + - + {rooms.map((room: Record) => ( - + - - + + -
Zimmernr. Name Typ KapazitätPreis/Nacht + Preis/Nacht + Aktiv
{String(room.room_number ?? '—')} {String(room.name ?? '—')} - {String(room.room_type ?? '—')} + + {String(room.name ?? '—')} + + + {String(room.room_type ?? '—')} + + + {String(room.capacity ?? '—')} {String(room.capacity ?? '—')} {room.price_per_night != null ? `${Number(room.price_per_night).toFixed(2)} €` diff --git a/apps/web/app/[locale]/home/[account]/courses/[courseId]/attendance/page.tsx b/apps/web/app/[locale]/home/[account]/courses/[courseId]/attendance/page.tsx index 09e4db878..e0668428b 100644 --- a/apps/web/app/[locale]/home/[account]/courses/[courseId]/attendance/page.tsx +++ b/apps/web/app/[locale]/home/[account]/courses/[courseId]/attendance/page.tsx @@ -1,11 +1,11 @@ import { ClipboardCheck, Calendar } from 'lucide-react'; +import { createCourseManagementApi } from '@kit/course-management/api'; +import { formatDate } from '@kit/shared/dates'; import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { Badge } from '@kit/ui/badge'; import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card'; -import { createCourseManagementApi } from '@kit/course-management/api'; - import { CmsPageShell } from '~/components/cms-page-shell'; import { EmptyState } from '~/components/empty-state'; @@ -14,7 +14,10 @@ interface PageProps { searchParams: Promise>; } -export default async function AttendancePage({ params, searchParams }: PageProps) { +export default async function AttendancePage({ + params, + searchParams, +}: PageProps) { const { account, courseId } = await params; const search = await searchParams; const client = getSupabaseServerClient(); @@ -28,14 +31,21 @@ export default async function AttendancePage({ params, searchParams }: PageProps if (!course) return
Kurs nicht gefunden
; - const selectedSessionId = (search.session as string) ?? (sessions.length > 0 ? String((sessions[0] as Record).id) : null); + const selectedSessionId = + (search.session as string) ?? + (sessions.length > 0 + ? String((sessions[0] as Record).id) + : null); const attendance = selectedSessionId ? await api.getAttendance(selectedSessionId) : []; const attendanceMap = new Map( - attendance.map((a: Record) => [String(a.participant_id), Boolean(a.present)]), + attendance.map((a: Record) => [ + String(a.participant_id), + Boolean(a.present), + ]), ); return ( @@ -70,9 +80,12 @@ export default async function AttendancePage({ params, searchParams }: PageProps key={String(s.id)} href={`/home/${account}/courses/${courseId}/attendance?session=${String(s.id)}`} > - + {s.session_date - ? new Date(String(s.session_date)).toLocaleDateString('de-DE') + ? formatDate(s.session_date as string) : String(s.id)} @@ -92,28 +105,38 @@ export default async function AttendancePage({ params, searchParams }: PageProps {participants.length === 0 ? ( -

+

Keine Teilnehmer in diesem Kurs

) : (
- - - + + + {participants.map((p: Record) => ( - +
TeilnehmerAnwesend
+ Teilnehmer + + Anwesend +
- {String(p.last_name ?? '')}, {String(p.first_name ?? '')} + {String(p.last_name ?? '')},{' '} + {String(p.first_name ?? '')} diff --git a/apps/web/app/[locale]/home/[account]/courses/[courseId]/page.tsx b/apps/web/app/[locale]/home/[account]/courses/[courseId]/page.tsx index ae98008b0..79e20c0d2 100644 --- a/apps/web/app/[locale]/home/[account]/courses/[courseId]/page.tsx +++ b/apps/web/app/[locale]/home/[account]/courses/[courseId]/page.tsx @@ -1,14 +1,21 @@ import Link from 'next/link'; -import { GraduationCap, Users, Calendar, Euro, User, Clock } from 'lucide-react'; +import { + GraduationCap, + Users, + Calendar, + Euro, + User, + Clock, +} from 'lucide-react'; +import { createCourseManagementApi } from '@kit/course-management/api'; +import { formatDate } from '@kit/shared/dates'; import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { Badge } from '@kit/ui/badge'; import { Button } from '@kit/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card'; -import { createCourseManagementApi } from '@kit/course-management/api'; - import { CmsPageShell } from '~/components/cms-page-shell'; interface PageProps { @@ -16,13 +23,22 @@ interface PageProps { } const STATUS_LABEL: Record = { - planned: 'Geplant', open: 'Offen', running: 'Laufend', - completed: 'Abgeschlossen', cancelled: 'Abgesagt', + planned: 'Geplant', + open: 'Offen', + running: 'Laufend', + completed: 'Abgeschlossen', + cancelled: 'Abgesagt', }; -const STATUS_VARIANT: Record = { - planned: 'secondary', open: 'default', running: 'info', - completed: 'outline', cancelled: 'destructive', +const STATUS_VARIANT: Record< + string, + 'secondary' | 'default' | 'info' | 'outline' | 'destructive' +> = { + planned: 'secondary', + open: 'default', + running: 'info', + completed: 'outline', + cancelled: 'destructive', }; export default async function CourseDetailPage({ params }: PageProps) { @@ -47,19 +63,21 @@ export default async function CourseDetailPage({ params }: PageProps) {
- +
-

Name

+

Name

{String(c.name)}

- +
-

Status

- +

Status

+ {STATUS_LABEL[String(c.status)] ?? String(c.status)}
@@ -67,31 +85,33 @@ export default async function CourseDetailPage({ params }: PageProps) {
- +
-

Dozent

-

{String(c.instructor_id ?? '—')}

-
-
-
- - - -
-

Beginn – Ende

+

Dozent

- {c.start_date ? new Date(String(c.start_date)).toLocaleDateString('de-DE') : '—'} - {' – '} - {c.end_date ? new Date(String(c.end_date)).toLocaleDateString('de-DE') : '—'} + {String(c.instructor_id ?? '—')}

- +
-

Gebühr

+

Beginn – Ende

+

+ {formatDate(c.start_date as string)} + {' – '} + {formatDate(c.end_date as string)} +

+
+
+
+ + + +
+

Gebühr

{c.fee != null ? `${Number(c.fee).toFixed(2)} €` : '—'}

@@ -100,9 +120,9 @@ export default async function CourseDetailPage({ params }: PageProps) { - +
-

Teilnehmer

+

Teilnehmer

{participants.length} / {String(c.capacity ?? '∞')}

@@ -116,14 +136,16 @@ export default async function CourseDetailPage({ params }: PageProps) { Teilnehmer - +
- + @@ -132,15 +154,36 @@ export default async function CourseDetailPage({ params }: PageProps) { {participants.length === 0 ? ( - - ) : participants.map((p: Record) => ( - - - - - + + - ))} + ) : ( + participants.map((p: Record) => ( + + + + + + + )) + )}
Name E-Mail Status
Keine Teilnehmer
{String(p.last_name ?? '')}, {String(p.first_name ?? '')}{String(p.email ?? '—')}{String(p.status ?? '—')}{p.enrolled_at ? new Date(String(p.enrolled_at)).toLocaleDateString('de-DE') : '—'}
+ Keine Teilnehmer +
+ {String(p.last_name ?? '')},{' '} + {String(p.first_name ?? '')} + {String(p.email ?? '—')} + + {String(p.status ?? '—')} + + + {formatDate(p.enrolled_at as string)} +
@@ -152,14 +195,16 @@ export default async function CourseDetailPage({ params }: PageProps) { Termine - +
- + @@ -168,15 +213,35 @@ export default async function CourseDetailPage({ params }: PageProps) { {sessions.length === 0 ? ( - - ) : sessions.map((s: Record) => ( - - - - - + + - ))} + ) : ( + sessions.map((s: Record) => ( + + + + + + + )) + )}
Datum Beginn Ende
Keine Termine
{s.session_date ? new Date(String(s.session_date)).toLocaleDateString('de-DE') : '—'}{String(s.start_time ?? '—')}{String(s.end_time ?? '—')}{s.cancelled ? Ja : '—'}
+ Keine Termine +
+ {formatDate(s.session_date as string)} + {String(s.start_time ?? '—')}{String(s.end_time ?? '—')} + {s.cancelled ? ( + Ja + ) : ( + '—' + )} +
diff --git a/apps/web/app/[locale]/home/[account]/courses/[courseId]/participants/page.tsx b/apps/web/app/[locale]/home/[account]/courses/[courseId]/participants/page.tsx index 8810bb981..00770f520 100644 --- a/apps/web/app/[locale]/home/[account]/courses/[courseId]/participants/page.tsx +++ b/apps/web/app/[locale]/home/[account]/courses/[courseId]/participants/page.tsx @@ -2,13 +2,13 @@ import Link from 'next/link'; import { Plus, Users } from 'lucide-react'; +import { createCourseManagementApi } from '@kit/course-management/api'; +import { formatDate } from '@kit/shared/dates'; import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { Badge } from '@kit/ui/badge'; import { Button } from '@kit/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card'; -import { createCourseManagementApi } from '@kit/course-management/api'; - import { CmsPageShell } from '~/components/cms-page-shell'; import { EmptyState } from '~/components/empty-state'; @@ -16,7 +16,10 @@ interface PageProps { params: Promise<{ account: string; courseId: string }>; } -const STATUS_VARIANT: Record = { +const STATUS_VARIANT: Record< + string, + 'secondary' | 'default' | 'info' | 'outline' | 'destructive' +> = { enrolled: 'default', waitlisted: 'secondary', cancelled: 'destructive', @@ -49,7 +52,8 @@ export default async function ParticipantsPage({ params }: PageProps) {

Teilnehmer

- {String((course as Record).name)} — {participants.length} Teilnehmer + {String((course as Record).name)} —{' '} + {participants.length} Teilnehmer