From ebd0fd4638550c38ff92323057eb5d733cfde5f8 Mon Sep 17 00:00:00 2001 From: Zaid Marzguioui Date: Tue, 31 Mar 2026 16:35:46 +0200 Subject: [PATCH] feat: complete CMS v2 with Docker, Fischerei, Meetings, Verband modules + UX audit fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Major changes: - Docker Compose: full Supabase stack (11 services) equivalent to supabase CLI - Fischerei module: 16 DB tables, waters/species/stocking/catch books/competitions - Sitzungsprotokolle module: meeting protocols, agenda items, task tracking - Verbandsverwaltung module: federation management, member clubs, contacts, fees - Per-account module activation via Modules page toggle - Site Builder: live CMS data in Puck blocks (courses, events, membership registration) - Public registration APIs: course signup, event registration, membership application - Document generation: PDF member cards, Excel reports, HTML labels - Landing page: real Com.BISS content (no filler text) - UX audit fixes: AccountNotFound component, shared status badges, confirm dialog, pagination, duplicate heading removal, emoji→badge replacement, a11y fixes - QA: healthcheck fix, API auth fix, enum mismatch fix, password required attribute --- .build-cache-buster | 1 + .dockerignore | 8 +- .env | 23 + .env.production.example | 16 +- Dockerfile | 18 +- QA_TEST_PLAN.md | 501 ++++++ .../[locale]/club/[slug]/[...page]/page.tsx | 20 +- apps/web/app/[locale]/club/[slug]/page.tsx | 24 +- .../team-account-layout-mobile-navigation.tsx | 6 +- .../team-account-layout-sidebar.tsx | 8 +- .../team-account-navigation-menu.tsx | 7 +- .../[account]/bookings/[bookingId]/page.tsx | 14 +- .../home/[account]/bookings/calendar/page.tsx | 18 +- .../home/[account]/bookings/guests/page.tsx | 14 +- .../home/[account]/bookings/new/page.tsx | 9 +- .../[locale]/home/[account]/bookings/page.tsx | 265 ++- .../home/[account]/bookings/rooms/page.tsx | 14 +- .../home/[account]/courses/calendar/page.tsx | 12 +- .../[account]/courses/categories/page.tsx | 8 +- .../[account]/courses/instructors/page.tsx | 8 +- .../home/[account]/courses/locations/page.tsx | 8 +- .../home/[account]/courses/new/page.tsx | 3 +- .../[locale]/home/[account]/courses/page.tsx | 91 +- .../[account]/courses/statistics/page.tsx | 3 +- .../_components/generate-document-form.tsx | 242 +++ .../_lib/server/generate-document.ts | 385 +++++ .../[account]/documents/generate/page.tsx | 85 +- .../home/[account]/documents/page.tsx | 24 +- .../[account]/documents/templates/page.tsx | 3 +- .../[account]/events/holiday-passes/page.tsx | 31 +- .../home/[account]/events/new/page.tsx | 7 +- .../[locale]/home/[account]/events/page.tsx | 211 +-- .../[account]/events/registrations/page.tsx | 47 +- .../[account]/finance/invoices/[id]/page.tsx | 3 +- .../[account]/finance/invoices/new/page.tsx | 3 +- .../home/[account]/finance/invoices/page.tsx | 30 +- .../[locale]/home/[account]/finance/page.tsx | 79 +- .../home/[account]/finance/payments/page.tsx | 3 +- .../[account]/finance/sepa/[batchId]/page.tsx | 3 +- .../home/[account]/finance/sepa/new/page.tsx | 3 +- .../home/[account]/finance/sepa/page.tsx | 30 +- .../[account]/fischerei/catch-books/page.tsx | 47 + .../[account]/fischerei/competitions/page.tsx | 45 + .../home/[account]/fischerei/layout.tsx | 5 + .../home/[account]/fischerei/leases/page.tsx | 119 ++ .../home/[account]/fischerei/page.tsx | 33 + .../home/[account]/fischerei/permits/page.tsx | 97 ++ .../[account]/fischerei/species/new/page.tsx | 29 + .../home/[account]/fischerei/species/page.tsx | 46 + .../[account]/fischerei/statistics/page.tsx | 50 + .../[account]/fischerei/stocking/new/page.tsx | 53 + .../[account]/fischerei/stocking/page.tsx | 45 + .../[account]/fischerei/waters/new/page.tsx | 29 + .../home/[account]/fischerei/waters/page.tsx | 47 + .../app/[locale]/home/[account]/layout.tsx | 108 +- .../home/[account]/meetings/layout.tsx | 5 + .../[locale]/home/[account]/meetings/page.tsx | 43 + .../meetings/protocols/[protocolId]/page.tsx | 128 ++ .../[account]/meetings/protocols/new/page.tsx | 37 + .../[account]/meetings/protocols/page.tsx | 50 + .../home/[account]/meetings/tasks/page.tsx | 52 + .../members-cms/[memberId]/edit/page.tsx | 3 +- .../[account]/members-cms/[memberId]/page.tsx | 3 +- .../members-cms/applications/page.tsx | 3 +- .../home/[account]/members-cms/cards/page.tsx | 83 +- .../departments/create-department-dialog.tsx | 103 ++ .../members-cms/departments/page.tsx | 65 +- .../home/[account]/members-cms/dues/page.tsx | 3 +- .../[account]/members-cms/import/page.tsx | 3 +- .../home/[account]/members-cms/new/page.tsx | 3 +- .../home/[account]/members-cms/page.tsx | 9 +- .../[account]/members-cms/statistics/page.tsx | 3 +- .../modules/_components/module-toggles.tsx | 110 ++ .../modules/_lib/server/toggle-module.ts | 40 + .../[locale]/home/[account]/modules/page.tsx | 92 +- .../newsletter/[campaignId]/page.tsx | 53 +- .../home/[account]/newsletter/new/page.tsx | 3 +- .../home/[account]/newsletter/page.tsx | 96 +- .../[account]/newsletter/templates/page.tsx | 3 +- apps/web/app/[locale]/home/[account]/page.tsx | 154 +- .../site-builder/[pageId]/edit/page.tsx | 3 +- .../home/[account]/site-builder/new/page.tsx | 3 +- .../home/[account]/site-builder/page.tsx | 21 +- .../[account]/site-builder/posts/new/page.tsx | 3 +- .../[account]/site-builder/posts/page.tsx | 3 +- .../[account]/site-builder/settings/page.tsx | 3 +- .../[account]/verband/clubs/[clubId]/page.tsx | 69 + .../home/[account]/verband/clubs/new/page.tsx | 37 + .../home/[account]/verband/clubs/page.tsx | 54 + .../home/[account]/verband/layout.tsx | 5 + .../[locale]/home/[account]/verband/page.tsx | 33 + .../settings/_components/settings-content.tsx | 255 +++ .../home/[account]/verband/settings/page.tsx | 44 + .../_components/statistics-content.tsx | 100 ++ .../[account]/verband/statistics/page.tsx | 20 + .../web/app/api/club/course-register/route.ts | 52 + apps/web/app/api/club/event-register/route.ts | 64 + .../app/api/club/membership-apply/route.ts | 70 + apps/web/app/api/healthcheck/route.ts | 5 +- apps/web/components/account-not-found.tsx | 22 + apps/web/components/confirm-dialog.tsx | 55 + apps/web/config/feature-flags.config.ts | 15 + apps/web/config/paths.config.ts | 6 + apps/web/i18n/messages/de/cms.json | 511 +++++- apps/web/i18n/messages/de/common.json | 5 +- apps/web/i18n/messages/de/marketing.json | 108 +- apps/web/i18n/messages/en/cms.json | 29 +- apps/web/i18n/messages/en/common.json | 5 +- apps/web/lib/status-badges.ts | 127 ++ apps/web/package.json | 3 + .../migrations/20260412000001_fischerei.sql | 862 ++++++++++ ...60412000002_fischerei_permission_seeds.sql | 7 + .../20260413000001_sitzungsprotokolle.sql | 224 +++ .../20260413000002_verbandsverwaltung.sql | 377 +++++ ...60413000003_universal_permission_seeds.sql | 10 + docker-compose.yml | 228 ++- docker/db/dev-bootstrap.sh | 51 + docker/db/zzz-role-passwords.sh | 34 + docker/kong.yml | 29 + .../auth/src/components/password-input.tsx | 1 + .../src/components/create-course-form.tsx | 2 +- .../src/components/create-event-form.tsx | 2 +- .../event-management/src/server/api.ts | 23 +- packages/features/fischerei/package.json | 40 + .../src/components/catch-books-data-table.tsx | 212 +++ .../components/competitions-data-table.tsx | 146 ++ .../src/components/create-species-form.tsx | 245 +++ .../src/components/create-stocking-form.tsx | 257 +++ .../src/components/create-water-form.tsx | 445 +++++ .../src/components/fischerei-dashboard.tsx | 171 ++ .../components/fischerei-tab-navigation.tsx | 54 + .../fischerei/src/components/index.ts | 10 + .../src/components/species-data-table.tsx | 179 ++ .../src/components/stocking-data-table.tsx | 158 ++ .../src/components/waters-data-table.tsx | 234 +++ .../fischerei/src/lib/fischerei-constants.ts | 102 ++ .../fischerei/src/lib/fischerei-utils.ts | 131 ++ .../fischerei/src/schema/fischerei.schema.ts | 430 +++++ .../src/server/actions/fischerei-actions.ts | 673 ++++++++ packages/features/fischerei/src/server/api.ts | 1458 +++++++++++++++++ packages/features/fischerei/tsconfig.json | 6 + .../src/components/application-workflow.tsx | 33 +- .../member-management/src/lib/member-utils.ts | 14 + .../src/components/create-newsletter-form.tsx | 2 +- packages/features/site-builder/package.json | 1 + .../src/components/site-renderer.tsx | 12 +- .../site-builder/src/config/puck-config.tsx | 442 ++++- .../src/context/site-data-context.tsx | 57 + .../features/sitzungsprotokolle/package.json | 39 + .../src/components/create-protocol-form.tsx | 228 +++ .../src/components/index.ts | 6 + .../src/components/meetings-dashboard.tsx | 210 +++ .../components/meetings-tab-navigation.tsx | 47 + .../src/components/open-tasks-view.tsx | 189 +++ .../src/components/protocol-items-list.tsx | 163 ++ .../src/components/protocols-data-table.tsx | 233 +++ .../src/lib/meetings-constants.ts | 36 + .../src/schema/meetings.schema.ts | 95 ++ .../src/server/actions/meetings-actions.ts | 206 +++ .../sitzungsprotokolle/src/server/api.ts | 394 +++++ .../features/sitzungsprotokolle/tsconfig.json | 6 + .../features/verbandsverwaltung/package.json | 40 + .../src/components/club-contacts-manager.tsx | 273 +++ .../src/components/club-fee-billing-table.tsx | 142 ++ .../src/components/club-notes-list.tsx | 135 ++ .../src/components/clubs-data-table.tsx | 238 +++ .../src/components/create-club-form.tsx | 336 ++++ .../src/components/index.ts | 7 + .../src/components/verband-dashboard.tsx | 172 ++ .../src/components/verband-tab-navigation.tsx | 48 + .../src/lib/verband-constants.ts | 76 + .../src/schema/verband.schema.ts | 196 +++ .../src/server/actions/verband-actions.ts | 472 ++++++ .../verbandsverwaltung/src/server/api.ts | 682 ++++++++ .../features/verbandsverwaltung/tsconfig.json | 6 + pnpm-lock.yaml | 159 +- 176 files changed, 17133 insertions(+), 981 deletions(-) create mode 100644 .build-cache-buster create mode 100644 .env create mode 100644 QA_TEST_PLAN.md create mode 100644 apps/web/app/[locale]/home/[account]/documents/_components/generate-document-form.tsx create mode 100644 apps/web/app/[locale]/home/[account]/documents/_lib/server/generate-document.ts create mode 100644 apps/web/app/[locale]/home/[account]/fischerei/catch-books/page.tsx create mode 100644 apps/web/app/[locale]/home/[account]/fischerei/competitions/page.tsx create mode 100644 apps/web/app/[locale]/home/[account]/fischerei/layout.tsx create mode 100644 apps/web/app/[locale]/home/[account]/fischerei/leases/page.tsx create mode 100644 apps/web/app/[locale]/home/[account]/fischerei/page.tsx create mode 100644 apps/web/app/[locale]/home/[account]/fischerei/permits/page.tsx create mode 100644 apps/web/app/[locale]/home/[account]/fischerei/species/new/page.tsx create mode 100644 apps/web/app/[locale]/home/[account]/fischerei/species/page.tsx create mode 100644 apps/web/app/[locale]/home/[account]/fischerei/statistics/page.tsx create mode 100644 apps/web/app/[locale]/home/[account]/fischerei/stocking/new/page.tsx create mode 100644 apps/web/app/[locale]/home/[account]/fischerei/stocking/page.tsx create mode 100644 apps/web/app/[locale]/home/[account]/fischerei/waters/new/page.tsx create mode 100644 apps/web/app/[locale]/home/[account]/fischerei/waters/page.tsx create mode 100644 apps/web/app/[locale]/home/[account]/meetings/layout.tsx create mode 100644 apps/web/app/[locale]/home/[account]/meetings/page.tsx create mode 100644 apps/web/app/[locale]/home/[account]/meetings/protocols/[protocolId]/page.tsx create mode 100644 apps/web/app/[locale]/home/[account]/meetings/protocols/new/page.tsx create mode 100644 apps/web/app/[locale]/home/[account]/meetings/protocols/page.tsx create mode 100644 apps/web/app/[locale]/home/[account]/meetings/tasks/page.tsx create mode 100644 apps/web/app/[locale]/home/[account]/members-cms/departments/create-department-dialog.tsx create mode 100644 apps/web/app/[locale]/home/[account]/modules/_components/module-toggles.tsx create mode 100644 apps/web/app/[locale]/home/[account]/modules/_lib/server/toggle-module.ts create mode 100644 apps/web/app/[locale]/home/[account]/verband/clubs/[clubId]/page.tsx create mode 100644 apps/web/app/[locale]/home/[account]/verband/clubs/new/page.tsx create mode 100644 apps/web/app/[locale]/home/[account]/verband/clubs/page.tsx create mode 100644 apps/web/app/[locale]/home/[account]/verband/layout.tsx create mode 100644 apps/web/app/[locale]/home/[account]/verband/page.tsx create mode 100644 apps/web/app/[locale]/home/[account]/verband/settings/_components/settings-content.tsx create mode 100644 apps/web/app/[locale]/home/[account]/verband/settings/page.tsx create mode 100644 apps/web/app/[locale]/home/[account]/verband/statistics/_components/statistics-content.tsx create mode 100644 apps/web/app/[locale]/home/[account]/verband/statistics/page.tsx create mode 100644 apps/web/app/api/club/course-register/route.ts create mode 100644 apps/web/app/api/club/event-register/route.ts create mode 100644 apps/web/app/api/club/membership-apply/route.ts create mode 100644 apps/web/components/account-not-found.tsx create mode 100644 apps/web/components/confirm-dialog.tsx create mode 100644 apps/web/lib/status-badges.ts create mode 100644 apps/web/supabase/migrations/20260412000001_fischerei.sql create mode 100644 apps/web/supabase/migrations/20260412000002_fischerei_permission_seeds.sql create mode 100644 apps/web/supabase/migrations/20260413000001_sitzungsprotokolle.sql create mode 100644 apps/web/supabase/migrations/20260413000002_verbandsverwaltung.sql create mode 100644 apps/web/supabase/migrations/20260413000003_universal_permission_seeds.sql create mode 100755 docker/db/dev-bootstrap.sh create mode 100755 docker/db/zzz-role-passwords.sh create mode 100644 packages/features/fischerei/package.json create mode 100644 packages/features/fischerei/src/components/catch-books-data-table.tsx create mode 100644 packages/features/fischerei/src/components/competitions-data-table.tsx create mode 100644 packages/features/fischerei/src/components/create-species-form.tsx create mode 100644 packages/features/fischerei/src/components/create-stocking-form.tsx create mode 100644 packages/features/fischerei/src/components/create-water-form.tsx create mode 100644 packages/features/fischerei/src/components/fischerei-dashboard.tsx create mode 100644 packages/features/fischerei/src/components/fischerei-tab-navigation.tsx create mode 100644 packages/features/fischerei/src/components/index.ts create mode 100644 packages/features/fischerei/src/components/species-data-table.tsx create mode 100644 packages/features/fischerei/src/components/stocking-data-table.tsx create mode 100644 packages/features/fischerei/src/components/waters-data-table.tsx create mode 100644 packages/features/fischerei/src/lib/fischerei-constants.ts create mode 100644 packages/features/fischerei/src/lib/fischerei-utils.ts create mode 100644 packages/features/fischerei/src/schema/fischerei.schema.ts create mode 100644 packages/features/fischerei/src/server/actions/fischerei-actions.ts create mode 100644 packages/features/fischerei/src/server/api.ts create mode 100644 packages/features/fischerei/tsconfig.json create mode 100644 packages/features/site-builder/src/context/site-data-context.tsx create mode 100644 packages/features/sitzungsprotokolle/package.json create mode 100644 packages/features/sitzungsprotokolle/src/components/create-protocol-form.tsx create mode 100644 packages/features/sitzungsprotokolle/src/components/index.ts create mode 100644 packages/features/sitzungsprotokolle/src/components/meetings-dashboard.tsx create mode 100644 packages/features/sitzungsprotokolle/src/components/meetings-tab-navigation.tsx create mode 100644 packages/features/sitzungsprotokolle/src/components/open-tasks-view.tsx create mode 100644 packages/features/sitzungsprotokolle/src/components/protocol-items-list.tsx create mode 100644 packages/features/sitzungsprotokolle/src/components/protocols-data-table.tsx create mode 100644 packages/features/sitzungsprotokolle/src/lib/meetings-constants.ts create mode 100644 packages/features/sitzungsprotokolle/src/schema/meetings.schema.ts create mode 100644 packages/features/sitzungsprotokolle/src/server/actions/meetings-actions.ts create mode 100644 packages/features/sitzungsprotokolle/src/server/api.ts create mode 100644 packages/features/sitzungsprotokolle/tsconfig.json create mode 100644 packages/features/verbandsverwaltung/package.json create mode 100644 packages/features/verbandsverwaltung/src/components/club-contacts-manager.tsx create mode 100644 packages/features/verbandsverwaltung/src/components/club-fee-billing-table.tsx create mode 100644 packages/features/verbandsverwaltung/src/components/club-notes-list.tsx create mode 100644 packages/features/verbandsverwaltung/src/components/clubs-data-table.tsx create mode 100644 packages/features/verbandsverwaltung/src/components/create-club-form.tsx create mode 100644 packages/features/verbandsverwaltung/src/components/index.ts create mode 100644 packages/features/verbandsverwaltung/src/components/verband-dashboard.tsx create mode 100644 packages/features/verbandsverwaltung/src/components/verband-tab-navigation.tsx create mode 100644 packages/features/verbandsverwaltung/src/lib/verband-constants.ts create mode 100644 packages/features/verbandsverwaltung/src/schema/verband.schema.ts create mode 100644 packages/features/verbandsverwaltung/src/server/actions/verband-actions.ts create mode 100644 packages/features/verbandsverwaltung/src/server/api.ts create mode 100644 packages/features/verbandsverwaltung/tsconfig.json diff --git a/.build-cache-buster b/.build-cache-buster new file mode 100644 index 000000000..675e77beb --- /dev/null +++ b/.build-cache-buster @@ -0,0 +1 @@ +Di. 31 März 2026 01:21:52 CEST diff --git a/.dockerignore b/.dockerignore index d26dfabb5..afbfc0dca 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,6 +1,7 @@ node_modules -.next +**/.next .turbo +**/.turbo .git *.md .env* @@ -10,3 +11,8 @@ apps/dev-tool .gitnexus .gsd .claude +.gemini +.junie +.github +docs +**/*.tsbuildinfo diff --git a/.env b/.env new file mode 100644 index 000000000..179a63669 --- /dev/null +++ b/.env @@ -0,0 +1,23 @@ +# ===================================================== +# MyEasyCMS v2 — Local Development Environment +# Auto-generated — Mo. 30 März 2026 17:11:01 CEST +# ===================================================== + +POSTGRES_PASSWORD=BqgGAwNuKeZZtaE2VJRAkxR48zj9XVw +JWT_SECRET=BhD5zXZXcdI23FtNk9kIeGN5z06UQ1fZUhCLvMr6 + +SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzc0ODgzNDYwLCJleHAiOjIwOTAyNDM0NjB9.hbeQae1gYRTcJQ_6m7MPjoDlYFhp4tsszEQD2g5q6vY +SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoic2VydmljZV9yb2xlIiwiaXNzIjoic3VwYWJhc2UiLCJpYXQiOjE3NzQ4ODM0NjAsImV4cCI6MjA5MDI0MzQ2MH0.gqAnWDmPmnf-XMA9FcIKGo7qVVxtJw5UvSsTlTup3Ns + +SITE_URL=http://localhost:3000 +APP_PORT=3000 + +KONG_HTTP_PORT=8000 +KONG_HTTPS_PORT=8443 +API_EXTERNAL_URL=http://localhost:8000 + +ENABLE_EMAIL_AUTOCONFIRM=true +DISABLE_SIGNUP=false +JWT_EXPIRY=3600 + +DB_WEBHOOK_SECRET=local-dev-webhook-secret diff --git a/.env.production.example b/.env.production.example index f801bc32b..a79d41842 100644 --- a/.env.production.example +++ b/.env.production.example @@ -1,10 +1,12 @@ # ===================================================== -# MyEasyCMS v2 — Environment Variables +# MyEasyCMS v2 — Environment Variables (Production) # Copy to .env and fill in your values # ===================================================== -# --- Supabase --- +# --- Supabase Database --- POSTGRES_PASSWORD=change-me-to-a-strong-password + +# --- Supabase Auth --- JWT_SECRET=change-me-to-at-least-32-characters-long-secret # Generate these with: npx supabase gen keys @@ -15,11 +17,17 @@ SUPABASE_SERVICE_ROLE_KEY=your-service-role-key-here SITE_URL=https://myeasycms.de APP_PORT=3000 -# --- Kong --- +# --- Kong (API Gateway) --- KONG_HTTP_PORT=8000 KONG_HTTPS_PORT=8443 API_EXTERNAL_URL=https://api.myeasycms.de +# --- Studio (Dashboard) --- +STUDIO_PORT=54323 + +# --- Inbucket (Email testing — dev only) --- +INBUCKET_PORT=54324 + # --- Email (SMTP) --- SMTP_HOST=smtp.example.com SMTP_PORT=587 @@ -27,7 +35,7 @@ SMTP_USER=noreply@myeasycms.de SMTP_PASS=your-smtp-password SMTP_ADMIN_EMAIL=admin@myeasycms.de -# --- Auth --- +# --- Auth Settings --- ENABLE_EMAIL_AUTOCONFIRM=false DISABLE_SIGNUP=false JWT_EXPIRY=3600 diff --git a/Dockerfile b/Dockerfile index 875c24f4e..b70083d30 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,23 +2,15 @@ FROM node:22-alpine AS base RUN corepack enable && corepack prepare pnpm@latest --activate WORKDIR /app -# --- Install deps --- -FROM base AS deps -COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./ -COPY apps/web/package.json ./apps/web/ -COPY apps/dev-tool/package.json ./apps/dev-tool/ -COPY tooling/ ./tooling/ -COPY packages/ ./packages/ -RUN pnpm install --frozen-lockfile - -# --- Build --- +# --- Install + Build in one stage --- FROM base AS builder -COPY --from=deps /app/ ./ +ARG CACHE_BUST=1 COPY . . +RUN pnpm install --no-frozen-lockfile ENV NEXT_TELEMETRY_DISABLED=1 ENV NEXT_PUBLIC_SITE_URL=https://myeasycms.de -ENV NEXT_PUBLIC_SUPABASE_URL=placeholder -ENV NEXT_PUBLIC_SUPABASE_PUBLIC_KEY=placeholder +ENV NEXT_PUBLIC_SUPABASE_URL=http://localhost:8000 +ENV NEXT_PUBLIC_SUPABASE_PUBLIC_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzc0ODgzNDYwLCJleHAiOjIwOTAyNDM0NjB9.hbeQae1gYRTcJQ_6m7MPjoDlYFhp4tsszEQD2g5q6vY RUN pnpm --filter web build # --- Run --- diff --git a/QA_TEST_PLAN.md b/QA_TEST_PLAN.md new file mode 100644 index 000000000..755445a9a --- /dev/null +++ b/QA_TEST_PLAN.md @@ -0,0 +1,501 @@ +# MyEasyCMS v2 — Comprehensive QA Test Plan + +## Test Environment +- App: localhost:3000 (Docker) +- Supabase: localhost:8000 (Kong gateway) +- Studio: localhost:54323 +- DB: supabase/postgres:15.8.1.060 + +## Test Accounts +| Email | Password | Role | Team | +|-------|----------|------|------| +| super-admin@makerkit.dev | testingpassword | Super Admin | - | +| test@makerkit.dev | testingpassword | Owner | Makerkit | +| owner@makerkit.dev | testingpassword | Owner | Makerkit | +| member@makerkit.dev | testingpassword | Member | Makerkit | +| custom@makerkit.dev | testingpassword | Custom | Makerkit | + +## Test Categories + +### A. Authentication & Authorization (12 tests) +### B. Public Pages (8 tests) +### C. Team Dashboard & Navigation (10 tests) +### D. Member Management CRUD (15 tests) +### E. Course Management CRUD (10 tests) +### F. Event Management CRUD (8 tests) +### G. Document Generation (8 tests) +### H. Newsletter System (6 tests) +### I. Site Builder & Public Club Pages (12 tests) +### J. Finance / SEPA (6 tests) +### K. Fischerei Module (12 tests) +### L. Sitzungsprotokolle Module (8 tests) +### M. Verbandsverwaltung Module (8 tests) +### N. Module Activation System (6 tests) +### O. Admin Panel (8 tests) +### P. Public Registration APIs (9 tests) +### Q. Edge Cases & Error Handling (10 tests) +### R. Permission Boundaries (8 tests) + +Total: ~156 test cases + +--- + +## A. AUTHENTICATION & AUTHORIZATION + +### A1. Login — Valid credentials +- Setup: Logged out +- Steps: Navigate /auth/sign-in, enter test@makerkit.dev / testingpassword, submit +- Expected: Redirect to /home, user avatar visible +- Pass: URL contains /home, no error toast + +### A2. Login — Invalid password +- Setup: Logged out +- Steps: Enter test@makerkit.dev / wrongpassword, submit +- Expected: Error message, stays on sign-in page +- Pass: Error alert visible, URL still /auth/sign-in + +### A3. Login — Empty fields +- Steps: Click submit without entering anything +- Expected: Client-side validation prevents submit +- Pass: Form doesn't submit, validation indicators shown + +### A4. Login — SQL injection attempt +- Steps: Enter ' OR 1=1-- as email +- Expected: Validation error (not valid email format) +- Pass: No crash, proper error message + +### A5. Registration — Valid +- Steps: Navigate /auth/sign-up, enter unique email, password >= 6 chars, matching repeat +- Expected: Account created, redirect to /home +- Pass: User exists in DB, logged in + +### A6. Registration — Duplicate email +- Steps: Try registering with test@makerkit.dev +- Expected: "Diese Anmeldedaten werden bereits verwendet" +- Pass: Error shown, no crash + +### A7. Registration — Weak password +- Steps: Enter password "123" +- Expected: Validation error (too short) +- Pass: Form doesn't submit + +### A8. Registration — Mismatched passwords +- Steps: Enter different passwords in password and repeat fields +- Expected: Validation error +- Pass: Form shows mismatch error + +### A9. Session persistence +- Steps: Login, close tab, open new tab to /home +- Expected: Still logged in +- Pass: Dashboard loads, not redirected to sign-in + +### A10. Logout +- Steps: Click account dropdown > "Abmelden" +- Expected: Session cleared, redirect to sign-in +- Pass: Accessing /home redirects to /auth/sign-in + +### A11. Protected route — unauthenticated access +- Steps: Clear cookies, navigate to /home/makerkit +- Expected: Redirect to /auth/sign-in +- Pass: URL changes to sign-in + +### A12. Admin route — non-admin access +- Steps: Login as member@makerkit.dev, navigate to /admin +- Expected: 404 (AdminGuard returns notFound) +- Pass: 404 page shown + +--- + +## B. PUBLIC PAGES + +### B1. Landing page loads with real content +- Expected: "Vereinsverwaltung, die mitwächst", "69.000", "SEPA" +- Pass: No placeholder/filler text + +### B2. Pricing page +- Navigate /pricing +- Expected: Pricing table renders + +### B3. FAQ page +- Navigate /faq +- Expected: FAQ items render + +### B4. Contact page +- Navigate /contact +- Expected: Contact form with name/email/message fields + +### B5. Blog page +- Navigate /blog +- Expected: Blog listing (may be empty) + +### B6. Legal pages (privacy, terms, cookies) +- Navigate /privacy-policy, /terms-of-service, /cookie-policy +- Expected: Each loads without error + +### B7. Public club page +- Navigate /club/makerkit +- Expected: Club homepage with Puck content +- Pass: Real data (courses, events) shown, not placeholders + +### B8. Non-existent club page +- Navigate /club/nonexistent +- Expected: 404 page +- Pass: Proper 404, no crash + +--- + +## C. TEAM DASHBOARD & NAVIGATION + +### C1. Team dashboard loads with stats +- Login as test@makerkit.dev, navigate /home/makerkit +- Expected: 4 stat cards, quick actions, activity feed +- Pass: Numbers render (even if 0) + +### C2. All sidebar links work +- Click each sidebar item: Dashboard, Module, Mitglieder, Kurse, Veranstaltungen, Finanzen, Dokumente, Newsletter, Website +- Expected: Each page loads without error + +### C3. Account switcher +- Click account dropdown > "Arbeitsbereich wechseln" +- Expected: Shows list of accounts + +### C4. Team settings — rename +- Navigate /home/makerkit/settings +- Expected: Team name editable, save works + +### C5. Team members list +- Navigate /home/makerkit/members +- Expected: Shows 4 members with roles + +### C6. Non-existent team slug +- Navigate /home/nonexistent +- Expected: Redirect or error page + +### C7. Profile settings +- Navigate /home/settings (personal) +- Expected: Name, language, email change form + +### C8. Theme toggle +- Click theme toggle in nav +- Expected: Dark/light theme switches + +### C9. Breadcrumb navigation +- Navigate to nested page, click breadcrumb links +- Expected: Navigate back correctly + +### C10. Mobile responsive (viewport 375px) +- Resize to mobile +- Expected: Sidebar collapses, hamburger menu works + +--- + +## D. MEMBER MANAGEMENT CRUD + +### D1. List members — empty state +- Navigate /home/makerkit/members-cms +- Expected: Shows "1 Mitglieder insgesamt" (Max Mustermann from earlier test) + +### D2. Create member — all fields +- Navigate /home/makerkit/members-cms/new +- Fill: Vorname=Anna, Nachname=Schmidt, Email=anna@test.de, PLZ=93047, Ort=Regensburg +- Expected: Member created, redirect to list + +### D3. Create member — required fields only +- Fill: Vorname=Test, Nachname=Minimal +- Expected: Created successfully + +### D4. Create member — empty required fields +- Submit with empty Vorname +- Expected: Validation error + +### D5. Create member — invalid email format +- Enter email "notanemail" +- Expected: Validation error + +### D6. Create member — duplicate email +- Create member with same email as existing +- Expected: DB constraint error or validation + +### D7. View member detail +- Click on member name in list +- Expected: Detail page with all fields + +### D8. Edit member +- Navigate to member edit page +- Change Vorname, save +- Expected: Updated in DB and UI + +### D9. Search members +- Type in search box +- Expected: List filters in real-time or on submit + +### D10. Filter by status +- Use status dropdown +- Expected: Only matching members shown + +### D11. Member with SEPA mandate +- Create member with IBAN field filled +- Expected: IBAN saved correctly + +### D12. Member import +- Navigate /home/makerkit/members-cms/import +- Expected: Import wizard loads + +### D13. Member statistics +- Navigate /home/makerkit/members-cms/statistics +- Expected: Statistics page loads + +### D14. Member departments +- Navigate /home/makerkit/members-cms/departments +- Expected: Department management page + +### D15. Pagination +- With many members, verify pagination controls work + +--- + +## E. COURSE MANAGEMENT + +### E1. Course list — shows existing course +- Navigate /home/makerkit/courses +- Expected: "Schwimmkurs Anfänger" visible + +### E2. Create course — valid +- Navigate /home/makerkit/courses/new +- Fill required fields, submit +- Expected: Created, redirect to courses list + +### E3. Course detail +- Click on course name +- Expected: Detail page with participants, schedule + +### E4. Course calendar +- Navigate /home/makerkit/courses/calendar +- Expected: Calendar view loads + +### E5. Course categories +- Navigate /home/makerkit/courses/categories +- Expected: Category management page + +### E6. Course instructors +- Navigate /home/makerkit/courses/instructors +- Expected: Instructor list + +### E7. Course locations +- Navigate /home/makerkit/courses/locations +- Expected: Location management + +### E8. Course statistics +- Navigate /home/makerkit/courses/statistics +- Expected: Statistics page loads + +### E9. Course participants +- Navigate to course > participants tab +- Expected: Participant list (may be empty) + +### E10. Course attendance +- Navigate to course > attendance tab +- Expected: Attendance tracking page + +--- + +## G. DOCUMENT GENERATION + +### G1. Document type selection page +- Navigate /home/makerkit/documents +- Expected: 6 document types shown + +### G2. Generate member cards (PDF) +- Select Mitgliedsausweis, fill title, click Generieren +- Expected: PDF downloads with .pdf extension +- Pass: File downloads, success banner shown + +### G3. Generate labels (HTML) +- Select Etiketten, fill title, click Generieren +- Expected: HTML file downloads with .html extension + +### G4. Generate report (Excel) +- Select Bericht, fill title, click Generieren +- Expected: XLSX downloads with .xlsx extension + +### G5. Coming soon types (invoice, letter, certificate) +- Select Rechnung +- Expected: "Demnächst verfügbar" banner, button disabled + +### G6. Generate with empty title +- Leave title blank, try to submit +- Expected: Validation prevents submit (required field) + +### G7. Generate with no members +- Create new account with no members, try generating +- Expected: Error "Keine aktiven Mitglieder" + +### G8. Document templates page +- Navigate /home/makerkit/documents/templates +- Expected: Page loads, shows empty state + +--- + +## I. SITE BUILDER & PUBLIC CLUB PAGES + +### I1. Site builder list +- Navigate /home/makerkit/site-builder +- Expected: Shows pages list (hello, Über uns) + +### I2. Create new page +- Navigate /home/makerkit/site-builder/new +- Fill title + slug, submit +- Expected: Page created, Puck editor opens + +### I3. Site builder settings +- Navigate /home/makerkit/site-builder/settings +- Expected: Design settings (name, colors, font, publish toggle) + +### I4. Public page — published +- Navigate /club/makerkit/hello +- Expected: Puck content renders + +### I5. Public page — unpublished +- Navigate /club/makerkit/ueber-uns +- Expected: 404 (not published) + +### I6. Public page — non-existent +- Navigate /club/makerkit/nonexistent +- Expected: 404 + +### I7. Course data on public page +- /club/makerkit should show "Schwimmkurs Anfänger" +- Expected: Real course data, not placeholders + +### I8. Course registration form +- Click "Anmelden" on a course on the public page +- Fill form, submit +- Expected: "Anmeldung erfolgreich!" message + +### I9. Event registration (no events) +- EventList block should show "Keine anstehenden Veranstaltungen" + +### I10. Membership application form +- Fill "Mitglied werden" form on public page +- Submit with valid data +- Expected: Application saved in DB + +### I11. Membership application — invalid email +- Submit with invalid email +- Expected: Client-side validation error + +### I12. Newsletter signup +- Use newsletter signup block +- Expected: Subscription created or error + +--- + +## N. MODULE ACTIVATION SYSTEM + +### N1. Module toggles page shows all modules +- Navigate /home/makerkit/modules +- Expected: Fischerei, Sitzungsprotokolle, Verbandsverwaltung toggles visible + +### N2. Activate Fischerei +- Toggle Fischerei ON +- Expected: "Fischerei" appears in sidebar + +### N3. Deactivate Fischerei +- Toggle Fischerei OFF +- Expected: "Fischerei" disappears from sidebar + +### N4. Activate Sitzungsprotokolle +- Toggle ON +- Expected: "Sitzungsprotokolle" appears in sidebar + +### N5. Activate Verbandsverwaltung +- Toggle ON +- Expected: "Verbandsverwaltung" appears in sidebar + +### N6. Direct URL access to deactivated module +- Deactivate Fischerei, navigate /home/makerkit/fischerei +- Expected: Page still loads (data exists) but not in sidebar + +--- + +## P. PUBLIC REGISTRATION APIS + +### P1. Course registration — valid +- POST /api/club/course-register with valid courseId, name, email +- Expected: 200 { success: true } + +### P2. Course registration — missing fields +- POST without email +- Expected: 400 error + +### P3. Course registration — invalid courseId +- POST with random UUID +- Expected: DB error or 500 + +### P4. Event registration — valid +- POST /api/club/event-register with valid data +- Expected: 200 success (need an event first) + +### P5. Membership application — valid +- POST /api/club/membership-apply with all fields +- Expected: Row inserted in membership_applications + +### P6. Membership application — invalid email +- POST with email "notanemail" +- Expected: 400 validation error + +### P7. Membership application — missing accountId +- POST without accountId +- Expected: Error + +### P8. Rate limiting (if any) +- Send 100 rapid POSTs +- Expected: No crash (may or may not rate limit) + +### P9. XSS in form fields +- Submit as firstName +- Expected: Stored as text, not executed on render + +--- + +## Q. EDGE CASES & ERROR HANDLING + +### Q1. API healthcheck +- GET /api/healthcheck +- Expected: { services: { database: true } } + +### Q2. 404 for unknown route +- Navigate /nonexistent +- Expected: 404 page with "Seite nicht gefunden" + +### Q3. Direct DB access via PostgREST (anon) +- GET localhost:8000/rest/v1/members (anon key, no auth) +- Expected: Empty array (RLS blocks anon) + +### Q4. JWT expiration handling +- Login, wait for token expiry (3600s), try action +- Expected: Auto-refresh or redirect to login + +### Q5. Concurrent writes +- Two users edit same member simultaneously +- Expected: Last write wins, no crash + +### Q6. Very long text input +- Enter 10000 char string in a text field +- Expected: Validation limits or graceful handling + +### Q7. Unicode/emoji in names +- Create member with name "Müller-Lüdenscheidt 🎣" +- Expected: Saved and displayed correctly + +### Q8. Browser back button +- Navigate deep, press back +- Expected: Previous page loads correctly + +### Q9. Double form submission +- Click submit twice rapidly +- Expected: Only one record created (isPending disables button) + +### Q10. Network disconnect during submit +- Submit form, disconnect network mid-request +- Expected: Error message, no partial data corruption diff --git a/apps/web/app/[locale]/club/[slug]/[...page]/page.tsx b/apps/web/app/[locale]/club/[slug]/[...page]/page.tsx index 1756eb8d4..a750b2d94 100644 --- a/apps/web/app/[locale]/club/[slug]/[...page]/page.tsx +++ b/apps/web/app/[locale]/club/[slug]/[...page]/page.tsx @@ -1,6 +1,7 @@ import { createClient } from '@supabase/supabase-js'; import { notFound } from 'next/navigation'; import { SiteRenderer } from '@kit/site-builder/components'; +import type { SiteData } from '@kit/site-builder/context'; interface Props { params: Promise<{ slug: string; page: string[] }> } @@ -23,9 +24,26 @@ export default async function ClubSubPage({ params }: Props) { .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), + ]); + + const siteData: SiteData = { + accountId: account.id, + events: eventsRes.data ?? [], + courses: (coursesRes.data ?? []).map(c => ({ ...c, enrolled_count: 0 })), + posts: postsRes.data ?? [], + }; + return (
- } /> + } siteData={siteData} />
); } diff --git a/apps/web/app/[locale]/club/[slug]/page.tsx b/apps/web/app/[locale]/club/[slug]/page.tsx index d5c3eb0e7..eea99aa17 100644 --- a/apps/web/app/[locale]/club/[slug]/page.tsx +++ b/apps/web/app/[locale]/club/[slug]/page.tsx @@ -1,34 +1,48 @@ import { createClient } from '@supabase/supabase-js'; import { notFound } from 'next/navigation'; import { SiteRenderer } from '@kit/site-builder/components'; +import type { SiteData } from '@kit/site-builder/context'; interface Props { params: Promise<{ slug: string }> } export default async function ClubHomePage({ params }: Props) { const { slug } = await params; - // Use anon client for public access const supabase = createClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_PUBLIC_KEY!, ); - // Resolve slug → account const { data: account } = await supabase.from('accounts').select('id, name').eq('slug', slug).single(); if (!account) notFound(); - // Check site is public const { data: settings } = await supabase.from('site_settings').select('*').eq('account_id', account.id).eq('is_public', true).maybeSingle(); if (!settings) notFound(); - // Get homepage 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), + ]); + + const siteData: SiteData = { + accountId: account.id, + events: eventsRes.data ?? [], + courses: (coursesRes.data ?? []).map(c => ({ ...c, enrolled_count: 0 })), + posts: postsRes.data ?? [], + }; + return (
- } /> + } siteData={siteData} />
); } 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 dc162fc99..70d208c25 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 @@ -4,6 +4,7 @@ import Link from 'next/link'; import { useRouter } from 'next/navigation'; import { Home, LogOut, Menu } from 'lucide-react'; +import * as z from 'zod'; import { AccountSelector } from '@kit/accounts/account-selector'; import { useSignOut } from '@kit/supabase/hooks/use-sign-out'; @@ -21,11 +22,11 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from '@kit/ui/dropdown-menu'; +import { NavigationConfigSchema } from '@kit/ui/navigation-schema'; import { Trans } from '@kit/ui/trans'; import featureFlagsConfig from '~/config/feature-flags.config'; import pathsConfig from '~/config/paths.config'; -import { getTeamAccountSidebarConfig } from '~/config/team-account-navigation.config'; type Accounts = Array<{ label: string | null; @@ -43,11 +44,12 @@ export const TeamAccountLayoutMobileNavigation = ( account: string; userId: string; accounts: Accounts; + config: z.output; }>, ) => { const signOut = useSignOut(); - const Links = getTeamAccountSidebarConfig(props.account).routes.map( + const Links = props.config.routes.map( (item, index) => { if ('children' in item) { return item.children.map((child) => { diff --git a/apps/web/app/[locale]/home/[account]/_components/team-account-layout-sidebar.tsx b/apps/web/app/[locale]/home/[account]/_components/team-account-layout-sidebar.tsx index 6eae946fc..365bd8e3a 100644 --- a/apps/web/app/[locale]/home/[account]/_components/team-account-layout-sidebar.tsx +++ b/apps/web/app/[locale]/home/[account]/_components/team-account-layout-sidebar.tsx @@ -1,11 +1,13 @@ +import * as z from 'zod'; + import { JWTUserData } from '@kit/supabase/types'; import { If } from '@kit/ui/if'; +import { NavigationConfigSchema } from '@kit/ui/navigation-schema'; import { Sidebar, SidebarContent, SidebarHeader } from '@kit/ui/sidebar'; import type { AccountModel } from '~/components/workspace-dropdown'; import { WorkspaceDropdown } from '~/components/workspace-dropdown'; import featureFlagsConfig from '~/config/feature-flags.config'; -import { getTeamAccountSidebarConfig } from '~/config/team-account-navigation.config'; import { TeamAccountNotifications } from '~/home/[account]/_components/team-account-notifications'; import { TeamAccountLayoutSidebarNavigation } from './team-account-layout-sidebar-navigation'; @@ -15,10 +17,10 @@ export function TeamAccountLayoutSidebar(props: { accountId: string; accounts: AccountModel[]; user: JWTUserData; + config: z.output; }) { - const { account, accounts, user } = props; + const { account, accounts, user, config } = props; - const config = getTeamAccountSidebarConfig(account); const collapsible = config.sidebarCollapsedStyle; return ( diff --git a/apps/web/app/[locale]/home/[account]/_components/team-account-navigation-menu.tsx b/apps/web/app/[locale]/home/[account]/_components/team-account-navigation-menu.tsx index 500701d72..5f6695643 100644 --- a/apps/web/app/[locale]/home/[account]/_components/team-account-navigation-menu.tsx +++ b/apps/web/app/[locale]/home/[account]/_components/team-account-navigation-menu.tsx @@ -1,13 +1,15 @@ +import * as z from 'zod'; + import { BorderedNavigationMenu, BorderedNavigationMenuItem, } from '@kit/ui/bordered-navigation-menu'; import { If } from '@kit/ui/if'; +import { NavigationConfigSchema } from '@kit/ui/navigation-schema'; import { AppLogo } from '~/components/app-logo'; import { ProfileAccountDropdownContainer } from '~/components/personal-account-dropdown-container'; import featureFlagsConfig from '~/config/feature-flags.config'; -import { getTeamAccountSidebarConfig } from '~/config/team-account-navigation.config'; import { TeamAccountAccountsSelector } from '~/home/[account]/_components/team-account-accounts-selector'; // local imports @@ -16,10 +18,11 @@ import { TeamAccountNotifications } from './team-account-notifications'; export function TeamAccountNavigationMenu(props: { workspace: TeamAccountWorkspace; + config: z.output; }) { const { account, user, accounts } = props.workspace; - const routes = getTeamAccountSidebarConfig(account.slug).routes.reduce< + const routes = props.config.routes.reduce< Array<{ path: string; label: string; 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 f156b848f..7fcb11e44 100644 --- a/apps/web/app/[locale]/home/[account]/bookings/[bookingId]/page.tsx +++ b/apps/web/app/[locale]/home/[account]/bookings/[bookingId]/page.tsx @@ -21,9 +21,8 @@ import { 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'; interface PageProps { params: Promise<{ account: string; bookingId: string }>; @@ -60,9 +59,13 @@ export default async function BookingDetailPage({ params }: PageProps) { .eq('slug', account) .single(); - if (!acct) return
Konto nicht gefunden
; - - const api = createBookingManagementApi(client); + if (!acct) { + return ( + + + + ); + } // Load booking directly const { data: booking } = await client @@ -117,7 +120,6 @@ export default async function BookingDetailPage({ params }: PageProps) {
-

Buchungsdetails

{STATUS_LABEL[status] ?? status} 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 b7d278dbb..82221898d 100644 --- a/apps/web/app/[locale]/home/[account]/bookings/calendar/page.tsx +++ b/apps/web/app/[locale]/home/[account]/bookings/calendar/page.tsx @@ -10,6 +10,7 @@ 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'; interface PageProps { params: Promise<{ account: string }>; @@ -56,7 +57,13 @@ export default async function BookingCalendarPage({ params }: PageProps) { .eq('slug', account) .single(); - if (!acct) return
Konto nicht gefunden
; + if (!acct) { + return ( + + + + ); + } const api = createBookingManagementApi(client); @@ -128,12 +135,9 @@ export default async function BookingCalendarPage({ params }: PageProps) { -
-

Belegungskalender

-

- Zimmerauslastung im Überblick -

-
+

+ Zimmerauslastung im Überblick +

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 cb8abc52d..653a3a699 100644 --- a/apps/web/app/[locale]/home/[account]/bookings/guests/page.tsx +++ b/apps/web/app/[locale]/home/[account]/bookings/guests/page.tsx @@ -8,6 +8,7 @@ import { createBookingManagementApi } from '@kit/booking-management/api'; 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 }>; @@ -23,7 +24,13 @@ export default async function GuestsPage({ params }: PageProps) { .eq('slug', account) .single(); - if (!acct) return
Konto nicht gefunden
; + if (!acct) { + return ( + + + + ); + } const api = createBookingManagementApi(client); const guests = await api.listGuests(acct.id); @@ -32,10 +39,7 @@ export default async function GuestsPage({ params }: PageProps) {
-
-

Gäste

-

Gästeverwaltung

-
+

Gästeverwaltung

+ {/* Search */} +
+
+ + +
+ + {searchQuery && ( + + + + )} +
+ {/* Table or Empty State */} - {bookings.data.length === 0 ? ( + {bookingsData.length === 0 ? ( } - title="Keine Buchungen vorhanden" - description="Erstellen Sie Ihre erste Buchung, um loszulegen." - actionLabel="Neue Buchung" - actionHref={`/home/${account}/bookings/new`} + title={ + searchQuery + ? 'Keine Buchungen gefunden' + : 'Keine Buchungen vorhanden' + } + description={ + searchQuery + ? `Keine Ergebnisse für „${searchQuery}".` + : 'Erstellen Sie Ihre erste Buchung, um loszulegen.' + } + actionLabel={searchQuery ? undefined : 'Neue Buchung'} + actionHref={ + searchQuery ? undefined : `/home/${account}/bookings/new` + } /> ) : ( - Alle Buchungen ({bookings.total}) + + {searchQuery + ? `Ergebnisse (${bookingsData.length})` + : `Alle Buchungen (${total})`} +
@@ -128,51 +210,104 @@ export default async function BookingsPage({ params }: PageProps) { - {bookings.data.map((booking: Record) => ( - - - - {String(booking.room_id ?? '—')} - - - - {String(booking.guest_id ?? '—')} - - - {booking.check_in - ? new Date(String(booking.check_in)).toLocaleDateString('de-DE') - : '—'} - - - {booking.check_out - ? new Date(String(booking.check_out)).toLocaleDateString('de-DE') - : '—'} - - - - {STATUS_LABEL[String(booking.status)] ?? String(booking.status)} - - - - {booking.total_price != null - ? `${Number(booking.total_price).toFixed(2)} €` - : '—'} - - - ))} + {bookingsData.map((booking) => { + const room = booking.room as Record | null; + const guest = booking.guest as Record | null; + + return ( + + + + {room + ? `${room.room_number}${room.name ? ` – ${room.name}` : ''}` + : '—'} + + + + {guest + ? `${guest.first_name} ${guest.last_name}` + : '—'} + + + {booking.check_in + ? new Date( + String(booking.check_in), + ).toLocaleDateString('de-DE') + : '—'} + + + {booking.check_out + ? new Date( + String(booking.check_out), + ).toLocaleDateString('de-DE') + : '—'} + + + + {STATUS_LABEL[String(booking.status)] ?? + String(booking.status)} + + + + {booking.total_price != null + ? `${Number(booking.total_price).toFixed(2)} €` + : '—'} + + + ); + })}
+ + {/* Pagination */} + {totalPages > 1 && !searchQuery && ( +
+

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

+
+ {page > 1 ? ( + + + + ) : ( + + )} + + {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 bd058cb7e..ab05ccb88 100644 --- a/apps/web/app/[locale]/home/[account]/bookings/rooms/page.tsx +++ b/apps/web/app/[locale]/home/[account]/bookings/rooms/page.tsx @@ -9,6 +9,7 @@ import { createBookingManagementApi } from '@kit/booking-management/api'; 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 }>; @@ -24,7 +25,13 @@ export default async function RoomsPage({ params }: PageProps) { .eq('slug', account) .single(); - if (!acct) return
Konto nicht gefunden
; + if (!acct) { + return ( + + + + ); + } const api = createBookingManagementApi(client); const rooms = await api.listRooms(acct.id); @@ -33,10 +40,7 @@ export default async function RoomsPage({ params }: PageProps) {
-
-

Zimmer

-

Zimmerverwaltung

-
+

Zimmerverwaltung

-
-

Kurskalender

-

- Kurstermine im Überblick -

-
+

+ Kurstermine im Überblick +

diff --git a/apps/web/app/[locale]/home/[account]/courses/categories/page.tsx b/apps/web/app/[locale]/home/[account]/courses/categories/page.tsx index e376f4fce..3a0b7f08d 100644 --- a/apps/web/app/[locale]/home/[account]/courses/categories/page.tsx +++ b/apps/web/app/[locale]/home/[account]/courses/categories/page.tsx @@ -8,6 +8,7 @@ import { createCourseManagementApi } from '@kit/course-management/api'; 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 }>; @@ -23,7 +24,7 @@ export default async function CategoriesPage({ params }: PageProps) { .eq('slug', account) .single(); - if (!acct) return
Konto nicht gefunden
; + if (!acct) return ; const api = createCourseManagementApi(client); const categories = await api.listCategories(acct.id); @@ -32,10 +33,7 @@ export default async function CategoriesPage({ params }: PageProps) {
-
-

Kategorien

-

Kurskategorien verwalten

-
+

Kurskategorien verwalten

+ + {/* Pagination */} + {totalPages > 1 && ( +
+

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

+
+ {page > 1 ? ( + + + + ) : ( + + )} + + {page < totalPages ? ( + + + + ) : ( + + )} +
+
+ )} )} diff --git a/apps/web/app/[locale]/home/[account]/courses/statistics/page.tsx b/apps/web/app/[locale]/home/[account]/courses/statistics/page.tsx index 2ebe734b8..acb2d8007 100644 --- a/apps/web/app/[locale]/home/[account]/courses/statistics/page.tsx +++ b/apps/web/app/[locale]/home/[account]/courses/statistics/page.tsx @@ -8,6 +8,7 @@ import { createCourseManagementApi } from '@kit/course-management/api'; import { CmsPageShell } from '~/components/cms-page-shell'; import { StatsCard } from '~/components/stats-card'; import { StatsBarChart, StatsPieChart } from '~/components/stats-charts'; +import { AccountNotFound } from '~/components/account-not-found'; interface PageProps { params: Promise<{ account: string }>; @@ -18,7 +19,7 @@ export default async function CourseStatisticsPage({ params }: PageProps) { const client = getSupabaseServerClient(); const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single(); - if (!acct) return
Konto nicht gefunden
; + if (!acct) return ; const api = createCourseManagementApi(client); const stats = await api.getStatistics(acct.id); diff --git a/apps/web/app/[locale]/home/[account]/documents/_components/generate-document-form.tsx b/apps/web/app/[locale]/home/[account]/documents/_components/generate-document-form.tsx new file mode 100644 index 000000000..79be8df3b --- /dev/null +++ b/apps/web/app/[locale]/home/[account]/documents/_components/generate-document-form.tsx @@ -0,0 +1,242 @@ +'use client'; + +import { useState, useTransition } from 'react'; + +import { FileDown, Loader2, AlertCircle, CheckCircle2 } from 'lucide-react'; + +import { Button } from '@kit/ui/button'; +import { Input } from '@kit/ui/input'; +import { Label } from '@kit/ui/label'; + +import { + generateDocumentAction, + type GenerateDocumentInput, + type GenerateDocumentResult, +} from '../_lib/server/generate-document'; + +interface Props { + accountSlug: string; + initialType: string; +} + +const DOCUMENT_LABELS: Record = { + 'member-card': 'Mitgliedsausweis', + invoice: 'Rechnung', + labels: 'Etiketten', + report: 'Bericht', + letter: 'Brief', + certificate: 'Zertifikat', +}; + +const COMING_SOON_TYPES = new Set(['invoice', 'letter', 'certificate']); + +export function GenerateDocumentForm({ accountSlug, initialType }: Props) { + const [isPending, startTransition] = useTransition(); + const [result, setResult] = useState(null); + const [selectedType, setSelectedType] = useState(initialType); + + const isComingSoon = COMING_SOON_TYPES.has(selectedType); + + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setResult(null); + + const formData = new FormData(e.currentTarget); + const input: GenerateDocumentInput = { + accountSlug, + documentType: formData.get('documentType') as string, + title: formData.get('title') as string, + format: formData.get('format') as 'A4' | 'A5' | 'letter', + orientation: formData.get('orientation') as 'portrait' | 'landscape', + }; + + startTransition(async () => { + const res = await generateDocumentAction(input); + setResult(res); + + if (res.success && res.data && res.mimeType && res.fileName) { + downloadFile(res.data, res.mimeType, res.fileName); + } + }); + } + + return ( +
+ {/* Document Type */} +
+ + +
+ + {/* Coming soon banner */} + {isComingSoon && ( +
+ +
+

Demnächst verfügbar

+

+ Die Generierung von “{DOCUMENT_LABELS[selectedType]}” + befindet sich noch in Entwicklung und wird in Kürze verfügbar sein. +

+
+
+ )} + + {/* Title */} +
+ + +
+ + {/* Format & Orientation */} +
+
+ + +
+
+ + +
+
+ + {/* Hint */} +
+

+ Hinweis:{' '} + {selectedType === 'member-card' + ? 'Es werden Mitgliedsausweise für alle aktiven Mitglieder generiert (4 Karten pro A4-Seite).' + : selectedType === 'labels' + ? 'Es werden Adressetiketten im Avery-L7163-Format für alle aktiven Mitglieder erzeugt.' + : selectedType === 'report' + ? 'Es wird eine Excel-Datei mit allen Mitgliederdaten erstellt.' + : 'Wählen Sie den gewünschten Dokumenttyp, um die Generierung zu starten.'} +

+
+ + {/* Result feedback */} + {result && !result.success && ( +
+ +
+

Fehler bei der Generierung

+

{result.error}

+
+
+ )} + + {result && result.success && ( +
+ +
+

Dokument erfolgreich erstellt!

+

+ Die Datei “{result.fileName}” wurde heruntergeladen. +

+ {result.data && result.mimeType && result.fileName && ( + + )} +
+
+ )} + + {/* Submit button */} +
+ +
+
+ ); +} + +/** + * Trigger a browser download from a base64 string. + * Uses an anchor element with the download attribute set to the full filename. + */ +function downloadFile( + base64Data: string, + mimeType: string, + fileName: string, +) { + const byteCharacters = atob(base64Data); + const byteNumbers = new Array(byteCharacters.length); + for (let i = 0; i < byteCharacters.length; i++) { + byteNumbers[i] = byteCharacters.charCodeAt(i); + } + const byteArray = new Uint8Array(byteNumbers); + const blob = new Blob([byteArray], { type: mimeType }); + + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.style.display = 'none'; + a.href = url; + // Ensure the filename always has the right extension + a.download = fileName; + // Force the filename by also setting it via the Content-Disposition-like attribute + a.setAttribute('download', fileName); + document.body.appendChild(a); + a.click(); + // Small delay before cleanup to ensure download starts + setTimeout(() => { + document.body.removeChild(a); + URL.revokeObjectURL(url); + }, 100); +} diff --git a/apps/web/app/[locale]/home/[account]/documents/_lib/server/generate-document.ts b/apps/web/app/[locale]/home/[account]/documents/_lib/server/generate-document.ts new file mode 100644 index 000000000..094bffb75 --- /dev/null +++ b/apps/web/app/[locale]/home/[account]/documents/_lib/server/generate-document.ts @@ -0,0 +1,385 @@ +'use server'; + +import React from 'react'; + +import { getSupabaseServerClient } from '@kit/supabase/server-client'; +import { createDocumentGeneratorApi } from '@kit/document-generator/api'; + +export type GenerateDocumentInput = { + accountSlug: string; + documentType: string; + title: string; + format: 'A4' | 'A5' | 'letter'; + orientation: 'portrait' | 'landscape'; +}; + +export type GenerateDocumentResult = { + success: boolean; + data?: string; + mimeType?: string; + fileName?: string; + error?: string; +}; + +export async function generateDocumentAction( + input: GenerateDocumentInput, +): Promise { + try { + const client = getSupabaseServerClient(); + + const { data: acct, error: acctError } = await client + .from('accounts') + .select('id, name') + .eq('slug', input.accountSlug) + .single(); + + if (acctError || !acct) { + return { success: false, error: 'Konto nicht gefunden.' }; + } + + switch (input.documentType) { + case 'member-card': + return await generateMemberCards(client, acct.id, acct.name, input); + case 'labels': + return await generateLabels(client, acct.id, input); + case 'report': + return await generateMemberReport(client, acct.id, input); + case 'invoice': + case 'letter': + case 'certificate': + return { + success: false, + error: `"${LABELS[input.documentType] ?? input.documentType}" ist noch in Entwicklung.`, + }; + default: + return { success: false, error: 'Unbekannter Dokumenttyp.' }; + } + } catch (err) { + console.error('Document generation error:', err); + return { + success: false, + error: err instanceof Error ? err.message : 'Unbekannter Fehler.', + }; + } +} + +const LABELS: Record = { + 'member-card': 'Mitgliedsausweis', + invoice: 'Rechnung', + labels: 'Etiketten', + report: 'Bericht', + letter: 'Brief', + certificate: 'Zertifikat', +}; + +function fmtDate(d: string | null): string { + if (!d) return '–'; + try { return new Date(d).toLocaleDateString('de-DE'); } catch { return d; } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Member Card PDF — premium design with color accent bar, structured layout +// ═══════════════════════════════════════════════════════════════════════════ +async function generateMemberCards( + client: ReturnType, + accountId: string, + accountName: string, + input: GenerateDocumentInput, +): Promise { + const { data: members, error } = await client + .from('members') + .select('id, member_number, first_name, last_name, entry_date, status, date_of_birth, street, house_number, postal_code, city, email') + .eq('account_id', accountId) + .eq('status', 'active') + .order('last_name'); + + if (error) return { success: false, error: `DB-Fehler: ${error.message}` }; + if (!members?.length) return { success: false, error: 'Keine aktiven Mitglieder.' }; + + const { Document, Page, View, Text, StyleSheet, renderToBuffer, Svg, Rect, Circle } = + await import('@react-pdf/renderer'); + + // — Brand colors (configurable later via account settings) — + const PRIMARY = '#1e40af'; + const PRIMARY_LIGHT = '#dbeafe'; + const DARK = '#0f172a'; + const GRAY = '#64748b'; + const LIGHT_GRAY = '#f1f5f9'; + + const s = StyleSheet.create({ + page: { padding: 24, flexDirection: 'row', flexWrap: 'wrap', gap: 16, fontFamily: 'Helvetica' }, + + // ── Card shell ── + card: { + width: '47%', + height: '45%', + borderRadius: 10, + overflow: 'hidden', + border: `1pt solid ${PRIMARY_LIGHT}`, + backgroundColor: '#ffffff', + }, + + // ── Top accent bar ── + accentBar: { height: 6, backgroundColor: PRIMARY }, + + // ── Header area ── + header: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingHorizontal: 14, + paddingTop: 10, + paddingBottom: 4, + }, + clubName: { fontSize: 12, fontFamily: 'Helvetica-Bold', color: PRIMARY }, + badge: { + backgroundColor: PRIMARY_LIGHT, + borderRadius: 4, + paddingHorizontal: 6, + paddingVertical: 2, + }, + badgeText: { fontSize: 6, color: PRIMARY, fontFamily: 'Helvetica-Bold', textTransform: 'uppercase' as const, letterSpacing: 0.8 }, + + // ── Main content ── + body: { flexDirection: 'row', paddingHorizontal: 14, paddingTop: 8, gap: 12, flex: 1 }, + + // Photo column + photoCol: { width: 64, alignItems: 'center' }, + photoFrame: { + width: 56, + height: 68, + borderRadius: 6, + backgroundColor: LIGHT_GRAY, + border: `0.5pt solid #e2e8f0`, + justifyContent: 'center', + alignItems: 'center', + }, + photoIcon: { fontSize: 20, color: '#cbd5e1' }, + memberNumber: { + marginTop: 4, + fontSize: 7, + color: PRIMARY, + fontFamily: 'Helvetica-Bold', + textAlign: 'center' as const, + }, + + // Info column + infoCol: { flex: 1, justifyContent: 'center' }, + memberName: { fontSize: 14, fontFamily: 'Helvetica-Bold', color: DARK, marginBottom: 6 }, + + fieldGroup: { flexDirection: 'row', flexWrap: 'wrap', gap: 4 }, + field: { width: '48%', marginBottom: 5 }, + fieldLabel: { fontSize: 6, color: GRAY, textTransform: 'uppercase' as const, letterSpacing: 0.6, marginBottom: 1 }, + fieldValue: { fontSize: 8, color: DARK, fontFamily: 'Helvetica-Bold' }, + + // ── Footer ── + footer: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingHorizontal: 14, + paddingVertical: 6, + backgroundColor: LIGHT_GRAY, + borderTop: `0.5pt solid #e2e8f0`, + }, + footerLeft: { fontSize: 6, color: GRAY }, + footerRight: { fontSize: 6, color: GRAY }, + validDot: { width: 5, height: 5, borderRadius: 2.5, backgroundColor: '#22c55e', marginRight: 3 }, + }); + + const today = new Date().toLocaleDateString('de-DE'); + const year = new Date().getFullYear(); + const cardsPerPage = 4; + const pages: React.ReactElement[] = []; + + for (let i = 0; i < members.length; i += cardsPerPage) { + const batch = members.slice(i, i + cardsPerPage); + + pages.push( + React.createElement( + Page, + { key: `p${i}`, size: input.format === 'letter' ? 'LETTER' : (input.format.toUpperCase() as 'A4'|'A5'), orientation: input.orientation, style: s.page }, + ...batch.map((m) => + React.createElement(View, { key: m.id, style: s.card }, + // Accent bar + React.createElement(View, { style: s.accentBar }), + + // Header + React.createElement(View, { style: s.header }, + React.createElement(Text, { style: s.clubName }, accountName), + React.createElement(View, { style: s.badge }, + React.createElement(Text, { style: s.badgeText }, 'Mitgliedsausweis'), + ), + ), + + // Body: photo + info + React.createElement(View, { style: s.body }, + // Photo column + React.createElement(View, { style: s.photoCol }, + React.createElement(View, { style: s.photoFrame }, + React.createElement(Text, { style: s.photoIcon }, '👤'), + ), + React.createElement(Text, { style: s.memberNumber }, `Nr. ${m.member_number ?? '–'}`), + ), + + // Info column + React.createElement(View, { style: s.infoCol }, + React.createElement(Text, { style: s.memberName }, `${m.first_name} ${m.last_name}`), + React.createElement(View, { style: s.fieldGroup }, + // Entry date + React.createElement(View, { style: s.field }, + React.createElement(Text, { style: s.fieldLabel }, 'Mitglied seit'), + React.createElement(Text, { style: s.fieldValue }, fmtDate(m.entry_date)), + ), + // Date of birth + React.createElement(View, { style: s.field }, + React.createElement(Text, { style: s.fieldLabel }, 'Geb.-Datum'), + React.createElement(Text, { style: s.fieldValue }, fmtDate(m.date_of_birth)), + ), + // Address + React.createElement(View, { style: { ...s.field, width: '100%' } }, + React.createElement(Text, { style: s.fieldLabel }, 'Adresse'), + React.createElement(Text, { style: s.fieldValue }, + [m.street, m.house_number].filter(Boolean).join(' ') || '–', + ), + React.createElement(Text, { style: { ...s.fieldValue, marginTop: 1 } }, + [m.postal_code, m.city].filter(Boolean).join(' ') || '', + ), + ), + ), + ), + ), + + // Footer + React.createElement(View, { style: s.footer }, + React.createElement(View, { style: { flexDirection: 'row', alignItems: 'center' } }, + React.createElement(View, { style: s.validDot }), + React.createElement(Text, { style: s.footerLeft }, `Gültig ${year}/${year + 1}`), + ), + React.createElement(Text, { style: s.footerRight }, `Ausgestellt ${today}`), + ), + ), + ), + ), + ); + } + + const doc = React.createElement(Document, { title: input.title }, ...pages); + const buffer = await renderToBuffer(doc); + + return { + success: true, + data: Buffer.from(buffer).toString('base64'), + mimeType: 'application/pdf', + fileName: `${input.title || 'Mitgliedsausweise'}.pdf`, + }; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Address Labels (HTML — Avery L7163) +// ═══════════════════════════════════════════════════════════════════════════ +async function generateLabels( + client: ReturnType, + accountId: string, + input: GenerateDocumentInput, +): Promise { + const { data: members, error } = await client + .from('members') + .select('first_name, last_name, street, house_number, postal_code, city, salutation, title') + .eq('account_id', accountId) + .eq('status', 'active') + .order('last_name'); + + if (error) return { success: false, error: `DB-Fehler: ${error.message}` }; + if (!members?.length) return { success: false, error: 'Keine aktiven Mitglieder.' }; + + const api = createDocumentGeneratorApi(); + const records = members.map((m) => ({ + line1: [m.salutation, m.title, m.first_name, m.last_name].filter(Boolean).join(' '), + line2: [m.street, m.house_number].filter(Boolean).join(' ') || undefined, + line3: [m.postal_code, m.city].filter(Boolean).join(' ') || undefined, + })); + + const html = api.generateLabelsHtml({ labelFormat: 'avery-l7163', records }); + + return { + success: true, + data: Buffer.from(html, 'utf-8').toString('base64'), + mimeType: 'text/html', + fileName: `${input.title || 'Adressetiketten'}.html`, + }; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Member Report (Excel) +// ═══════════════════════════════════════════════════════════════════════════ +async function generateMemberReport( + client: ReturnType, + accountId: string, + input: GenerateDocumentInput, +): Promise { + const { data: members, error } = await client + .from('members') + .select('member_number, last_name, first_name, email, postal_code, city, status, entry_date') + .eq('account_id', accountId) + .order('last_name'); + + if (error) return { success: false, error: `DB-Fehler: ${error.message}` }; + if (!members?.length) return { success: false, error: 'Keine Mitglieder.' }; + + const ExcelJS = await import('exceljs'); + const wb = new ExcelJS.Workbook(); + wb.creator = 'MyEasyCMS'; + wb.created = new Date(); + + const ws = wb.addWorksheet('Mitglieder'); + ws.columns = [ + { header: 'Nr.', key: 'nr', width: 12 }, + { header: 'Name', key: 'name', width: 20 }, + { header: 'Vorname', key: 'vorname', width: 20 }, + { header: 'E-Mail', key: 'email', width: 28 }, + { header: 'PLZ', key: 'plz', width: 10 }, + { header: 'Ort', key: 'ort', width: 20 }, + { header: 'Status', key: 'status', width: 12 }, + { header: 'Eintritt', key: 'eintritt', width: 14 }, + ]; + + const hdr = ws.getRow(1); + hdr.font = { bold: true, color: { argb: 'FFFFFFFF' } }; + hdr.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF1E40AF' } }; + hdr.alignment = { vertical: 'middle', horizontal: 'center' }; + hdr.height = 24; + + const SL: Record = { active: 'Aktiv', inactive: 'Inaktiv', pending: 'Ausstehend', resigned: 'Ausgetreten', excluded: 'Ausgeschlossen' }; + + for (const m of members) { + ws.addRow({ + nr: m.member_number ?? '', + name: m.last_name, + vorname: m.first_name, + email: m.email ?? '', + plz: m.postal_code ?? '', + ort: m.city ?? '', + status: SL[m.status] ?? m.status, + eintritt: m.entry_date ? new Date(m.entry_date).toLocaleDateString('de-DE') : '', + }); + } + + ws.eachRow((row, n) => { + if (n > 1 && n % 2 === 0) row.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFF1F5F9' } }; + row.border = { bottom: { style: 'thin', color: { argb: 'FFE2E8F0' } } }; + }); + + ws.addRow({}); + const sum = ws.addRow({ nr: `Gesamt: ${members.length} Mitglieder` }); + sum.font = { bold: true }; + + const buf = await wb.xlsx.writeBuffer(); + + return { + success: true, + data: Buffer.from(buf as ArrayBuffer).toString('base64'), + mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + fileName: `${input.title || 'Mitgliederbericht'}.xlsx`, + }; +} diff --git a/apps/web/app/[locale]/home/[account]/documents/generate/page.tsx b/apps/web/app/[locale]/home/[account]/documents/generate/page.tsx index 5c22a2eb1..5452d592d 100644 --- a/apps/web/app/[locale]/home/[account]/documents/generate/page.tsx +++ b/apps/web/app/[locale]/home/[account]/documents/generate/page.tsx @@ -1,6 +1,6 @@ import Link from 'next/link'; -import { ArrowLeft, FileDown } from 'lucide-react'; +import { ArrowLeft } from 'lucide-react'; import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { Button } from '@kit/ui/button'; @@ -12,11 +12,12 @@ import { CardHeader, CardTitle, } from '@kit/ui/card'; -import { Input } from '@kit/ui/input'; -import { Label } from '@kit/ui/label'; import { CmsPageShell } from '~/components/cms-page-shell'; +import { GenerateDocumentForm } from '../_components/generate-document-form'; +import { AccountNotFound } from '~/components/account-not-found'; + interface PageProps { params: Promise<{ account: string }>; searchParams: Promise<{ type?: string }>; @@ -45,7 +46,7 @@ export default async function GenerateDocumentPage({ .eq('slug', account) .single(); - if (!acct) return
Konto nicht gefunden
; + if (!acct) return ; const selectedType = type ?? 'member-card'; const selectedLabel = DOCUMENT_LABELS[selectedType] ?? 'Dokument'; @@ -73,82 +74,16 @@ export default async function GenerateDocumentPage({ -
- {/* Document Type */} -
- - -
- - {/* Title */} -
- - -
- - {/* Format */} -
-
- - -
-
- - -
-
- - {/* Info */} -
-

- Hinweis: Die Dokumentgenerierung verwendet - Ihre gespeicherten Vorlagen. Stellen Sie sicher, dass eine - passende Vorlage für den gewählten Dokumenttyp existiert. -

-
-
+
- + -
diff --git a/apps/web/app/[locale]/home/[account]/documents/page.tsx b/apps/web/app/[locale]/home/[account]/documents/page.tsx index f2944ab01..b480df5c1 100644 --- a/apps/web/app/[locale]/home/[account]/documents/page.tsx +++ b/apps/web/app/[locale]/home/[account]/documents/page.tsx @@ -14,6 +14,7 @@ import { Button } from '@kit/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card'; import { CmsPageShell } from '~/components/cms-page-shell'; +import { AccountNotFound } from '~/components/account-not-found'; interface PageProps { params: Promise<{ account: string }>; @@ -80,25 +81,16 @@ export default async function DocumentsPage({ params }: PageProps) { .eq('slug', account) .single(); - if (!acct) return
Konto nicht gefunden
; + if (!acct) return ; return ( - +
- {/* Header */} -
-
-

Dokumente

-

- Dokumente erstellen und verwalten -

-
- -
- - - -
+ {/* Actions */} +
+ + +
{/* Document Type Grid */} diff --git a/apps/web/app/[locale]/home/[account]/documents/templates/page.tsx b/apps/web/app/[locale]/home/[account]/documents/templates/page.tsx index 8a0314185..9e7b2ac8f 100644 --- a/apps/web/app/[locale]/home/[account]/documents/templates/page.tsx +++ b/apps/web/app/[locale]/home/[account]/documents/templates/page.tsx @@ -8,6 +8,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card'; 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 }>; @@ -23,7 +24,7 @@ export default async function DocumentTemplatesPage({ params }: PageProps) { .eq('slug', account) .single(); - if (!acct) return
Konto nicht gefunden
; + if (!acct) return ; // Document templates are stored locally for now — placeholder for future DB integration const templates: Array<{ diff --git a/apps/web/app/[locale]/home/[account]/events/holiday-passes/page.tsx b/apps/web/app/[locale]/home/[account]/events/holiday-passes/page.tsx index e1daa5f0a..f6387232f 100644 --- a/apps/web/app/[locale]/home/[account]/events/holiday-passes/page.tsx +++ b/apps/web/app/[locale]/home/[account]/events/holiday-passes/page.tsx @@ -1,5 +1,6 @@ import { Ticket, Plus } from 'lucide-react'; +import { getTranslations } from 'next-intl/server'; import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { Button } from '@kit/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card'; @@ -8,6 +9,7 @@ import { createEventManagementApi } from '@kit/event-management/api'; 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 }>; @@ -16,6 +18,7 @@ interface PageProps { export default async function HolidayPassesPage({ params }: PageProps) { const { account } = await params; const client = getSupabaseServerClient(); + const t = await getTranslations('cms.events'); const { data: acct } = await client .from('accounts') @@ -23,47 +26,47 @@ export default async function HolidayPassesPage({ params }: PageProps) { .eq('slug', account) .single(); - if (!acct) return
Konto nicht gefunden
; + if (!acct) return ; const api = createEventManagementApi(client); const passes = await api.listHolidayPasses(acct.id); return ( - +
-

Ferienpässe

-

Ferienpässe und Ferienprogramme verwalten

+

{t('holidayPasses')}

+

{t('holidayPassesDescription')}

{passes.length === 0 ? ( } - title="Keine Ferienpässe vorhanden" - description="Erstellen Sie Ihren ersten Ferienpass." - actionLabel="Neuer Ferienpass" + title={t('noHolidayPasses')} + description={t('noHolidayPassesDescription')} + actionLabel={t('newHolidayPass')} /> ) : ( - Alle Ferienpässe ({passes.length}) + {t('allHolidayPasses')} ({passes.length})
- - - - - + + + + + diff --git a/apps/web/app/[locale]/home/[account]/events/new/page.tsx b/apps/web/app/[locale]/home/[account]/events/new/page.tsx index 88ef8f21e..59c2395b8 100644 --- a/apps/web/app/[locale]/home/[account]/events/new/page.tsx +++ b/apps/web/app/[locale]/home/[account]/events/new/page.tsx @@ -1,17 +1,20 @@ +import { getTranslations } from 'next-intl/server'; import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { CreateEventForm } from '@kit/event-management/components'; import { CmsPageShell } from '~/components/cms-page-shell'; +import { AccountNotFound } from '~/components/account-not-found'; interface Props { params: Promise<{ account: string }> } export default async function NewEventPage({ params }: Props) { const { account } = await params; const client = getSupabaseServerClient(); + const t = await getTranslations('cms.events'); const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single(); - if (!acct) return
Konto nicht gefunden
; + if (!acct) return ; return ( - + ); diff --git a/apps/web/app/[locale]/home/[account]/events/page.tsx b/apps/web/app/[locale]/home/[account]/events/page.tsx index 6c1d8a26a..707d3bc95 100644 --- a/apps/web/app/[locale]/home/[account]/events/page.tsx +++ b/apps/web/app/[locale]/home/[account]/events/page.tsx @@ -1,7 +1,8 @@ import Link from 'next/link'; -import { CalendarDays, MapPin, Plus, Users } from 'lucide-react'; +import { CalendarDays, ChevronLeft, ChevronRight, MapPin, Plus, Users } from 'lucide-react'; +import { getTranslations } from 'next-intl/server'; import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { Badge } from '@kit/ui/badge'; import { Button } from '@kit/ui/button'; @@ -12,35 +13,19 @@ import { createEventManagementApi } from '@kit/event-management/api'; 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'; +import { EVENT_STATUS_VARIANT, EVENT_STATUS_LABEL } from '~/lib/status-badges'; interface PageProps { params: Promise<{ account: string }>; + searchParams: Promise>; } -const STATUS_BADGE_VARIANT: Record< - string, - 'secondary' | 'default' | 'info' | 'outline' | 'destructive' -> = { - draft: 'secondary', - published: 'default', - registration_open: 'info', - registration_closed: 'outline', - cancelled: 'destructive', - completed: 'outline', -}; - -const STATUS_LABEL: Record = { - draft: 'Entwurf', - published: 'Veröffentlicht', - registration_open: 'Anmeldung offen', - registration_closed: 'Anmeldung geschlossen', - cancelled: 'Abgesagt', - completed: 'Abgeschlossen', -}; - -export default async function EventsPage({ params }: PageProps) { +export default async function EventsPage({ params, searchParams }: PageProps) { const { account } = await params; + const search = await searchParams; const client = getSupabaseServerClient(); + const t = await getTranslations('cms.events'); const { data: acct } = await client .from('accounts') @@ -48,27 +33,45 @@ export default async function EventsPage({ params }: PageProps) { .eq('slug', account) .single(); - if (!acct) return
Konto nicht gefunden
; + if (!acct) return ; + const page = Number(search.page) || 1; const api = createEventManagementApi(client); - const events = await api.listEvents(acct.id, { page: 1 }); + const events = await api.listEvents(acct.id, { page }); + + // Fetch registration counts for all events on this page + const eventIds = events.data.map((e: Record) => String(e.id)); + const registrationCounts = await api.getRegistrationCounts(eventIds); + + // Pre-compute stats before rendering + const uniqueLocationCount = new Set( + events.data + .map((e: Record) => e.location) + .filter(Boolean), + ).size; + + const totalCapacity = events.data.reduce( + (sum: number, e: Record) => + sum + (Number(e.capacity) || 0), + 0, + ); return ( - +
{/* Header */}
-

Veranstaltungen

+

{t('title')}

- Veranstaltungen und Ferienprogramme + {t('description')}

@@ -76,28 +79,18 @@ export default async function EventsPage({ params }: PageProps) { {/* Stats */}
} /> ) => e.location) - .filter(Boolean), - ).size - } + title={t('locations')} + value={uniqueLocationCount} icon={} /> ) => - sum + (Number(e.capacity) || 0), - 0, - )} + title={t('totalCapacity')} + value={totalCapacity} icon={} />
@@ -106,71 +99,105 @@ export default async function EventsPage({ params }: PageProps) { {events.data.length === 0 ? ( } - title="Keine Veranstaltungen vorhanden" - description="Erstellen Sie Ihre erste Veranstaltung, um loszulegen." - actionLabel="Neue Veranstaltung" + title={t('noEvents')} + description={t('noEventsDescription')} + actionLabel={t('newEvent')} actionHref={`/home/${account}/events/new`} /> ) : ( - Alle Veranstaltungen ({events.total}) + {t('allEvents')} ({events.total})
NameJahrPreisGültig vonGültig bis{t('name')}{t('year')}{t('price')}{t('validFrom')}{t('validUntil')}
- - - - - - + + + + + + - {events.data.map((event: Record) => ( - - - - - - - - - ))} + {events.data.map((event: Record) => { + const eventId = String(event.id); + const regCount = registrationCounts[eventId] ?? 0; + + return ( + + + + + + + + + ); + })}
NameDatumOrtKapazitätStatusAnmeldungen{t('name')}{t('eventDate')}{t('eventLocation')}{t('capacity')}{t('status')}{t('registrations')}
- - {String(event.name)} - - - {event.event_date - ? new Date(String(event.event_date)).toLocaleDateString('de-DE') - : '—'} - - {String(event.location ?? '—')} - - {event.capacity != null - ? String(event.capacity) - : '—'} - - - {STATUS_LABEL[String(event.status)] ?? String(event.status)} - -
+ + {String(event.name)} + + + {event.event_date + ? new Date(String(event.event_date)).toLocaleDateString('de-DE') + : '—'} + + {String(event.location ?? '—')} + + {event.capacity != null + ? String(event.capacity) + : '—'} + + + {EVENT_STATUS_LABEL[String(event.status)] ?? String(event.status)} + + + {regCount} +
+ + {/* Pagination */} + {events.totalPages > 1 && ( +
+ + {t('paginationPage', { page: events.page, totalPages: events.totalPages })} + +
+ {events.page > 1 && ( + + + + )} + {events.page < events.totalPages && ( + + + + )} +
+
+ )}
)} diff --git a/apps/web/app/[locale]/home/[account]/events/registrations/page.tsx b/apps/web/app/[locale]/home/[account]/events/registrations/page.tsx index 5d3e816aa..e4ca2280f 100644 --- a/apps/web/app/[locale]/home/[account]/events/registrations/page.tsx +++ b/apps/web/app/[locale]/home/[account]/events/registrations/page.tsx @@ -2,9 +2,9 @@ import Link from 'next/link'; import { CalendarDays, ClipboardList, Users } from 'lucide-react'; +import { getTranslations } from 'next-intl/server'; 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 { createEventManagementApi } from '@kit/event-management/api'; @@ -12,6 +12,8 @@ import { createEventManagementApi } from '@kit/event-management/api'; 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'; +import { EVENT_STATUS_VARIANT, EVENT_STATUS_LABEL } from '~/lib/status-badges'; interface PageProps { params: Promise<{ account: string }>; @@ -20,6 +22,7 @@ interface PageProps { export default async function EventRegistrationsPage({ params }: PageProps) { const { account } = await params; const client = getSupabaseServerClient(); + const t = await getTranslations('cms.events'); const { data: acct } = await client .from('accounts') @@ -27,7 +30,7 @@ export default async function EventRegistrationsPage({ params }: PageProps) { .eq('slug', account) .single(); - if (!acct) return
Konto nicht gefunden
; + if (!acct) return ; const api = createEventManagementApi(client); const events = await api.listEvents(acct.id, { page: 1 }); @@ -56,30 +59,30 @@ export default async function EventRegistrationsPage({ params }: PageProps) { ); return ( - +
{/* Header */}
-

Anmeldungen

+

{t('registrations')}

- Anmeldungen aller Veranstaltungen im Überblick + {t('registrationsOverview')}

{/* Stats */}
} /> } /> } /> @@ -89,16 +92,16 @@ export default async function EventRegistrationsPage({ params }: PageProps) { {eventsWithRegistrations.length === 0 ? ( } - title="Keine Veranstaltungen vorhanden" - description="Erstellen Sie eine Veranstaltung, um Anmeldungen zu erhalten." - actionLabel="Neue Veranstaltung" + title={t('noEvents')} + description={t('noEventsForRegistrations')} + actionLabel={t('newEvent')} actionHref={`/home/${account}/events/new`} /> ) : ( - Übersicht nach Veranstaltung ({eventsWithRegistrations.length}) + {t('overviewByEvent')} ({eventsWithRegistrations.length}) @@ -107,15 +110,15 @@ export default async function EventRegistrationsPage({ params }: PageProps) { - Veranstaltung + {t('event')} - Datum - Status - Kapazität + {t('eventDate')} + {t('status')} + {t('capacity')} - Anmeldungen + {t('registrations')} - Auslastung + {t('utilization')} @@ -148,7 +151,13 @@ export default async function EventRegistrationsPage({ params }: PageProps) { : '—'} - {event.status} + + {EVENT_STATUS_LABEL[event.status] ?? event.status} + {event.capacity ?? '—'} diff --git a/apps/web/app/[locale]/home/[account]/finance/invoices/[id]/page.tsx b/apps/web/app/[locale]/home/[account]/finance/invoices/[id]/page.tsx index 1ebbc532d..1f9f5612c 100644 --- a/apps/web/app/[locale]/home/[account]/finance/invoices/[id]/page.tsx +++ b/apps/web/app/[locale]/home/[account]/finance/invoices/[id]/page.tsx @@ -10,6 +10,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card'; import { createFinanceApi } from '@kit/finance/api'; import { CmsPageShell } from '~/components/cms-page-shell'; +import { AccountNotFound } from '~/components/account-not-found'; interface PageProps { params: Promise<{ account: string; id: string }>; @@ -49,7 +50,7 @@ export default async function InvoiceDetailPage({ params }: PageProps) { .eq('slug', account) .single(); - if (!acct) return
Konto nicht gefunden
; + if (!acct) return ; const api = createFinanceApi(client); const invoice = await api.getInvoiceWithItems(id); diff --git a/apps/web/app/[locale]/home/[account]/finance/invoices/new/page.tsx b/apps/web/app/[locale]/home/[account]/finance/invoices/new/page.tsx index 4456e8d23..dd5f7aebf 100644 --- a/apps/web/app/[locale]/home/[account]/finance/invoices/new/page.tsx +++ b/apps/web/app/[locale]/home/[account]/finance/invoices/new/page.tsx @@ -1,6 +1,7 @@ import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { CreateInvoiceForm } from '@kit/finance/components'; import { CmsPageShell } from '~/components/cms-page-shell'; +import { AccountNotFound } from '~/components/account-not-found'; interface Props { params: Promise<{ account: string }> } @@ -8,7 +9,7 @@ export default async function NewInvoicePage({ params }: Props) { const { account } = await params; const client = getSupabaseServerClient(); const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single(); - if (!acct) return
Konto nicht gefunden
; + if (!acct) return ; return ( diff --git a/apps/web/app/[locale]/home/[account]/finance/invoices/page.tsx b/apps/web/app/[locale]/home/[account]/finance/invoices/page.tsx index ed12b797f..60f81aeb6 100644 --- a/apps/web/app/[locale]/home/[account]/finance/invoices/page.tsx +++ b/apps/web/app/[locale]/home/[account]/finance/invoices/page.tsx @@ -11,30 +11,16 @@ import { createFinanceApi } from '@kit/finance/api'; import { CmsPageShell } from '~/components/cms-page-shell'; import { EmptyState } from '~/components/empty-state'; +import { AccountNotFound } from '~/components/account-not-found'; +import { + INVOICE_STATUS_VARIANT, + INVOICE_STATUS_LABEL, +} from '~/lib/status-badges'; interface PageProps { params: Promise<{ account: string }>; } -const STATUS_VARIANT: Record< - string, - 'secondary' | 'default' | 'info' | 'outline' | 'destructive' -> = { - draft: 'secondary', - sent: 'default', - paid: 'info', - overdue: 'destructive', - cancelled: 'destructive', -}; - -const STATUS_LABEL: Record = { - draft: 'Entwurf', - sent: 'Versendet', - paid: 'Bezahlt', - overdue: 'Überfällig', - cancelled: 'Storniert', -}; - const formatCurrency = (amount: unknown) => new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format( Number(amount), @@ -50,7 +36,7 @@ export default async function InvoicesPage({ params }: PageProps) { .eq('slug', account) .single(); - if (!acct) return
Konto nicht gefunden
; + if (!acct) return ; const api = createFinanceApi(client); const invoices = await api.listInvoices(acct.id); @@ -141,10 +127,10 @@ export default async function InvoicesPage({ params }: PageProps) { - {STATUS_LABEL[status] ?? status} + {INVOICE_STATUS_LABEL[status] ?? status} diff --git a/apps/web/app/[locale]/home/[account]/finance/page.tsx b/apps/web/app/[locale]/home/[account]/finance/page.tsx index 550aff88c..8f3a708eb 100644 --- a/apps/web/app/[locale]/home/[account]/finance/page.tsx +++ b/apps/web/app/[locale]/home/[account]/finance/page.tsx @@ -1,6 +1,6 @@ import Link from 'next/link'; -import { Landmark, FileText, Euro, ArrowRight } from 'lucide-react'; +import { Landmark, FileText, Euro, ArrowRight, Plus } from 'lucide-react'; import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { Badge } from '@kit/ui/badge'; @@ -12,49 +12,18 @@ import { createFinanceApi } from '@kit/finance/api'; 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'; +import { + BATCH_STATUS_VARIANT, + BATCH_STATUS_LABEL, + INVOICE_STATUS_VARIANT, + INVOICE_STATUS_LABEL, +} from '~/lib/status-badges'; interface PageProps { params: Promise<{ account: string }>; } -const BATCH_STATUS_VARIANT: Record< - string, - 'secondary' | 'default' | 'info' | 'outline' | 'destructive' -> = { - draft: 'secondary', - ready: 'default', - submitted: 'info', - completed: 'outline', - failed: 'destructive', -}; - -const BATCH_STATUS_LABEL: Record = { - draft: 'Entwurf', - ready: 'Bereit', - submitted: 'Eingereicht', - completed: 'Abgeschlossen', - failed: 'Fehlgeschlagen', -}; - -const INVOICE_STATUS_VARIANT: Record< - string, - 'secondary' | 'default' | 'info' | 'outline' | 'destructive' -> = { - draft: 'secondary', - sent: 'default', - paid: 'info', - overdue: 'destructive', - cancelled: 'destructive', -}; - -const INVOICE_STATUS_LABEL: Record = { - draft: 'Entwurf', - sent: 'Versendet', - paid: 'Bezahlt', - overdue: 'Überfällig', - cancelled: 'Storniert', -}; - export default async function FinancePage({ params }: PageProps) { const { account } = await params; const client = getSupabaseServerClient(); @@ -65,7 +34,7 @@ export default async function FinancePage({ params }: PageProps) { .eq('slug', account) .single(); - if (!acct) return
Konto nicht gefunden
; + if (!acct) return ; const api = createFinanceApi(client); @@ -89,11 +58,27 @@ export default async function FinancePage({ params }: PageProps) {
{/* Header */} -
-

Finanzen

-

- SEPA-Einzüge und Rechnungen -

+
+
+

Finanzen

+

+ SEPA-Einzüge und Rechnungen +

+
+
+ + + + + + +
{/* Stats */} @@ -147,7 +132,7 @@ export default async function FinancePage({ params }: PageProps) { - {batches.slice(0, 5).map((batch: Record) => ( + {batches.map((batch: Record) => ( - {invoices.slice(0, 5).map((invoice: Record) => ( + {invoices.map((invoice: Record) => ( ; @@ -31,7 +32,7 @@ export default async function PaymentsPage({ params }: PageProps) { .eq('slug', account) .single(); - if (!acct) return
Konto nicht gefunden
; + if (!acct) return ; const api = createFinanceApi(client); diff --git a/apps/web/app/[locale]/home/[account]/finance/sepa/[batchId]/page.tsx b/apps/web/app/[locale]/home/[account]/finance/sepa/[batchId]/page.tsx index 77b0381dd..d93f3699d 100644 --- a/apps/web/app/[locale]/home/[account]/finance/sepa/[batchId]/page.tsx +++ b/apps/web/app/[locale]/home/[account]/finance/sepa/[batchId]/page.tsx @@ -10,6 +10,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card'; import { createFinanceApi } from '@kit/finance/api'; import { CmsPageShell } from '~/components/cms-page-shell'; +import { AccountNotFound } from '~/components/account-not-found'; interface PageProps { params: Promise<{ account: string; batchId: string }>; @@ -64,7 +65,7 @@ export default async function SepaBatchDetailPage({ params }: PageProps) { .eq('slug', account) .single(); - if (!acct) return
Konto nicht gefunden
; + if (!acct) return ; const api = createFinanceApi(client); diff --git a/apps/web/app/[locale]/home/[account]/finance/sepa/new/page.tsx b/apps/web/app/[locale]/home/[account]/finance/sepa/new/page.tsx index 5dd9a318d..05675aee9 100644 --- a/apps/web/app/[locale]/home/[account]/finance/sepa/new/page.tsx +++ b/apps/web/app/[locale]/home/[account]/finance/sepa/new/page.tsx @@ -2,6 +2,7 @@ import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { CreateSepaBatchForm } from '@kit/finance/components'; import { CmsPageShell } from '~/components/cms-page-shell'; +import { AccountNotFound } from '~/components/account-not-found'; interface Props { params: Promise<{ account: string }>; @@ -17,7 +18,7 @@ export default async function NewSepaBatchPage({ params }: Props) { .eq('slug', account) .single(); - if (!acct) return
Konto nicht gefunden
; + if (!acct) return ; return ( diff --git a/apps/web/app/[locale]/home/[account]/finance/sepa/page.tsx b/apps/web/app/[locale]/home/[account]/finance/sepa/page.tsx index e2e07fde5..e68d597ca 100644 --- a/apps/web/app/[locale]/home/[account]/finance/sepa/page.tsx +++ b/apps/web/app/[locale]/home/[account]/finance/sepa/page.tsx @@ -11,30 +11,16 @@ import { createFinanceApi } from '@kit/finance/api'; import { CmsPageShell } from '~/components/cms-page-shell'; import { EmptyState } from '~/components/empty-state'; +import { AccountNotFound } from '~/components/account-not-found'; +import { + BATCH_STATUS_VARIANT, + BATCH_STATUS_LABEL, +} from '~/lib/status-badges'; interface PageProps { params: Promise<{ account: string }>; } -const STATUS_VARIANT: Record< - string, - 'secondary' | 'default' | 'info' | 'outline' | 'destructive' -> = { - draft: 'secondary', - ready: 'default', - submitted: 'info', - completed: 'outline', - failed: 'destructive', -}; - -const STATUS_LABEL: Record = { - draft: 'Entwurf', - ready: 'Bereit', - submitted: 'Eingereicht', - completed: 'Abgeschlossen', - failed: 'Fehlgeschlagen', -}; - const formatCurrency = (amount: unknown) => new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format( Number(amount), @@ -50,7 +36,7 @@ export default async function SepaPage({ params }: PageProps) { .eq('slug', account) .single(); - if (!acct) return
Konto nicht gefunden
; + if (!acct) return ; const api = createFinanceApi(client); const batches = await api.listBatches(acct.id); @@ -115,10 +101,10 @@ export default async function SepaPage({ params }: PageProps) { - {STATUS_LABEL[String(batch.status)] ?? + {BATCH_STATUS_LABEL[String(batch.status)] ?? String(batch.status)} diff --git a/apps/web/app/[locale]/home/[account]/fischerei/catch-books/page.tsx b/apps/web/app/[locale]/home/[account]/fischerei/catch-books/page.tsx new file mode 100644 index 000000000..fe1b9ebec --- /dev/null +++ b/apps/web/app/[locale]/home/[account]/fischerei/catch-books/page.tsx @@ -0,0 +1,47 @@ +import { getSupabaseServerClient } from '@kit/supabase/server-client'; +import { createFischereiApi } from '@kit/fischerei/api'; +import { FischereiTabNavigation, CatchBooksDataTable } from '@kit/fischerei/components'; + +import { CmsPageShell } from '~/components/cms-page-shell'; +import { AccountNotFound } from '~/components/account-not-found'; + +interface Props { + params: Promise<{ account: string }>; + searchParams: Promise>; +} + +export default async function CatchBooksPage({ params, searchParams }: Props) { + const { account } = await params; + const search = await searchParams; + const client = getSupabaseServerClient(); + + const { data: acct } = await client + .from('accounts') + .select('id') + .eq('slug', account) + .single(); + + if (!acct) return ; + + const api = createFischereiApi(client); + const page = Number(search.page) || 1; + const result = await api.listCatchBooks(acct.id, { + year: search.year ? Number(search.year) : undefined, + status: search.status as string, + page, + pageSize: 25, + }); + + return ( + + + + + ); +} diff --git a/apps/web/app/[locale]/home/[account]/fischerei/competitions/page.tsx b/apps/web/app/[locale]/home/[account]/fischerei/competitions/page.tsx new file mode 100644 index 000000000..5d093076a --- /dev/null +++ b/apps/web/app/[locale]/home/[account]/fischerei/competitions/page.tsx @@ -0,0 +1,45 @@ +import { getSupabaseServerClient } from '@kit/supabase/server-client'; +import { createFischereiApi } from '@kit/fischerei/api'; +import { FischereiTabNavigation, CompetitionsDataTable } from '@kit/fischerei/components'; + +import { CmsPageShell } from '~/components/cms-page-shell'; +import { AccountNotFound } from '~/components/account-not-found'; + +interface Props { + params: Promise<{ account: string }>; + searchParams: Promise>; +} + +export default async function CompetitionsPage({ params, searchParams }: Props) { + const { account } = await params; + const search = await searchParams; + const client = getSupabaseServerClient(); + + const { data: acct } = await client + .from('accounts') + .select('id') + .eq('slug', account) + .single(); + + if (!acct) return ; + + const api = createFischereiApi(client); + const page = Number(search.page) || 1; + const result = await api.listCompetitions(acct.id, { + page, + pageSize: 25, + }); + + return ( + + + + + ); +} diff --git a/apps/web/app/[locale]/home/[account]/fischerei/layout.tsx b/apps/web/app/[locale]/home/[account]/fischerei/layout.tsx new file mode 100644 index 000000000..463e90960 --- /dev/null +++ b/apps/web/app/[locale]/home/[account]/fischerei/layout.tsx @@ -0,0 +1,5 @@ +import { ReactNode } from 'react'; + +export default function FischereiLayout({ children }: { children: ReactNode }) { + return <>{children}; +} diff --git a/apps/web/app/[locale]/home/[account]/fischerei/leases/page.tsx b/apps/web/app/[locale]/home/[account]/fischerei/leases/page.tsx new file mode 100644 index 000000000..b3fb6ca6b --- /dev/null +++ b/apps/web/app/[locale]/home/[account]/fischerei/leases/page.tsx @@ -0,0 +1,119 @@ +import { getSupabaseServerClient } from '@kit/supabase/server-client'; +import { createFischereiApi } from '@kit/fischerei/api'; +import { FischereiTabNavigation } from '@kit/fischerei/components'; + +import { CmsPageShell } from '~/components/cms-page-shell'; +import { Badge } from '@kit/ui/badge'; +import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card'; + +import { LEASE_PAYMENT_LABELS } from '@kit/fischerei/lib/fischerei-constants'; +import { AccountNotFound } from '~/components/account-not-found'; + +interface Props { + params: Promise<{ account: string }>; + searchParams: Promise>; +} + +export default async function LeasesPage({ params, searchParams }: Props) { + const { account } = await params; + const search = await searchParams; + const client = getSupabaseServerClient(); + + const { data: acct } = await client + .from('accounts') + .select('id') + .eq('slug', account) + .single(); + + if (!acct) return ; + + const api = createFischereiApi(client); + const page = Number(search.page) || 1; + const result = await api.listLeases(acct.id, { + page, + pageSize: 25, + }); + + return ( + + +
+
+

Pachten

+

+ Gewässerpachtverträge verwalten +

+
+ + + Pachten ({result.total}) + + + {result.data.length === 0 ? ( +
+

+ Keine Pachten vorhanden +

+

+ Erstellen Sie Ihren ersten Pachtvertrag. +

+
+ ) : ( +
+ + + + + + + + + + + + + {result.data.map((lease: Record) => { + const waters = lease.waters as Record | null; + const paymentMethod = String(lease.payment_method ?? 'ueberweisung'); + + return ( + + + + + + + + + ); + })} + +
VerpächterGewässerBeginnEndeJahresbetrag (€)Zahlungsart
+ {String(lease.lessor_name)} + + {waters ? String(waters.name) : '—'} + + {lease.start_date + ? new Date(String(lease.start_date)).toLocaleDateString('de-DE') + : '—'} + + {lease.end_date + ? new Date(String(lease.end_date)).toLocaleDateString('de-DE') + : 'unbefristet'} + + {lease.initial_amount != null + ? `${Number(lease.initial_amount).toLocaleString('de-DE', { minimumFractionDigits: 2 })} €` + : '—'} + + + {LEASE_PAYMENT_LABELS[paymentMethod] ?? paymentMethod} + +
+
+ )} +
+
+
+
+ ); +} diff --git a/apps/web/app/[locale]/home/[account]/fischerei/page.tsx b/apps/web/app/[locale]/home/[account]/fischerei/page.tsx new file mode 100644 index 000000000..58cf30073 --- /dev/null +++ b/apps/web/app/[locale]/home/[account]/fischerei/page.tsx @@ -0,0 +1,33 @@ +import { getSupabaseServerClient } from '@kit/supabase/server-client'; +import { createFischereiApi } from '@kit/fischerei/api'; +import { FischereiTabNavigation, FischereiDashboard } from '@kit/fischerei/components'; + +import { CmsPageShell } from '~/components/cms-page-shell'; +import { AccountNotFound } from '~/components/account-not-found'; + +interface PageProps { + params: Promise<{ account: string }>; +} + +export default async function FischereiPage({ params }: PageProps) { + const { account } = await params; + const client = getSupabaseServerClient(); + + const { data: acct } = await client + .from('accounts') + .select('id') + .eq('slug', account) + .single(); + + if (!acct) return ; + + const api = createFischereiApi(client); + const stats = await api.getDashboardStats(acct.id); + + return ( + + + + + ); +} diff --git a/apps/web/app/[locale]/home/[account]/fischerei/permits/page.tsx b/apps/web/app/[locale]/home/[account]/fischerei/permits/page.tsx new file mode 100644 index 000000000..64e0fc926 --- /dev/null +++ b/apps/web/app/[locale]/home/[account]/fischerei/permits/page.tsx @@ -0,0 +1,97 @@ +import { getSupabaseServerClient } from '@kit/supabase/server-client'; +import { createFischereiApi } from '@kit/fischerei/api'; +import { FischereiTabNavigation } from '@kit/fischerei/components'; + +import { CmsPageShell } from '~/components/cms-page-shell'; +import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card'; +import { AccountNotFound } from '~/components/account-not-found'; + +interface Props { + params: Promise<{ account: string }>; +} + +export default async function PermitsPage({ params }: Props) { + const { account } = await params; + const client = getSupabaseServerClient(); + + const { data: acct } = await client + .from('accounts') + .select('id') + .eq('slug', account) + .single(); + + if (!acct) return ; + + const api = createFischereiApi(client); + const permits = await api.listPermits(acct.id); + + return ( + + +
+
+

Erlaubnisscheine

+

+ Erlaubnisscheine und Gewässerkarten verwalten +

+
+ + + Erlaubnisscheine ({permits.length}) + + + {permits.length === 0 ? ( +
+

+ Keine Erlaubnisscheine vorhanden +

+

+ Erstellen Sie Ihren ersten Erlaubnisschein. +

+
+ ) : ( +
+ + + + + + + + + + + + {permits.map((permit: Record) => { + const waters = permit.waters as Record | null; + + return ( + + + + + + + + ); + })} + +
BezeichnungKurzcodeHauptgewässerGesamtmengeZum Verkauf
{String(permit.name)} + {String(permit.short_code ?? '—')} + + {waters ? String(waters.name) : '—'} + + {permit.total_quantity != null + ? String(permit.total_quantity) + : '—'} + + {permit.is_for_sale ? '✓' : '—'} +
+
+ )} +
+
+
+
+ ); +} diff --git a/apps/web/app/[locale]/home/[account]/fischerei/species/new/page.tsx b/apps/web/app/[locale]/home/[account]/fischerei/species/new/page.tsx new file mode 100644 index 000000000..c6bd36fd0 --- /dev/null +++ b/apps/web/app/[locale]/home/[account]/fischerei/species/new/page.tsx @@ -0,0 +1,29 @@ +import { getSupabaseServerClient } from '@kit/supabase/server-client'; +import { FischereiTabNavigation, CreateSpeciesForm } from '@kit/fischerei/components'; + +import { CmsPageShell } from '~/components/cms-page-shell'; +import { AccountNotFound } from '~/components/account-not-found'; + +interface Props { + params: Promise<{ account: string }>; +} + +export default async function NewSpeciesPage({ params }: Props) { + const { account } = await params; + const client = getSupabaseServerClient(); + + const { data: acct } = await client + .from('accounts') + .select('id') + .eq('slug', account) + .single(); + + if (!acct) return ; + + return ( + + + + + ); +} diff --git a/apps/web/app/[locale]/home/[account]/fischerei/species/page.tsx b/apps/web/app/[locale]/home/[account]/fischerei/species/page.tsx new file mode 100644 index 000000000..9c0b58de1 --- /dev/null +++ b/apps/web/app/[locale]/home/[account]/fischerei/species/page.tsx @@ -0,0 +1,46 @@ +import { getSupabaseServerClient } from '@kit/supabase/server-client'; +import { createFischereiApi } from '@kit/fischerei/api'; +import { FischereiTabNavigation, SpeciesDataTable } from '@kit/fischerei/components'; + +import { CmsPageShell } from '~/components/cms-page-shell'; +import { AccountNotFound } from '~/components/account-not-found'; + +interface Props { + params: Promise<{ account: string }>; + searchParams: Promise>; +} + +export default async function SpeciesPage({ params, searchParams }: Props) { + const { account } = await params; + const search = await searchParams; + const client = getSupabaseServerClient(); + + const { data: acct } = await client + .from('accounts') + .select('id') + .eq('slug', account) + .single(); + + if (!acct) return ; + + const api = createFischereiApi(client); + const page = Number(search.page) || 1; + const result = await api.listSpecies(acct.id, { + search: search.q as string, + page, + pageSize: 50, + }); + + return ( + + + + + ); +} diff --git a/apps/web/app/[locale]/home/[account]/fischerei/statistics/page.tsx b/apps/web/app/[locale]/home/[account]/fischerei/statistics/page.tsx new file mode 100644 index 000000000..001ec65fd --- /dev/null +++ b/apps/web/app/[locale]/home/[account]/fischerei/statistics/page.tsx @@ -0,0 +1,50 @@ +import { getSupabaseServerClient } from '@kit/supabase/server-client'; +import { FischereiTabNavigation } from '@kit/fischerei/components'; + +import { CmsPageShell } from '~/components/cms-page-shell'; +import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card'; +import { AccountNotFound } from '~/components/account-not-found'; + +interface Props { + params: Promise<{ account: string }>; +} + +export default async function StatisticsPage({ params }: Props) { + const { account } = await params; + const client = getSupabaseServerClient(); + + const { data: acct } = await client + .from('accounts') + .select('id') + .eq('slug', account) + .single(); + + if (!acct) return ; + + return ( + + +
+
+

Statistiken

+

+ Fangstatistiken und Auswertungen +

+
+ + + Fangstatistiken + + +
+

Noch keine Daten vorhanden

+

+ Sobald Fangbücher eingereicht und geprüft werden, erscheinen hier Statistiken und Auswertungen. +

+
+
+
+
+
+ ); +} diff --git a/apps/web/app/[locale]/home/[account]/fischerei/stocking/new/page.tsx b/apps/web/app/[locale]/home/[account]/fischerei/stocking/new/page.tsx new file mode 100644 index 000000000..dc5a2b753 --- /dev/null +++ b/apps/web/app/[locale]/home/[account]/fischerei/stocking/new/page.tsx @@ -0,0 +1,53 @@ +import { getSupabaseServerClient } from '@kit/supabase/server-client'; +import { createFischereiApi } from '@kit/fischerei/api'; +import { FischereiTabNavigation, CreateStockingForm } from '@kit/fischerei/components'; + +import { CmsPageShell } from '~/components/cms-page-shell'; +import { AccountNotFound } from '~/components/account-not-found'; + +interface Props { + params: Promise<{ account: string }>; +} + +export default async function NewStockingPage({ params }: Props) { + const { account } = await params; + const client = getSupabaseServerClient(); + + const { data: acct } = await client + .from('accounts') + .select('id') + .eq('slug', account) + .single(); + + if (!acct) return ; + + const api = createFischereiApi(client); + + // Load waters and species lists for form dropdowns + const [watersResult, speciesResult] = await Promise.all([ + api.listWaters(acct.id, { pageSize: 200 }), + api.listSpecies(acct.id, { pageSize: 200 }), + ]); + + const waters = watersResult.data.map((w: Record) => ({ + id: String(w.id), + name: String(w.name), + })); + + const species = speciesResult.data.map((s: Record) => ({ + id: String(s.id), + name: String(s.name), + })); + + return ( + + + + + ); +} diff --git a/apps/web/app/[locale]/home/[account]/fischerei/stocking/page.tsx b/apps/web/app/[locale]/home/[account]/fischerei/stocking/page.tsx new file mode 100644 index 000000000..2d20b3469 --- /dev/null +++ b/apps/web/app/[locale]/home/[account]/fischerei/stocking/page.tsx @@ -0,0 +1,45 @@ +import { getSupabaseServerClient } from '@kit/supabase/server-client'; +import { createFischereiApi } from '@kit/fischerei/api'; +import { FischereiTabNavigation, StockingDataTable } from '@kit/fischerei/components'; + +import { CmsPageShell } from '~/components/cms-page-shell'; +import { AccountNotFound } from '~/components/account-not-found'; + +interface Props { + params: Promise<{ account: string }>; + searchParams: Promise>; +} + +export default async function StockingPage({ params, searchParams }: Props) { + const { account } = await params; + const search = await searchParams; + const client = getSupabaseServerClient(); + + const { data: acct } = await client + .from('accounts') + .select('id') + .eq('slug', account) + .single(); + + if (!acct) return ; + + const api = createFischereiApi(client); + const page = Number(search.page) || 1; + const result = await api.listStocking(acct.id, { + page, + pageSize: 25, + }); + + return ( + + + + + ); +} diff --git a/apps/web/app/[locale]/home/[account]/fischerei/waters/new/page.tsx b/apps/web/app/[locale]/home/[account]/fischerei/waters/new/page.tsx new file mode 100644 index 000000000..ac130cf93 --- /dev/null +++ b/apps/web/app/[locale]/home/[account]/fischerei/waters/new/page.tsx @@ -0,0 +1,29 @@ +import { getSupabaseServerClient } from '@kit/supabase/server-client'; +import { FischereiTabNavigation, CreateWaterForm } from '@kit/fischerei/components'; + +import { CmsPageShell } from '~/components/cms-page-shell'; +import { AccountNotFound } from '~/components/account-not-found'; + +interface Props { + params: Promise<{ account: string }>; +} + +export default async function NewWaterPage({ params }: Props) { + const { account } = await params; + const client = getSupabaseServerClient(); + + const { data: acct } = await client + .from('accounts') + .select('id') + .eq('slug', account) + .single(); + + if (!acct) return ; + + return ( + + + + + ); +} diff --git a/apps/web/app/[locale]/home/[account]/fischerei/waters/page.tsx b/apps/web/app/[locale]/home/[account]/fischerei/waters/page.tsx new file mode 100644 index 000000000..8ded3489a --- /dev/null +++ b/apps/web/app/[locale]/home/[account]/fischerei/waters/page.tsx @@ -0,0 +1,47 @@ +import { getSupabaseServerClient } from '@kit/supabase/server-client'; +import { createFischereiApi } from '@kit/fischerei/api'; +import { FischereiTabNavigation, WatersDataTable } from '@kit/fischerei/components'; + +import { CmsPageShell } from '~/components/cms-page-shell'; +import { AccountNotFound } from '~/components/account-not-found'; + +interface Props { + params: Promise<{ account: string }>; + searchParams: Promise>; +} + +export default async function WatersPage({ params, searchParams }: Props) { + const { account } = await params; + const search = await searchParams; + const client = getSupabaseServerClient(); + + const { data: acct } = await client + .from('accounts') + .select('id') + .eq('slug', account) + .single(); + + if (!acct) return ; + + const api = createFischereiApi(client); + const page = Number(search.page) || 1; + const result = await api.listWaters(acct.id, { + search: search.q as string, + waterType: search.type as string, + page, + pageSize: 25, + }); + + return ( + + + + + ); +} diff --git a/apps/web/app/[locale]/home/[account]/layout.tsx b/apps/web/app/[locale]/home/[account]/layout.tsx index b487eefca..4908b79c7 100644 --- a/apps/web/app/[locale]/home/[account]/layout.tsx +++ b/apps/web/app/[locale]/home/[account]/layout.tsx @@ -1,11 +1,15 @@ +import { cache } from 'react'; import { use } from 'react'; import { cookies } from 'next/headers'; import { redirect } from 'next/navigation'; +import { Fish, FileSignature, Building2 } from 'lucide-react'; import * as z from 'zod'; +import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { TeamAccountWorkspaceContextProvider } from '@kit/team-accounts/components'; +import { NavigationConfigSchema } from '@kit/ui/navigation-schema'; import { Page, PageMobileNavigation, PageNavigation } from '@kit/ui/page'; import { SidebarProvider } from '@kit/ui/sidebar'; @@ -33,21 +37,109 @@ function TeamWorkspaceLayout({ children, params }: TeamWorkspaceLayoutProps) { return {children}; } +/** + * Query account_settings.features for a given account slug. + * Cached per-request so multiple calls don't hit the DB twice. + */ +const getAccountFeatures = cache(async (accountSlug: string) => { + const client = getSupabaseServerClient(); + + const { data: accountData } = await client + .from('accounts') + .select('id') + .eq('slug', accountSlug) + .single(); + + if (!accountData) return {}; + + const { data: settings } = await client + .from('account_settings') + .select('features') + .eq('account_id', accountData.id) + .maybeSingle(); + + return (settings?.features as Record) ?? {}; +}); + +/** + * Inject per-account feature routes (e.g. Fischerei) into the parsed + * navigation config. The entry is inserted right after "Veranstaltungen". + */ +function injectAccountFeatureRoutes( + config: z.output, + account: string, + features: Record, +): z.output { + if (!features.fischerei && !features.meetings && !features.verband) return config; + + const featureEntries: Array<{ + label: string; + path: string; + Icon: React.ReactNode; + }> = []; + + if (features.fischerei) { + featureEntries.push({ + label: 'common.routes.fischerei', + path: `/home/${account}/fischerei`, + Icon: , + }); + } + + if (features.meetings) { + featureEntries.push({ + label: 'common.routes.meetings', + path: `/home/${account}/meetings`, + Icon: , + }); + } + + if (features.verband) { + featureEntries.push({ + label: 'common.routes.verband', + path: `/home/${account}/verband`, + Icon: , + }); + } + + return { + ...config, + routes: config.routes.map((group) => { + if (!('children' in group)) return group; + + const eventsIndex = group.children.findIndex( + (child) => child.label === 'common.routes.events', + ); + + if (eventsIndex === -1) return group; + + const newChildren = [...group.children]; + newChildren.splice(eventsIndex + 1, 0, ...featureEntries); + + return { ...group, children: newChildren }; + }), + }; +} + async function SidebarLayout({ account, children, }: React.PropsWithChildren<{ account: string; }>) { - const [data, state] = await Promise.all([ + const [data, state, features] = await Promise.all([ loadTeamWorkspace(account), getLayoutState(account), + getAccountFeatures(account), ]); if (!data) { redirect('/'); } + const baseConfig = getTeamAccountSidebarConfig(account); + const config = injectAccountFeatureRoutes(baseConfig, account, features); + const accounts = data.accounts.map(({ name, slug, picture_url }) => ({ label: name, value: slug, @@ -64,6 +156,7 @@ async function SidebarLayout({ accountId={data.account.id} accounts={accounts} user={data.user} + config={config} /> @@ -75,6 +168,7 @@ async function SidebarLayout({ userId={data.user.id} accounts={accounts} account={account} + config={config} />
@@ -86,19 +180,25 @@ async function SidebarLayout({ ); } -function HeaderLayout({ +async function HeaderLayout({ account, children, }: React.PropsWithChildren<{ account: string; }>) { - const data = use(loadTeamWorkspace(account)); + const [data, features] = await Promise.all([ + loadTeamWorkspace(account), + getAccountFeatures(account), + ]); + + const baseConfig = getTeamAccountSidebarConfig(account); + const config = injectAccountFeatureRoutes(baseConfig, account, features); return ( - + {children} diff --git a/apps/web/app/[locale]/home/[account]/meetings/layout.tsx b/apps/web/app/[locale]/home/[account]/meetings/layout.tsx new file mode 100644 index 000000000..017f57b2e --- /dev/null +++ b/apps/web/app/[locale]/home/[account]/meetings/layout.tsx @@ -0,0 +1,5 @@ +import { ReactNode } from 'react'; + +export default function MeetingsLayout({ children }: { children: ReactNode }) { + return <>{children}; +} diff --git a/apps/web/app/[locale]/home/[account]/meetings/page.tsx b/apps/web/app/[locale]/home/[account]/meetings/page.tsx new file mode 100644 index 000000000..76ef7c22b --- /dev/null +++ b/apps/web/app/[locale]/home/[account]/meetings/page.tsx @@ -0,0 +1,43 @@ +import { getSupabaseServerClient } from '@kit/supabase/server-client'; +import { createMeetingsApi } from '@kit/sitzungsprotokolle/api'; +import { MeetingsTabNavigation, MeetingsDashboard } from '@kit/sitzungsprotokolle/components'; + +import { CmsPageShell } from '~/components/cms-page-shell'; +import { AccountNotFound } from '~/components/account-not-found'; + +interface PageProps { + params: Promise<{ account: string }>; +} + +export default async function MeetingsPage({ params }: PageProps) { + const { account } = await params; + const client = getSupabaseServerClient(); + + const { data: acct } = await client + .from('accounts') + .select('id') + .eq('slug', account) + .single(); + + if (!acct) return ; + + const api = createMeetingsApi(client); + + const [stats, recentProtocols, overdueTasks] = await Promise.all([ + api.getDashboardStats(acct.id), + api.getRecentProtocols(acct.id), + api.getOverdueTasks(acct.id), + ]); + + return ( + + + + + ); +} diff --git a/apps/web/app/[locale]/home/[account]/meetings/protocols/[protocolId]/page.tsx b/apps/web/app/[locale]/home/[account]/meetings/protocols/[protocolId]/page.tsx new file mode 100644 index 000000000..310345421 --- /dev/null +++ b/apps/web/app/[locale]/home/[account]/meetings/protocols/[protocolId]/page.tsx @@ -0,0 +1,128 @@ +import Link from 'next/link'; + +import { ArrowLeft } from 'lucide-react'; + +import { Badge } from '@kit/ui/badge'; +import { Button } from '@kit/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card'; + +import { getSupabaseServerClient } from '@kit/supabase/server-client'; +import { createMeetingsApi } from '@kit/sitzungsprotokolle/api'; +import { MeetingsTabNavigation, ProtocolItemsList } from '@kit/sitzungsprotokolle/components'; + +import { CmsPageShell } from '~/components/cms-page-shell'; + +import { MEETING_TYPE_LABELS } from '@kit/sitzungsprotokolle/lib/meetings-constants'; +import { AccountNotFound } from '~/components/account-not-found'; + +interface PageProps { + params: Promise<{ account: string; protocolId: string }>; +} + +export default async function ProtocolDetailPage({ params }: PageProps) { + const { account, protocolId } = await params; + const client = getSupabaseServerClient(); + + const { data: acct } = await client + .from('accounts') + .select('id') + .eq('slug', account) + .single(); + + if (!acct) return ; + + const api = createMeetingsApi(client); + + let protocol; + try { + protocol = await api.getProtocol(protocolId); + } catch { + return ( + +
+

Protokoll nicht gefunden

+ + + +
+
+ ); + } + + const items = await api.listItems(protocolId); + + return ( + + + +
+ {/* Back + Title */} +
+ + + +
+ + {/* Protocol Header */} + + +
+
+ {protocol.title} +
+ + {new Date(protocol.meeting_date).toLocaleDateString('de-DE', { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric', + })} + + · + + {MEETING_TYPE_LABELS[protocol.meeting_type] ?? protocol.meeting_type} + + {protocol.is_published ? ( + Veröffentlicht + ) : ( + Entwurf + )} +
+
+
+
+ + {protocol.location && ( +
+

Ort

+

{protocol.location}

+
+ )} + {protocol.attendees && ( +
+

Teilnehmer

+

{protocol.attendees}

+
+ )} + {protocol.remarks && ( +
+

Anmerkungen

+

{protocol.remarks}

+
+ )} +
+
+ + {/* Items List */} + +
+
+ ); +} diff --git a/apps/web/app/[locale]/home/[account]/meetings/protocols/new/page.tsx b/apps/web/app/[locale]/home/[account]/meetings/protocols/new/page.tsx new file mode 100644 index 000000000..737c4377a --- /dev/null +++ b/apps/web/app/[locale]/home/[account]/meetings/protocols/new/page.tsx @@ -0,0 +1,37 @@ +import { getSupabaseServerClient } from '@kit/supabase/server-client'; +import { MeetingsTabNavigation, CreateProtocolForm } from '@kit/sitzungsprotokolle/components'; + +import { CmsPageShell } from '~/components/cms-page-shell'; +import { AccountNotFound } from '~/components/account-not-found'; + +interface PageProps { + params: Promise<{ account: string }>; +} + +export default async function NewProtocolPage({ params }: PageProps) { + const { account } = await params; + const client = getSupabaseServerClient(); + + const { data: acct } = await client + .from('accounts') + .select('id') + .eq('slug', account) + .single(); + + if (!acct) return ; + + return ( + + +
+
+

Neues Protokoll erstellen

+

+ Erstellen Sie ein neues Sitzungsprotokoll mit Tagesordnungspunkten. +

+
+ +
+
+ ); +} diff --git a/apps/web/app/[locale]/home/[account]/meetings/protocols/page.tsx b/apps/web/app/[locale]/home/[account]/meetings/protocols/page.tsx new file mode 100644 index 000000000..7367ee3f3 --- /dev/null +++ b/apps/web/app/[locale]/home/[account]/meetings/protocols/page.tsx @@ -0,0 +1,50 @@ +import { getSupabaseServerClient } from '@kit/supabase/server-client'; +import { createMeetingsApi } from '@kit/sitzungsprotokolle/api'; +import { MeetingsTabNavigation, ProtocolsDataTable } from '@kit/sitzungsprotokolle/components'; + +import { CmsPageShell } from '~/components/cms-page-shell'; +import { AccountNotFound } from '~/components/account-not-found'; + +interface PageProps { + params: Promise<{ account: string }>; + searchParams: Promise>; +} + +export default async function ProtocolsPage({ params, searchParams }: PageProps) { + const { account } = await params; + const sp = await searchParams; + const client = getSupabaseServerClient(); + + const { data: acct } = await client + .from('accounts') + .select('id') + .eq('slug', account) + .single(); + + if (!acct) return ; + + const api = createMeetingsApi(client); + + const search = typeof sp.q === 'string' ? sp.q : undefined; + const meetingType = typeof sp.type === 'string' ? sp.type : undefined; + const page = typeof sp.page === 'string' ? parseInt(sp.page, 10) : 1; + + const result = await api.listProtocols(acct.id, { + search, + meetingType, + page, + }); + + return ( + + + + + ); +} diff --git a/apps/web/app/[locale]/home/[account]/meetings/tasks/page.tsx b/apps/web/app/[locale]/home/[account]/meetings/tasks/page.tsx new file mode 100644 index 000000000..4f09d908e --- /dev/null +++ b/apps/web/app/[locale]/home/[account]/meetings/tasks/page.tsx @@ -0,0 +1,52 @@ +import { getSupabaseServerClient } from '@kit/supabase/server-client'; +import { createMeetingsApi } from '@kit/sitzungsprotokolle/api'; +import { MeetingsTabNavigation, OpenTasksView } from '@kit/sitzungsprotokolle/components'; + +import { CmsPageShell } from '~/components/cms-page-shell'; +import { AccountNotFound } from '~/components/account-not-found'; + +interface PageProps { + params: Promise<{ account: string }>; + searchParams: Promise>; +} + +export default async function TasksPage({ params, searchParams }: PageProps) { + const { account } = await params; + const sp = await searchParams; + const client = getSupabaseServerClient(); + + const { data: acct } = await client + .from('accounts') + .select('id') + .eq('slug', account) + .single(); + + if (!acct) return ; + + const api = createMeetingsApi(client); + + const page = typeof sp.page === 'string' ? parseInt(sp.page, 10) : 1; + + const result = await api.listOpenTasks(acct.id, { page }); + + return ( + + +
+
+

Offene Aufgaben

+

+ Alle offenen und in Bearbeitung befindlichen Tagesordnungspunkte über alle Protokolle. +

+
+ +
+
+ ); +} diff --git a/apps/web/app/[locale]/home/[account]/members-cms/[memberId]/edit/page.tsx b/apps/web/app/[locale]/home/[account]/members-cms/[memberId]/edit/page.tsx index c42fa41e8..b578f4e8b 100644 --- a/apps/web/app/[locale]/home/[account]/members-cms/[memberId]/edit/page.tsx +++ b/apps/web/app/[locale]/home/[account]/members-cms/[memberId]/edit/page.tsx @@ -2,6 +2,7 @@ import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { createMemberManagementApi } from '@kit/member-management/api'; import { EditMemberForm } from '@kit/member-management/components'; import { CmsPageShell } from '~/components/cms-page-shell'; +import { AccountNotFound } from '~/components/account-not-found'; interface Props { params: Promise<{ account: string; memberId: string }>; @@ -11,7 +12,7 @@ export default async function EditMemberPage({ params }: Props) { const { account, memberId } = await params; const client = getSupabaseServerClient(); const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single(); - if (!acct) return
Konto nicht gefunden
; + if (!acct) return ; const api = createMemberManagementApi(client); const member = await api.getMember(memberId); diff --git a/apps/web/app/[locale]/home/[account]/members-cms/[memberId]/page.tsx b/apps/web/app/[locale]/home/[account]/members-cms/[memberId]/page.tsx index cf3fd9773..64024b816 100644 --- a/apps/web/app/[locale]/home/[account]/members-cms/[memberId]/page.tsx +++ b/apps/web/app/[locale]/home/[account]/members-cms/[memberId]/page.tsx @@ -2,6 +2,7 @@ import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { createMemberManagementApi } from '@kit/member-management/api'; import { MemberDetailView } from '@kit/member-management/components'; import { CmsPageShell } from '~/components/cms-page-shell'; +import { AccountNotFound } from '~/components/account-not-found'; interface Props { params: Promise<{ account: string; memberId: string }>; @@ -11,7 +12,7 @@ export default async function MemberDetailPage({ params }: Props) { const { account, memberId } = await params; const client = getSupabaseServerClient(); const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single(); - if (!acct) return
Konto nicht gefunden
; + if (!acct) return ; const api = createMemberManagementApi(client); const member = await api.getMember(memberId); diff --git a/apps/web/app/[locale]/home/[account]/members-cms/applications/page.tsx b/apps/web/app/[locale]/home/[account]/members-cms/applications/page.tsx index 204b363f9..5df3db453 100644 --- a/apps/web/app/[locale]/home/[account]/members-cms/applications/page.tsx +++ b/apps/web/app/[locale]/home/[account]/members-cms/applications/page.tsx @@ -2,6 +2,7 @@ import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { createMemberManagementApi } from '@kit/member-management/api'; import { ApplicationWorkflow } from '@kit/member-management/components'; import { CmsPageShell } from '~/components/cms-page-shell'; +import { AccountNotFound } from '~/components/account-not-found'; interface Props { params: Promise<{ account: string }>; @@ -11,7 +12,7 @@ export default async function ApplicationsPage({ params }: Props) { const { account } = await params; const client = getSupabaseServerClient(); const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single(); - if (!acct) return
Konto nicht gefunden
; + if (!acct) return ; const api = createMemberManagementApi(client); const applications = await api.listApplications(acct.id); diff --git a/apps/web/app/[locale]/home/[account]/members-cms/cards/page.tsx b/apps/web/app/[locale]/home/[account]/members-cms/cards/page.tsx index be6a8b1f8..98a2c8acf 100644 --- a/apps/web/app/[locale]/home/[account]/members-cms/cards/page.tsx +++ b/apps/web/app/[locale]/home/[account]/members-cms/cards/page.tsx @@ -1,11 +1,12 @@ import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { createMemberManagementApi } from '@kit/member-management/api'; -import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card'; -import { Button } from '@kit/ui/button'; -import { Badge } from '@kit/ui/badge'; -import { CreditCard, Download } from 'lucide-react'; +import { CreditCard } from 'lucide-react'; import { CmsPageShell } from '~/components/cms-page-shell'; import { EmptyState } from '~/components/empty-state'; +import { AccountNotFound } from '~/components/account-not-found'; + +/** All active members are fetched for the card overview. */ +const CARDS_PAGE_SIZE = 100; interface Props { params: Promise<{ account: string }>; @@ -15,67 +16,31 @@ export default async function MemberCardsPage({ params }: Props) { const { account } = await params; const client = getSupabaseServerClient(); const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single(); - if (!acct) return
Konto nicht gefunden
; + if (!acct) return ; const api = createMemberManagementApi(client); - const result = await api.listMembers(acct.id, { status: 'active', pageSize: 100 }); + const result = await api.listMembers(acct.id, { status: 'active', pageSize: CARDS_PAGE_SIZE }); const members = result.data; return ( -
-
-

{members.length} aktive Mitglieder

- -
- - {members.length === 0 ? ( - } - title="Keine aktiven Mitglieder" - description="Erstellen Sie zuerst Mitglieder, um Ausweise zu generieren." - actionLabel="Mitglieder verwalten" - actionHref={`/home/${account}/members-cms`} - /> - ) : ( -
- {members.map((m: Record) => ( - - -
-
-

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

-

Nr. {String(m.member_number ?? '—')}

-
- Aktiv -
-
- -
-
-
- ))} -
- )} - - - - PDF-Generierung - - -

- Die PDF-Generierung erfordert die Installation von @react-pdf/renderer. - Nach der Installation können Mitgliedsausweise einzeln oder als Stapel erstellt werden. -

-
-
-
+ {members.length === 0 ? ( + } + title="Keine aktiven Mitglieder" + description="Erstellen Sie zuerst Mitglieder, um Ausweise zu generieren." + actionLabel="Mitglieder verwalten" + actionHref={`/home/${account}/members-cms`} + /> + ) : ( + } + title="Feature in Entwicklung" + description={`Die Ausweiserstellung für ${members.length} aktive Mitglieder wird derzeit entwickelt. Diese Funktion wird in einem kommenden Update verfügbar sein.`} + actionLabel="Mitglieder verwalten" + actionHref={`/home/${account}/members-cms`} + /> + )}
); } diff --git a/apps/web/app/[locale]/home/[account]/members-cms/departments/create-department-dialog.tsx b/apps/web/app/[locale]/home/[account]/members-cms/departments/create-department-dialog.tsx new file mode 100644 index 000000000..1f87a75e2 --- /dev/null +++ b/apps/web/app/[locale]/home/[account]/members-cms/departments/create-department-dialog.tsx @@ -0,0 +1,103 @@ +'use client'; + +import { useCallback, useState } from 'react'; +import { useAction } from 'next-safe-action/hooks'; +import { useRouter } from 'next/navigation'; +import { toast } from '@kit/ui/sonner'; +import { Button } from '@kit/ui/button'; +import { Input } from '@kit/ui/input'; +import { Label } from '@kit/ui/label'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@kit/ui/dialog'; +import { Plus } from 'lucide-react'; + +import { createDepartment } from '@kit/member-management/actions/member-actions'; + +interface CreateDepartmentDialogProps { + accountId: string; +} + +export function CreateDepartmentDialog({ accountId }: CreateDepartmentDialogProps) { + const router = useRouter(); + const [open, setOpen] = useState(false); + const [name, setName] = useState(''); + const [description, setDescription] = useState(''); + + const { execute, isPending } = useAction(createDepartment, { + onSuccess: ({ data }) => { + if (data?.success) { + toast.success('Abteilung erstellt'); + setOpen(false); + setName(''); + setDescription(''); + router.refresh(); + } + }, + onError: ({ error }) => { + toast.error(error.serverError ?? 'Fehler beim Erstellen der Abteilung'); + }, + }); + + const handleSubmit = useCallback( + (e: React.FormEvent) => { + e.preventDefault(); + if (!name.trim()) return; + execute({ accountId, name: name.trim(), description: description.trim() || undefined }); + }, + [execute, accountId, name, description], + ); + + return ( + + }> + + Neue Abteilung + + +
+ + Neue Abteilung + + Erstellen Sie eine neue Abteilung oder Sparte für Ihren Verein. + + +
+
+ + setName(e.target.value)} + placeholder="z. B. Jugendabteilung" + required + minLength={1} + maxLength={128} + /> +
+
+ + setDescription(e.target.value)} + placeholder="Kurze Beschreibung" + /> +
+
+ + + +
+
+
+ ); +} diff --git a/apps/web/app/[locale]/home/[account]/members-cms/departments/page.tsx b/apps/web/app/[locale]/home/[account]/members-cms/departments/page.tsx index 913baec8d..680099b86 100644 --- a/apps/web/app/[locale]/home/[account]/members-cms/departments/page.tsx +++ b/apps/web/app/[locale]/home/[account]/members-cms/departments/page.tsx @@ -1,11 +1,11 @@ import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { createMemberManagementApi } from '@kit/member-management/api'; -import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card'; -import { Badge } from '@kit/ui/badge'; -import { Button } from '@kit/ui/button'; import { CmsPageShell } from '~/components/cms-page-shell'; import { EmptyState } from '~/components/empty-state'; import { Users } from 'lucide-react'; +import { AccountNotFound } from '~/components/account-not-found'; + +import { CreateDepartmentDialog } from './create-department-dialog'; interface Props { params: Promise<{ account: string }>; @@ -15,40 +15,45 @@ export default async function DepartmentsPage({ params }: Props) { const { account } = await params; const client = getSupabaseServerClient(); const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single(); - if (!acct) return
Konto nicht gefunden
; + if (!acct) return ; const api = createMemberManagementApi(client); const departments = await api.listDepartments(acct.id); return ( - {departments.length === 0 ? ( - } - title="Keine Abteilungen vorhanden" - description="Erstellen Sie Ihre erste Abteilung." - actionLabel="Neue Abteilung" - /> - ) : ( -
- - - - - - - - - {departments.map((dept: Record) => ( - - - - - ))} - -
NameBeschreibung
{String(dept.name)}{String(dept.description ?? '—')}
+
+
+
- )} + + {departments.length === 0 ? ( + } + title="Keine Abteilungen vorhanden" + description="Erstellen Sie Ihre erste Abteilung." + /> + ) : ( +
+ + + + + + + + + {departments.map((dept: Record) => ( + + + + + ))} + +
NameBeschreibung
{String(dept.name)}{String(dept.description ?? '—')}
+
+ )} +
); } diff --git a/apps/web/app/[locale]/home/[account]/members-cms/dues/page.tsx b/apps/web/app/[locale]/home/[account]/members-cms/dues/page.tsx index ad11cb834..c15e3e17a 100644 --- a/apps/web/app/[locale]/home/[account]/members-cms/dues/page.tsx +++ b/apps/web/app/[locale]/home/[account]/members-cms/dues/page.tsx @@ -2,6 +2,7 @@ import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { createMemberManagementApi } from '@kit/member-management/api'; import { DuesCategoryManager } from '@kit/member-management/components'; import { CmsPageShell } from '~/components/cms-page-shell'; +import { AccountNotFound } from '~/components/account-not-found'; interface Props { params: Promise<{ account: string }>; @@ -11,7 +12,7 @@ export default async function DuesPage({ params }: Props) { const { account } = await params; const client = getSupabaseServerClient(); const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single(); - if (!acct) return
Konto nicht gefunden
; + if (!acct) return ; const api = createMemberManagementApi(client); const categories = await api.listDuesCategories(acct.id); diff --git a/apps/web/app/[locale]/home/[account]/members-cms/import/page.tsx b/apps/web/app/[locale]/home/[account]/members-cms/import/page.tsx index 34fcd9c3f..e85081c9b 100644 --- a/apps/web/app/[locale]/home/[account]/members-cms/import/page.tsx +++ b/apps/web/app/[locale]/home/[account]/members-cms/import/page.tsx @@ -1,6 +1,7 @@ import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { MemberImportWizard } from '@kit/member-management/components'; import { CmsPageShell } from '~/components/cms-page-shell'; +import { AccountNotFound } from '~/components/account-not-found'; interface Props { params: Promise<{ account: string }>; @@ -10,7 +11,7 @@ export default async function MemberImportPage({ params }: Props) { const { account } = await params; const client = getSupabaseServerClient(); const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single(); - if (!acct) return
Konto nicht gefunden
; + if (!acct) return ; return ( diff --git a/apps/web/app/[locale]/home/[account]/members-cms/new/page.tsx b/apps/web/app/[locale]/home/[account]/members-cms/new/page.tsx index de6d0c137..000207477 100644 --- a/apps/web/app/[locale]/home/[account]/members-cms/new/page.tsx +++ b/apps/web/app/[locale]/home/[account]/members-cms/new/page.tsx @@ -2,6 +2,7 @@ import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { createMemberManagementApi } from '@kit/member-management/api'; import { CreateMemberForm } from '@kit/member-management/components'; import { CmsPageShell } from '~/components/cms-page-shell'; +import { AccountNotFound } from '~/components/account-not-found'; interface Props { params: Promise<{ account: string }> } @@ -9,7 +10,7 @@ export default async function NewMemberPage({ params }: Props) { const { account } = await params; const client = getSupabaseServerClient(); const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single(); - if (!acct) return
Konto nicht gefunden
; + if (!acct) return ; const api = createMemberManagementApi(client); const duesCategories = await api.listDuesCategories(acct.id); diff --git a/apps/web/app/[locale]/home/[account]/members-cms/page.tsx b/apps/web/app/[locale]/home/[account]/members-cms/page.tsx index 30a1f7dfa..002fa463d 100644 --- a/apps/web/app/[locale]/home/[account]/members-cms/page.tsx +++ b/apps/web/app/[locale]/home/[account]/members-cms/page.tsx @@ -2,6 +2,9 @@ import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { createMemberManagementApi } from '@kit/member-management/api'; import { MembersDataTable } from '@kit/member-management/components'; import { CmsPageShell } from '~/components/cms-page-shell'; +import { AccountNotFound } from '~/components/account-not-found'; + +const PAGE_SIZE = 25; interface Props { params: Promise<{ account: string }>; @@ -13,7 +16,7 @@ export default async function MembersPage({ params, searchParams }: Props) { const search = await searchParams; const client = getSupabaseServerClient(); const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single(); - if (!acct) return
Konto nicht gefunden
; + if (!acct) return ; const api = createMemberManagementApi(client); const page = Number(search.page) || 1; @@ -21,7 +24,7 @@ export default async function MembersPage({ params, searchParams }: Props) { search: search.q as string, status: search.status as string, page, - pageSize: 25, + pageSize: PAGE_SIZE, }); const duesCategories = await api.listDuesCategories(acct.id); @@ -31,7 +34,7 @@ export default async function MembersPage({ params, searchParams }: Props) { data={result.data} total={result.total} page={page} - pageSize={25} + pageSize={PAGE_SIZE} account={account} duesCategories={(duesCategories ?? []).map((c: Record) => ({ id: String(c.id), name: String(c.name), diff --git a/apps/web/app/[locale]/home/[account]/members-cms/statistics/page.tsx b/apps/web/app/[locale]/home/[account]/members-cms/statistics/page.tsx index 108e7af13..73eec3104 100644 --- a/apps/web/app/[locale]/home/[account]/members-cms/statistics/page.tsx +++ b/apps/web/app/[locale]/home/[account]/members-cms/statistics/page.tsx @@ -8,6 +8,7 @@ import { createMemberManagementApi } from '@kit/member-management/api'; import { CmsPageShell } from '~/components/cms-page-shell'; import { StatsCard } from '~/components/stats-card'; import { StatsBarChart, StatsPieChart } from '~/components/stats-charts'; +import { AccountNotFound } from '~/components/account-not-found'; interface PageProps { params: Promise<{ account: string }>; @@ -23,7 +24,7 @@ export default async function MemberStatisticsPage({ params }: PageProps) { .eq('slug', account) .single(); - if (!acct) return
Konto nicht gefunden
; + if (!acct) return ; const api = createMemberManagementApi(client); const stats = await api.getMemberStatistics(acct.id); diff --git a/apps/web/app/[locale]/home/[account]/modules/_components/module-toggles.tsx b/apps/web/app/[locale]/home/[account]/modules/_components/module-toggles.tsx new file mode 100644 index 000000000..cf377ea69 --- /dev/null +++ b/apps/web/app/[locale]/home/[account]/modules/_components/module-toggles.tsx @@ -0,0 +1,110 @@ +'use client'; + +import { useRouter } from 'next/navigation'; +import { useTransition } from 'react'; + +import { Fish, FileSignature, Building2 } from 'lucide-react'; +import { toast } from '@kit/ui/sonner'; + +import { Switch } from '@kit/ui/switch'; + +import { toggleModuleAction } from '../_lib/server/toggle-module'; + +interface ModuleDefinition { + key: string; + label: string; + description: string; + icon: React.ReactNode; +} + +const AVAILABLE_MODULES: ModuleDefinition[] = [ + { + key: 'fischerei', + label: 'Fischerei', + description: + 'Gewässer, Fischarten, Besatz, Fangbücher und Wettbewerbe verwalten', + icon: , + }, + { + key: 'meetings', + label: 'Sitzungsprotokolle', + description: + 'Sitzungsprotokolle, Tagesordnungspunkte und Beschlüsse verwalten', + icon: , + }, + { + key: 'verband', + label: 'Verbandsverwaltung', + description: + 'Mitgliedsvereine, Kontaktpersonen, Beiträge und Statistiken verwalten', + icon: , + }, +]; + +interface ModuleTogglesProps { + accountId: string; + features: Record; +} + +export function ModuleToggles({ accountId, features }: ModuleTogglesProps) { + const router = useRouter(); + const [isPending, startTransition] = useTransition(); + + const handleToggle = (moduleKey: string, enabled: boolean) => { + startTransition(async () => { + const result = await toggleModuleAction(accountId, moduleKey, enabled); + + if (result.success) { + toast.success( + enabled ? 'Modul aktiviert' : 'Modul deaktiviert', + ); + + router.refresh(); + } else { + toast.error('Fehler beim Aktualisieren des Moduls'); + } + }); + }; + + return ( +
+
+

Verfügbare Module

+

+ Aktivieren oder deaktivieren Sie Module für Ihren Verein +

+
+ +
+ {AVAILABLE_MODULES.map((mod) => { + const isEnabled = features[mod.key] === true; + + return ( +
+
+
{mod.icon}
+
+

{mod.label}

+

+ {mod.description} +

+
+
+ + + handleToggle(mod.key, Boolean(checked)) + } + disabled={isPending} + /> +
+ ); + })} +
+
+ ); +} diff --git a/apps/web/app/[locale]/home/[account]/modules/_lib/server/toggle-module.ts b/apps/web/app/[locale]/home/[account]/modules/_lib/server/toggle-module.ts new file mode 100644 index 000000000..27baa931c --- /dev/null +++ b/apps/web/app/[locale]/home/[account]/modules/_lib/server/toggle-module.ts @@ -0,0 +1,40 @@ +'use server'; + +import { revalidatePath } from 'next/cache'; + +import { getSupabaseServerClient } from '@kit/supabase/server-client'; + +export async function toggleModuleAction( + accountId: string, + moduleKey: string, + enabled: boolean, +) { + const client = getSupabaseServerClient(); + + // Read current features + const { data: settings } = await client + .from('account_settings') + .select('features') + .eq('account_id', accountId) + .maybeSingle(); + + const currentFeatures = + (settings?.features as Record) ?? {}; + const newFeatures = { ...currentFeatures, [moduleKey]: enabled }; + + // Upsert + const { error } = await client.from('account_settings').upsert( + { + account_id: accountId, + features: newFeatures, + }, + { onConflict: 'account_id' }, + ); + + if (error) { + return { success: false, error: error.message }; + } + + revalidatePath(`/home`, 'layout'); + return { success: true }; +} diff --git a/apps/web/app/[locale]/home/[account]/modules/page.tsx b/apps/web/app/[locale]/home/[account]/modules/page.tsx index a7e7cb46f..5c904252c 100644 --- a/apps/web/app/[locale]/home/[account]/modules/page.tsx +++ b/apps/web/app/[locale]/home/[account]/modules/page.tsx @@ -1,7 +1,14 @@ +import Link from 'next/link'; + import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { createModuleBuilderApi } from '@kit/module-builder/api'; +import { CmsPageShell } from '~/components/cms-page-shell'; +import { AccountNotFound } from '~/components/account-not-found'; + +import { ModuleToggles } from './_components/module-toggles'; + interface ModulesPageProps { params: Promise<{ account: string }>; } @@ -19,48 +26,59 @@ export default async function ModulesPage({ params }: ModulesPageProps) { .single(); if (!accountData) { - return
Account not found
; + return ; } + // Load account features + const { data: settings } = await client + .from('account_settings') + .select('features') + .eq('account_id', accountData.id) + .maybeSingle(); + + const features = (settings?.features as Record) ?? {}; + const modules = await api.modules.listModules(accountData.id); return ( -
-
-
-

Module

-

- Verwalten Sie Ihre Datenmodule -

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

- Noch keine Module vorhanden. Erstellen Sie Ihr erstes Modul. -

-
- ) : ( -
- {modules.map((module: Record) => ( -
-

{String(module.display_name)}

- {module.description ? ( -

- {String(module.description)} -

- ) : null} -
- Status: {String(module.status)} -
-
- ))} -
- )} -
+ {modules.length === 0 ? ( +
+

+ Noch keine Module vorhanden. Erstellen Sie Ihr erstes Modul. +

+
+ ) : ( +
+ {modules.map((module: Record) => ( + +

+ {String(module.display_name)} +

+ {module.description ? ( +

+ {String(module.description)} +

+ ) : null} +
+ Status: {String(module.status)} +
+ + ))} +
+ )} +
+
); } diff --git a/apps/web/app/[locale]/home/[account]/newsletter/[campaignId]/page.tsx b/apps/web/app/[locale]/home/[account]/newsletter/[campaignId]/page.tsx index a3b151d7b..1c1638222 100644 --- a/apps/web/app/[locale]/home/[account]/newsletter/[campaignId]/page.tsx +++ b/apps/web/app/[locale]/home/[account]/newsletter/[campaignId]/page.tsx @@ -11,47 +11,18 @@ import { createNewsletterApi } from '@kit/newsletter/api'; import { CmsPageShell } from '~/components/cms-page-shell'; import { StatsCard } from '~/components/stats-card'; +import { AccountNotFound } from '~/components/account-not-found'; +import { + NEWSLETTER_STATUS_VARIANT, + NEWSLETTER_STATUS_LABEL, + NEWSLETTER_RECIPIENT_STATUS_VARIANT, + NEWSLETTER_RECIPIENT_STATUS_LABEL, +} from '~/lib/status-badges'; interface PageProps { params: Promise<{ account: string; campaignId: string }>; } -const STATUS_VARIANT: Record< - string, - 'secondary' | 'default' | 'info' | 'outline' | 'destructive' -> = { - draft: 'secondary', - scheduled: 'default', - sending: 'info', - sent: 'outline', - failed: 'destructive', -}; - -const STATUS_LABEL: Record = { - draft: 'Entwurf', - scheduled: 'Geplant', - sending: 'Wird gesendet', - sent: 'Gesendet', - failed: 'Fehlgeschlagen', -}; - -const RECIPIENT_STATUS_VARIANT: Record< - string, - 'secondary' | 'default' | 'info' | 'outline' | 'destructive' -> = { - pending: 'secondary', - sent: 'default', - failed: 'destructive', - bounced: 'destructive', -}; - -const RECIPIENT_STATUS_LABEL: Record = { - pending: 'Ausstehend', - sent: 'Gesendet', - failed: 'Fehlgeschlagen', - bounced: 'Zurückgewiesen', -}; - export default async function NewsletterDetailPage({ params }: PageProps) { const { account, campaignId } = await params; const client = getSupabaseServerClient(); @@ -62,7 +33,7 @@ export default async function NewsletterDetailPage({ params }: PageProps) { .eq('slug', account) .single(); - if (!acct) return
Konto nicht gefunden
; + if (!acct) return ; const api = createNewsletterApi(client); @@ -102,8 +73,8 @@ export default async function NewsletterDetailPage({ params }: PageProps) { {String(newsletter.subject ?? '(Kein Betreff)')} - - {STATUS_LABEL[status] ?? status} + + {NEWSLETTER_STATUS_LABEL[status] ?? status} @@ -175,10 +146,10 @@ export default async function NewsletterDetailPage({ params }: PageProps) { - {RECIPIENT_STATUS_LABEL[rStatus] ?? rStatus} + {NEWSLETTER_RECIPIENT_STATUS_LABEL[rStatus] ?? rStatus} diff --git a/apps/web/app/[locale]/home/[account]/newsletter/new/page.tsx b/apps/web/app/[locale]/home/[account]/newsletter/new/page.tsx index 95d173f37..6a3fbc68d 100644 --- a/apps/web/app/[locale]/home/[account]/newsletter/new/page.tsx +++ b/apps/web/app/[locale]/home/[account]/newsletter/new/page.tsx @@ -1,6 +1,7 @@ import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { CreateNewsletterForm } from '@kit/newsletter/components'; import { CmsPageShell } from '~/components/cms-page-shell'; +import { AccountNotFound } from '~/components/account-not-found'; interface Props { params: Promise<{ account: string }> } @@ -8,7 +9,7 @@ export default async function NewNewsletterPage({ params }: Props) { const { account } = await params; const client = getSupabaseServerClient(); const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single(); - if (!acct) return
Konto nicht gefunden
; + if (!acct) return ; return ( diff --git a/apps/web/app/[locale]/home/[account]/newsletter/page.tsx b/apps/web/app/[locale]/home/[account]/newsletter/page.tsx index 1971dbc9c..1402c9c25 100644 --- a/apps/web/app/[locale]/home/[account]/newsletter/page.tsx +++ b/apps/web/app/[locale]/home/[account]/newsletter/page.tsx @@ -1,6 +1,6 @@ import Link from 'next/link'; -import { Mail, Plus, Send, Users } from 'lucide-react'; +import { ChevronLeft, ChevronRight, Mail, Plus, Send, Users } from 'lucide-react'; import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { Badge } from '@kit/ui/badge'; @@ -12,32 +12,22 @@ import { createNewsletterApi } from '@kit/newsletter/api'; 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'; +import { + NEWSLETTER_STATUS_VARIANT, + NEWSLETTER_STATUS_LABEL, +} from '~/lib/status-badges'; + +const PAGE_SIZE = 25; interface PageProps { params: Promise<{ account: string }>; + searchParams: Promise>; } -const STATUS_BADGE_VARIANT: Record< - string, - 'secondary' | 'default' | 'info' | 'outline' | 'destructive' -> = { - draft: 'secondary', - scheduled: 'default', - sending: 'info', - sent: 'outline', - failed: 'destructive', -}; - -const STATUS_LABEL: Record = { - draft: 'Entwurf', - scheduled: 'Geplant', - sending: 'Wird gesendet', - sent: 'Gesendet', - failed: 'Fehlgeschlagen', -}; - -export default async function NewsletterPage({ params }: PageProps) { +export default async function NewsletterPage({ params, searchParams }: PageProps) { const { account } = await params; + const search = await searchParams; const client = getSupabaseServerClient(); const { data: acct } = await client @@ -46,21 +36,29 @@ export default async function NewsletterPage({ params }: PageProps) { .eq('slug', account) .single(); - if (!acct) return
Konto nicht gefunden
; + if (!acct) return ; const api = createNewsletterApi(client); - const newsletters = await api.listNewsletters(acct.id); + const allNewsletters = await api.listNewsletters(acct.id); - const sentCount = newsletters.filter( + const sentCount = allNewsletters.filter( (n: Record) => n.status === 'sent', ).length; - const totalRecipients = newsletters.reduce( + const totalRecipients = allNewsletters.reduce( (sum: number, n: Record) => sum + (Number(n.total_recipients) || 0), 0, ); + // Pagination + const currentPage = Math.max(1, Number(search.page) || 1); + const totalItems = allNewsletters.length; + const totalPages = Math.max(1, Math.ceil(totalItems / PAGE_SIZE)); + const safePage = Math.min(currentPage, totalPages); + const startIdx = (safePage - 1) * PAGE_SIZE; + const newsletters = allNewsletters.slice(startIdx, startIdx + PAGE_SIZE); + return (
@@ -85,7 +83,7 @@ export default async function NewsletterPage({ params }: PageProps) {
} /> {/* Table or Empty State */} - {newsletters.length === 0 ? ( + {totalItems === 0 ? ( } title="Keine Newsletter vorhanden" @@ -112,7 +110,7 @@ export default async function NewsletterPage({ params }: PageProps) { ) : ( - Alle Newsletter ({newsletters.length}) + Alle Newsletter ({totalItems})
@@ -143,10 +141,10 @@ export default async function NewsletterPage({ params }: PageProps) { - {STATUS_LABEL[String(nl.status)] ?? String(nl.status)} + {NEWSLETTER_STATUS_LABEL[String(nl.status)] ?? String(nl.status)} @@ -169,6 +167,44 @@ export default async function NewsletterPage({ params }: PageProps) {
+ + {/* Pagination */} + {totalPages > 1 && ( +
+

+ {startIdx + 1}–{Math.min(startIdx + PAGE_SIZE, totalItems)} von {totalItems} +

+
+ {safePage > 1 ? ( + + + + ) : ( + + )} + + + {safePage} / {totalPages} + + + {safePage < totalPages ? ( + + + + ) : ( + + )} +
+
+ )}
)} diff --git a/apps/web/app/[locale]/home/[account]/newsletter/templates/page.tsx b/apps/web/app/[locale]/home/[account]/newsletter/templates/page.tsx index 795cf534b..6b972b014 100644 --- a/apps/web/app/[locale]/home/[account]/newsletter/templates/page.tsx +++ b/apps/web/app/[locale]/home/[account]/newsletter/templates/page.tsx @@ -11,6 +11,7 @@ import { createNewsletterApi } from '@kit/newsletter/api'; 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 }>; @@ -26,7 +27,7 @@ export default async function NewsletterTemplatesPage({ params }: PageProps) { .eq('slug', account) .single(); - if (!acct) return
Konto nicht gefunden
; + if (!acct) return ; const api = createNewsletterApi(client); const templates = await api.listTemplates(acct.id); diff --git a/apps/web/app/[locale]/home/[account]/page.tsx b/apps/web/app/[locale]/home/[account]/page.tsx index 6f8f33b54..a4b7811e9 100644 --- a/apps/web/app/[locale]/home/[account]/page.tsx +++ b/apps/web/app/[locale]/home/[account]/page.tsx @@ -15,7 +15,7 @@ import { import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { Badge } from '@kit/ui/badge'; -import { Button } from '@kit/ui/button'; + import { Card, CardContent, @@ -32,7 +32,9 @@ import { createBookingManagementApi } from '@kit/booking-management/api'; import { createEventManagementApi } from '@kit/event-management/api'; 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 TeamAccountHomePageProps { params: Promise<{ account: string }>; @@ -50,7 +52,7 @@ export default async function TeamAccountHomePage({ .eq('slug', account) .single(); - if (!acct) return
Konto nicht gefunden
; + if (!acct) return ; // Load all stats in parallel with allSettled for resilience const [ @@ -157,7 +159,15 @@ export default async function TeamAccountHomePage({ href={`/home/${account}/bookings/${String(booking.id)}`} className="text-sm font-medium hover:underline" > - Buchung #{String(booking.id).slice(0, 8)} + Buchung{' '} + {booking.check_in + ? new Date( + String(booking.check_in), + ).toLocaleDateString('de-DE', { + day: '2-digit', + month: 'short', + }) + : '—'}

{booking.check_in @@ -213,12 +223,11 @@ export default async function TeamAccountHomePage({ ))} {bookings.data.length === 0 && events.data.length === 0 && ( -

- -

- Noch keine Aktivitäten vorhanden -

-
+ } + title="Noch keine Aktivitäten" + description="Aktuelle Buchungen und Veranstaltungen werden hier angezeigt." + /> )}
@@ -231,69 +240,59 @@ export default async function TeamAccountHomePage({ Häufig verwendete Aktionen - - + + + + Neues Mitglied + + - - + + + + Neuer Kurs + + - - + + + + Newsletter erstellen + + - - + + + + Neue Buchung + + - - + + + + Neue Veranstaltung + + @@ -317,10 +316,11 @@ export default async function TeamAccountHomePage({ aktiv

- - + +
@@ -343,10 +343,11 @@ export default async function TeamAccountHomePage({ aktiv

- - + +
@@ -366,10 +367,11 @@ export default async function TeamAccountHomePage({ von {courseStats.totalCourses} insgesamt

- - + +
diff --git a/apps/web/app/[locale]/home/[account]/site-builder/[pageId]/edit/page.tsx b/apps/web/app/[locale]/home/[account]/site-builder/[pageId]/edit/page.tsx index c98d475bc..21531cb51 100644 --- a/apps/web/app/[locale]/home/[account]/site-builder/[pageId]/edit/page.tsx +++ b/apps/web/app/[locale]/home/[account]/site-builder/[pageId]/edit/page.tsx @@ -1,6 +1,7 @@ import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { createSiteBuilderApi } from '@kit/site-builder/api'; import { SiteEditor } from '@kit/site-builder/components'; +import { AccountNotFound } from '~/components/account-not-found'; interface Props { params: Promise<{ account: string; pageId: string }> } @@ -8,7 +9,7 @@ export default async function EditPageRoute({ params }: Props) { const { account, pageId } = await params; const client = getSupabaseServerClient(); const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single(); - if (!acct) return
Konto nicht gefunden
; + if (!acct) return ; const api = createSiteBuilderApi(client); const page = await api.getPage(pageId); diff --git a/apps/web/app/[locale]/home/[account]/site-builder/new/page.tsx b/apps/web/app/[locale]/home/[account]/site-builder/new/page.tsx index f0e461218..1b9be7ef1 100644 --- a/apps/web/app/[locale]/home/[account]/site-builder/new/page.tsx +++ b/apps/web/app/[locale]/home/[account]/site-builder/new/page.tsx @@ -1,6 +1,7 @@ import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { CreatePageForm } from '@kit/site-builder/components'; import { CmsPageShell } from '~/components/cms-page-shell'; +import { AccountNotFound } from '~/components/account-not-found'; interface Props { params: Promise<{ account: string }>; @@ -10,7 +11,7 @@ export default async function NewSitePage({ params }: Props) { const { account } = await params; const client = getSupabaseServerClient(); const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single(); - if (!acct) return
Konto nicht gefunden
; + if (!acct) return ; return ( diff --git a/apps/web/app/[locale]/home/[account]/site-builder/page.tsx b/apps/web/app/[locale]/home/[account]/site-builder/page.tsx index f66000f60..f2bb35647 100644 --- a/apps/web/app/[locale]/home/[account]/site-builder/page.tsx +++ b/apps/web/app/[locale]/home/[account]/site-builder/page.tsx @@ -3,10 +3,12 @@ import { createSiteBuilderApi } from '@kit/site-builder/api'; import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card'; import { Badge } from '@kit/ui/badge'; import { Button } from '@kit/ui/button'; +import { cn } from '@kit/ui/utils'; import { Plus, Globe, FileText, Settings, ExternalLink } from 'lucide-react'; import Link from 'next/link'; import { CmsPageShell } from '~/components/cms-page-shell'; import { EmptyState } from '~/components/empty-state'; +import { AccountNotFound } from '~/components/account-not-found'; interface Props { params: Promise<{ account: string }> } @@ -14,14 +16,15 @@ export default async function SiteBuilderDashboard({ params }: Props) { const { account } = await params; const client = getSupabaseServerClient(); const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single(); - if (!acct) return
Konto nicht gefunden
; + if (!acct) return ; const api = createSiteBuilderApi(client); const pages = await api.listPages(acct.id); const settings = await api.getSiteSettings(acct.id); const posts = await api.listPosts(acct.id); - const publishedCount = pages.filter((p: any) => p.is_published).length; + const isOnline = Boolean(settings?.is_public); + const publishedCount = pages.filter((p: Record) => p.is_published).length; return ( @@ -34,7 +37,7 @@ export default async function SiteBuilderDashboard({ params }: Props) { - {settings?.is_public && ( + {isOnline && ( @@ -48,7 +51,17 @@ export default async function SiteBuilderDashboard({ params }: Props) {

Seiten

{pages.length}

Veröffentlicht

{publishedCount}

-

Status

{settings?.is_public ? '🟢 Online' : '🔴 Offline'}

+ + +

Status

+

+ + + {isOnline ? 'Online' : 'Offline'} + +

+
+
{pages.length === 0 ? ( diff --git a/apps/web/app/[locale]/home/[account]/site-builder/posts/new/page.tsx b/apps/web/app/[locale]/home/[account]/site-builder/posts/new/page.tsx index dd3a026b5..5023fc263 100644 --- a/apps/web/app/[locale]/home/[account]/site-builder/posts/new/page.tsx +++ b/apps/web/app/[locale]/home/[account]/site-builder/posts/new/page.tsx @@ -1,6 +1,7 @@ import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { CreatePostForm } from '@kit/site-builder/components'; import { CmsPageShell } from '~/components/cms-page-shell'; +import { AccountNotFound } from '~/components/account-not-found'; interface Props { params: Promise<{ account: string }> } @@ -8,7 +9,7 @@ export default async function NewPostPage({ params }: Props) { const { account } = await params; const client = getSupabaseServerClient(); const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single(); - if (!acct) return
Konto nicht gefunden
; + if (!acct) return ; return ( diff --git a/apps/web/app/[locale]/home/[account]/site-builder/posts/page.tsx b/apps/web/app/[locale]/home/[account]/site-builder/posts/page.tsx index ee139418e..ce8a1d7b5 100644 --- a/apps/web/app/[locale]/home/[account]/site-builder/posts/page.tsx +++ b/apps/web/app/[locale]/home/[account]/site-builder/posts/page.tsx @@ -7,6 +7,7 @@ import { Plus } from 'lucide-react'; import Link from 'next/link'; import { CmsPageShell } from '~/components/cms-page-shell'; import { EmptyState } from '~/components/empty-state'; +import { AccountNotFound } from '~/components/account-not-found'; interface Props { params: Promise<{ account: string }> } @@ -14,7 +15,7 @@ export default async function PostsManagerPage({ params }: Props) { const { account } = await params; const client = getSupabaseServerClient(); const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single(); - if (!acct) return
Konto nicht gefunden
; + if (!acct) return ; const api = createSiteBuilderApi(client); const posts = await api.listPosts(acct.id); diff --git a/apps/web/app/[locale]/home/[account]/site-builder/settings/page.tsx b/apps/web/app/[locale]/home/[account]/site-builder/settings/page.tsx index c2a8da79d..4aafda5f6 100644 --- a/apps/web/app/[locale]/home/[account]/site-builder/settings/page.tsx +++ b/apps/web/app/[locale]/home/[account]/site-builder/settings/page.tsx @@ -2,6 +2,7 @@ import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { createSiteBuilderApi } from '@kit/site-builder/api'; import { SiteSettingsForm } from '@kit/site-builder/components'; import { CmsPageShell } from '~/components/cms-page-shell'; +import { AccountNotFound } from '~/components/account-not-found'; interface Props { params: Promise<{ account: string }> } @@ -9,7 +10,7 @@ export default async function SiteSettingsPage({ params }: Props) { const { account } = await params; const client = getSupabaseServerClient(); const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single(); - if (!acct) return
Konto nicht gefunden
; + if (!acct) return ; const api = createSiteBuilderApi(client); const settings = await api.getSiteSettings(acct.id); diff --git a/apps/web/app/[locale]/home/[account]/verband/clubs/[clubId]/page.tsx b/apps/web/app/[locale]/home/[account]/verband/clubs/[clubId]/page.tsx new file mode 100644 index 000000000..659f0466f --- /dev/null +++ b/apps/web/app/[locale]/home/[account]/verband/clubs/[clubId]/page.tsx @@ -0,0 +1,69 @@ +import { getSupabaseServerClient } from '@kit/supabase/server-client'; +import { createVerbandApi } from '@kit/verbandsverwaltung/api'; +import { + VerbandTabNavigation, + ClubContactsManager, + ClubFeeBillingTable, + ClubNotesList, +} from '@kit/verbandsverwaltung/components'; + +import { CmsPageShell } from '~/components/cms-page-shell'; +import { AccountNotFound } from '~/components/account-not-found'; + +interface Props { + params: Promise<{ account: string; clubId: string }>; +} + +export default async function ClubDetailPage({ params }: Props) { + const { account, clubId } = await params; + const client = getSupabaseServerClient(); + + const { data: acct } = await client + .from('accounts') + .select('id') + .eq('slug', account) + .single(); + + if (!acct) return ; + + const api = createVerbandApi(client); + const detail = await api.getClubDetail(clubId); + + return ( + + + +
+ {/* Club Header */} +
+
+

{detail.club.name}

+ {detail.club.short_name && ( +

{detail.club.short_name}

+ )} +
+ {detail.club.city && ( + {detail.club.zip} {detail.club.city} + )} + {detail.club.member_count != null && ( + {detail.club.member_count} Mitglieder + )} + {detail.club.founded_year && ( + Gegr. {detail.club.founded_year} + )} +
+
+
+ + {/* Contacts */} + + + {/* Fee Billings */} + + + {/* Notes */} + +
+
+ ); +} diff --git a/apps/web/app/[locale]/home/[account]/verband/clubs/new/page.tsx b/apps/web/app/[locale]/home/[account]/verband/clubs/new/page.tsx new file mode 100644 index 000000000..e71f7ae15 --- /dev/null +++ b/apps/web/app/[locale]/home/[account]/verband/clubs/new/page.tsx @@ -0,0 +1,37 @@ +import { getSupabaseServerClient } from '@kit/supabase/server-client'; +import { createVerbandApi } from '@kit/verbandsverwaltung/api'; +import { VerbandTabNavigation, CreateClubForm } from '@kit/verbandsverwaltung/components'; + +import { CmsPageShell } from '~/components/cms-page-shell'; +import { AccountNotFound } from '~/components/account-not-found'; + +interface Props { + params: Promise<{ account: string }>; +} + +export default async function NewClubPage({ params }: Props) { + const { account } = await params; + const client = getSupabaseServerClient(); + + const { data: acct } = await client + .from('accounts') + .select('id') + .eq('slug', account) + .single(); + + if (!acct) return ; + + const api = createVerbandApi(client); + const types = await api.listTypes(acct.id); + + return ( + + + ({ id: t.id, name: t.name }))} + /> + + ); +} diff --git a/apps/web/app/[locale]/home/[account]/verband/clubs/page.tsx b/apps/web/app/[locale]/home/[account]/verband/clubs/page.tsx new file mode 100644 index 000000000..a91e02045 --- /dev/null +++ b/apps/web/app/[locale]/home/[account]/verband/clubs/page.tsx @@ -0,0 +1,54 @@ +import { getSupabaseServerClient } from '@kit/supabase/server-client'; +import { createVerbandApi } from '@kit/verbandsverwaltung/api'; +import { VerbandTabNavigation, ClubsDataTable } from '@kit/verbandsverwaltung/components'; + +import { CmsPageShell } from '~/components/cms-page-shell'; +import { AccountNotFound } from '~/components/account-not-found'; + +interface Props { + params: Promise<{ account: string }>; + searchParams: Promise>; +} + +export default async function ClubsPage({ params, searchParams }: Props) { + const { account } = await params; + const search = await searchParams; + const client = getSupabaseServerClient(); + + const { data: acct } = await client + .from('accounts') + .select('id') + .eq('slug', account) + .single(); + + if (!acct) return ; + + const api = createVerbandApi(client); + const page = Number(search.page) || 1; + const showArchived = search.archived === '1'; + + const [result, types] = await Promise.all([ + api.listClubs(acct.id, { + search: search.q as string, + typeId: search.type as string, + archived: showArchived ? undefined : false, + page, + pageSize: 25, + }), + api.listTypes(acct.id), + ]); + + return ( + + + ({ id: t.id, name: t.name }))} + /> + + ); +} diff --git a/apps/web/app/[locale]/home/[account]/verband/layout.tsx b/apps/web/app/[locale]/home/[account]/verband/layout.tsx new file mode 100644 index 000000000..d461913eb --- /dev/null +++ b/apps/web/app/[locale]/home/[account]/verband/layout.tsx @@ -0,0 +1,5 @@ +import { ReactNode } from 'react'; + +export default function VerbandLayout({ children }: { children: ReactNode }) { + return <>{children}; +} diff --git a/apps/web/app/[locale]/home/[account]/verband/page.tsx b/apps/web/app/[locale]/home/[account]/verband/page.tsx new file mode 100644 index 000000000..558674223 --- /dev/null +++ b/apps/web/app/[locale]/home/[account]/verband/page.tsx @@ -0,0 +1,33 @@ +import { getSupabaseServerClient } from '@kit/supabase/server-client'; +import { createVerbandApi } from '@kit/verbandsverwaltung/api'; +import { VerbandTabNavigation, VerbandDashboard } from '@kit/verbandsverwaltung/components'; + +import { CmsPageShell } from '~/components/cms-page-shell'; +import { AccountNotFound } from '~/components/account-not-found'; + +interface PageProps { + params: Promise<{ account: string }>; +} + +export default async function VerbandPage({ params }: PageProps) { + const { account } = await params; + const client = getSupabaseServerClient(); + + const { data: acct } = await client + .from('accounts') + .select('id') + .eq('slug', account) + .single(); + + if (!acct) return ; + + const api = createVerbandApi(client); + const stats = await api.getDashboardStats(acct.id); + + return ( + + + + + ); +} diff --git a/apps/web/app/[locale]/home/[account]/verband/settings/_components/settings-content.tsx b/apps/web/app/[locale]/home/[account]/verband/settings/_components/settings-content.tsx new file mode 100644 index 000000000..d85d72b35 --- /dev/null +++ b/apps/web/app/[locale]/home/[account]/verband/settings/_components/settings-content.tsx @@ -0,0 +1,255 @@ +'use client'; + +import { useState } from 'react'; +import { useAction } from 'next-safe-action/hooks'; + +import { Plus, Pencil, Trash2, Settings } 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 { toast } from '@kit/ui/sonner'; + +import { + createRole, + updateRole, + deleteRole, + createAssociationType, + updateAssociationType, + deleteAssociationType, + createFeeType, + updateFeeType, + deleteFeeType, +} from '@kit/verbandsverwaltung/actions/verband-actions'; + +interface SettingsContentProps { + accountId: string; + roles: Array>; + types: Array>; + feeTypes: Array>; +} + +function SettingsSection({ + title, + items, + onAdd, + onUpdate, + onDelete, + isAdding, + isUpdating, +}: { + title: string; + items: Array>; + onAdd: (name: string, description?: string) => void; + onUpdate: (id: string, name: string) => void; + onDelete: (id: string) => void; + isAdding: boolean; + isUpdating: boolean; +}) { + const [showAdd, setShowAdd] = useState(false); + const [newName, setNewName] = useState(''); + const [newDesc, setNewDesc] = useState(''); + const [editingId, setEditingId] = useState(null); + const [editName, setEditName] = useState(''); + + return ( + + + + + {title} + + {!showAdd && ( + + )} + + + {showAdd && ( +
+ setNewName(e.target.value)} + className="flex-1" + /> + setNewDesc(e.target.value)} + className="flex-1" + /> + + +
+ )} + + {items.length === 0 ? ( +

Keine Einträge vorhanden.

+ ) : ( +
+ {items.map((item) => ( +
+ {editingId === String(item.id) ? ( +
+ setEditName(e.target.value)} + className="flex-1" + /> + + +
+ ) : ( + <> +
+ {String(item.name)} + {item.description && ( +

{String(item.description)}

+ )} +
+
+ + +
+ + )} +
+ ))} +
+ )} +
+
+ ); +} + +export default function SettingsContent({ + accountId, + roles, + types, + feeTypes, +}: SettingsContentProps) { + // Roles + const { execute: execCreateRole, isPending: isCreatingRole } = useAction(createRole, { + onSuccess: () => toast.success('Funktion erstellt'), + onError: ({ error }) => toast.error(error.serverError ?? 'Fehler'), + }); + const { execute: execUpdateRole, isPending: isUpdatingRole } = useAction(updateRole, { + onSuccess: () => toast.success('Funktion aktualisiert'), + onError: ({ error }) => toast.error(error.serverError ?? 'Fehler'), + }); + const { execute: execDeleteRole } = useAction(deleteRole, { + onSuccess: () => toast.success('Funktion gelöscht'), + onError: ({ error }) => toast.error(error.serverError ?? 'Fehler'), + }); + + // Types + const { execute: execCreateType, isPending: isCreatingType } = useAction(createAssociationType, { + onSuccess: () => toast.success('Vereinstyp erstellt'), + onError: ({ error }) => toast.error(error.serverError ?? 'Fehler'), + }); + const { execute: execUpdateType, isPending: isUpdatingType } = useAction(updateAssociationType, { + onSuccess: () => toast.success('Vereinstyp aktualisiert'), + onError: ({ error }) => toast.error(error.serverError ?? 'Fehler'), + }); + const { execute: execDeleteType } = useAction(deleteAssociationType, { + onSuccess: () => toast.success('Vereinstyp gelöscht'), + onError: ({ error }) => toast.error(error.serverError ?? 'Fehler'), + }); + + // Fee Types + const { execute: execCreateFeeType, isPending: isCreatingFee } = useAction(createFeeType, { + onSuccess: () => toast.success('Beitragsart erstellt'), + onError: ({ error }) => toast.error(error.serverError ?? 'Fehler'), + }); + const { execute: execUpdateFeeType, isPending: isUpdatingFee } = useAction(updateFeeType, { + onSuccess: () => toast.success('Beitragsart aktualisiert'), + onError: ({ error }) => toast.error(error.serverError ?? 'Fehler'), + }); + const { execute: execDeleteFeeType } = useAction(deleteFeeType, { + onSuccess: () => toast.success('Beitragsart gelöscht'), + onError: ({ error }) => toast.error(error.serverError ?? 'Fehler'), + }); + + return ( +
+
+

Einstellungen

+

+ Funktionen, Vereinstypen und Beitragsarten verwalten +

+
+ + execCreateRole({ accountId, name, description, sortOrder: 0 })} + onUpdate={(id, name) => execUpdateRole({ roleId: id, name })} + onDelete={(id) => execDeleteRole({ roleId: id })} + isAdding={isCreatingRole} + isUpdating={isUpdatingRole} + /> + + execCreateType({ accountId, name, description, sortOrder: 0 })} + onUpdate={(id, name) => execUpdateType({ typeId: id, name })} + onDelete={(id) => execDeleteType({ typeId: id })} + isAdding={isCreatingType} + isUpdating={isUpdatingType} + /> + + execCreateFeeType({ accountId, name, description, isActive: true })} + onUpdate={(id, name) => execUpdateFeeType({ feeTypeId: id, name })} + onDelete={(id) => execDeleteFeeType({ feeTypeId: id })} + isAdding={isCreatingFee} + isUpdating={isUpdatingFee} + /> +
+ ); +} diff --git a/apps/web/app/[locale]/home/[account]/verband/settings/page.tsx b/apps/web/app/[locale]/home/[account]/verband/settings/page.tsx new file mode 100644 index 000000000..e4d418ef8 --- /dev/null +++ b/apps/web/app/[locale]/home/[account]/verband/settings/page.tsx @@ -0,0 +1,44 @@ +import { getSupabaseServerClient } from '@kit/supabase/server-client'; +import { createVerbandApi } from '@kit/verbandsverwaltung/api'; +import { VerbandTabNavigation } from '@kit/verbandsverwaltung/components'; + +import { CmsPageShell } from '~/components/cms-page-shell'; + +import SettingsContent from './_components/settings-content'; +import { AccountNotFound } from '~/components/account-not-found'; + +interface Props { + params: Promise<{ account: string }>; +} + +export default async function SettingsPage({ params }: Props) { + const { account } = await params; + const client = getSupabaseServerClient(); + + const { data: acct } = await client + .from('accounts') + .select('id') + .eq('slug', account) + .single(); + + if (!acct) return ; + + const api = createVerbandApi(client); + const [roles, types, feeTypes] = await Promise.all([ + api.listRoles(acct.id), + api.listTypes(acct.id), + api.listFeeTypes(acct.id), + ]); + + return ( + + + + + ); +} diff --git a/apps/web/app/[locale]/home/[account]/verband/statistics/_components/statistics-content.tsx b/apps/web/app/[locale]/home/[account]/verband/statistics/_components/statistics-content.tsx new file mode 100644 index 000000000..9ee2dc3b4 --- /dev/null +++ b/apps/web/app/[locale]/home/[account]/verband/statistics/_components/statistics-content.tsx @@ -0,0 +1,100 @@ +'use client'; + +import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card'; +import { BarChart3 } from 'lucide-react'; +import { + AreaChart, + Area, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, +} from 'recharts'; + +const PLACEHOLDER_DATA = [ + { year: '2020', vereine: 12, mitglieder: 850 }, + { year: '2021', vereine: 14, mitglieder: 920 }, + { year: '2022', vereine: 15, mitglieder: 980 }, + { year: '2023', vereine: 16, mitglieder: 1050 }, + { year: '2024', vereine: 18, mitglieder: 1120 }, + { year: '2025', vereine: 19, mitglieder: 1200 }, +]; + +export default function StatisticsContent() { + return ( +
+
+

Statistik

+

+ Entwicklung der Mitgliedsvereine und Gesamtmitglieder im Zeitverlauf +

+
+ +
+ + + + + Vereinsentwicklung + + + + + + + + + + + + + + + + + + + + Mitgliederentwicklung + + + + + + + + + + + + + + +
+ + + +

+ Die Statistiken werden automatisch aus den Vereinsdaten und der Verbandshistorie berechnet. + Pflegen Sie die Mitgliederzahlen in den einzelnen Vereinsdetails, um aktuelle Auswertungen zu erhalten. +

+
+
+
+ ); +} diff --git a/apps/web/app/[locale]/home/[account]/verband/statistics/page.tsx b/apps/web/app/[locale]/home/[account]/verband/statistics/page.tsx new file mode 100644 index 000000000..03a992413 --- /dev/null +++ b/apps/web/app/[locale]/home/[account]/verband/statistics/page.tsx @@ -0,0 +1,20 @@ +import { VerbandTabNavigation } from '@kit/verbandsverwaltung/components'; + +import { CmsPageShell } from '~/components/cms-page-shell'; + +import StatisticsContent from './_components/statistics-content'; + +interface Props { + params: Promise<{ account: string }>; +} + +export default async function StatisticsPage({ params }: Props) { + const { account } = await params; + + return ( + + + + + ); +} diff --git a/apps/web/app/api/club/course-register/route.ts b/apps/web/app/api/club/course-register/route.ts new file mode 100644 index 000000000..8c3085c85 --- /dev/null +++ b/apps/web/app/api/club/course-register/route.ts @@ -0,0 +1,52 @@ +import { NextResponse } from 'next/server'; + +import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client'; + +export async function POST(request: Request) { + try { + const body = await request.json(); + const { courseId, firstName, lastName, email, phone } = body; + + if (!courseId || !firstName || !lastName || !email) { + return NextResponse.json( + { error: 'Kurs-ID, Vorname, Nachname und E-Mail sind erforderlich' }, + { status: 400 }, + ); + } + + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { + return NextResponse.json( + { error: 'Ungültige E-Mail-Adresse' }, + { status: 400 }, + ); + } + + const supabase = getSupabaseServerAdminClient(); + + const { error } = await supabase.from('course_participants').insert({ + course_id: courseId, + first_name: firstName, + last_name: lastName, + email, + phone: phone || null, + status: 'enrolled', + enrolled_at: new Date().toISOString(), + }); + + if (error) { + console.error('[course-register] Insert error:', error.message); + return NextResponse.json( + { error: 'Anmeldung fehlgeschlagen' }, + { status: 500 }, + ); + } + + return NextResponse.json({ + success: true, + message: 'Anmeldung erfolgreich', + }); + } catch (err) { + console.error('[course-register] Error:', err); + return NextResponse.json({ error: 'Serverfehler' }, { status: 500 }); + } +} diff --git a/apps/web/app/api/club/event-register/route.ts b/apps/web/app/api/club/event-register/route.ts new file mode 100644 index 000000000..433d25d3d --- /dev/null +++ b/apps/web/app/api/club/event-register/route.ts @@ -0,0 +1,64 @@ +import { NextResponse } from 'next/server'; + +import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client'; + +export async function POST(request: Request) { + try { + const body = await request.json(); + const { + eventId, + firstName, + lastName, + email, + phone, + dateOfBirth, + parentName, + parentPhone, + } = body; + + if (!eventId || !firstName || !lastName || !email) { + return NextResponse.json( + { error: 'Event-ID, Vorname, Nachname und E-Mail sind erforderlich' }, + { status: 400 }, + ); + } + + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { + return NextResponse.json( + { error: 'Ungültige E-Mail-Adresse' }, + { status: 400 }, + ); + } + + const supabase = getSupabaseServerAdminClient(); + + const { error } = await supabase.from('event_registrations').insert({ + event_id: eventId, + first_name: firstName, + last_name: lastName, + email, + phone: phone || null, + date_of_birth: dateOfBirth || null, + parent_name: parentName || null, + parent_phone: parentPhone || null, + status: 'registered', + created_at: new Date().toISOString(), + }); + + if (error) { + console.error('[event-register] Insert error:', error.message); + return NextResponse.json( + { error: 'Anmeldung fehlgeschlagen' }, + { status: 500 }, + ); + } + + return NextResponse.json({ + success: true, + message: 'Anmeldung erfolgreich', + }); + } catch (err) { + console.error('[event-register] Error:', err); + return NextResponse.json({ error: 'Serverfehler' }, { status: 500 }); + } +} diff --git a/apps/web/app/api/club/membership-apply/route.ts b/apps/web/app/api/club/membership-apply/route.ts new file mode 100644 index 000000000..fd1edc7ee --- /dev/null +++ b/apps/web/app/api/club/membership-apply/route.ts @@ -0,0 +1,70 @@ +import { NextResponse } from 'next/server'; + +import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client'; + +export async function POST(request: Request) { + try { + const body = await request.json(); + const { + accountId, + firstName, + lastName, + email, + phone, + street, + postalCode, + city, + dateOfBirth, + message, + } = body; + + if (!accountId || !firstName || !lastName || !email) { + return NextResponse.json( + { + error: + 'Konto-ID, Vorname, Nachname und E-Mail sind erforderlich', + }, + { status: 400 }, + ); + } + + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { + return NextResponse.json( + { error: 'Ungültige E-Mail-Adresse' }, + { status: 400 }, + ); + } + + const supabase = getSupabaseServerAdminClient(); + + const { error } = await supabase.from('membership_applications').insert({ + account_id: accountId, + first_name: firstName, + last_name: lastName, + email, + phone: phone || null, + street: street || null, + postal_code: postalCode || null, + city: city || null, + date_of_birth: dateOfBirth || null, + message: message || null, + status: 'submitted', + }); + + if (error) { + console.error('[membership-apply] Insert error:', error.message); + return NextResponse.json( + { error: 'Bewerbung fehlgeschlagen' }, + { status: 500 }, + ); + } + + return NextResponse.json({ + success: true, + message: 'Bewerbung erfolgreich eingereicht', + }); + } catch (err) { + console.error('[membership-apply] Error:', err); + return NextResponse.json({ error: 'Serverfehler' }, { status: 500 }); + } +} diff --git a/apps/web/app/api/healthcheck/route.ts b/apps/web/app/api/healthcheck/route.ts index 551961c66..53ccfb018 100644 --- a/apps/web/app/api/healthcheck/route.ts +++ b/apps/web/app/api/healthcheck/route.ts @@ -29,9 +29,10 @@ async function getSupabaseHealthCheck() { const { data, error } = await client .from('config') .select('billing_provider') - .single(); + .limit(1) + .maybeSingle(); - return !error && Boolean(data?.billing_provider); + return !error; } catch { return false; } diff --git a/apps/web/components/account-not-found.tsx b/apps/web/components/account-not-found.tsx new file mode 100644 index 000000000..e9ba61e93 --- /dev/null +++ b/apps/web/components/account-not-found.tsx @@ -0,0 +1,22 @@ +import Link from 'next/link'; +import { AlertTriangle } from 'lucide-react'; +import { Button } from '@kit/ui/button'; + +export function AccountNotFound() { + return ( +
+
+ +
+

Konto nicht gefunden

+

+ Das angeforderte Konto existiert nicht oder Sie haben keine Berechtigung darauf zuzugreifen. +

+
+ + + +
+
+ ); +} diff --git a/apps/web/components/confirm-dialog.tsx b/apps/web/components/confirm-dialog.tsx new file mode 100644 index 000000000..a853e25f6 --- /dev/null +++ b/apps/web/components/confirm-dialog.tsx @@ -0,0 +1,55 @@ +'use client'; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from '@kit/ui/alert-dialog'; + +interface ConfirmDialogProps { + trigger: React.ReactNode; + title: string; + description: string; + confirmLabel?: string; + cancelLabel?: string; + variant?: 'default' | 'destructive'; + onConfirm: () => void; +} + +export function ConfirmDialog({ + trigger, + title, + description, + confirmLabel = 'Bestätigen', + cancelLabel = 'Abbrechen', + variant = 'default', + onConfirm, +}: ConfirmDialogProps) { + return ( + + + + + + {title} + {description} + + + {cancelLabel} + + {confirmLabel} + + + + + ); +} diff --git a/apps/web/config/feature-flags.config.ts b/apps/web/config/feature-flags.config.ts index e7128773d..c27140c7f 100644 --- a/apps/web/config/feature-flags.config.ts +++ b/apps/web/config/feature-flags.config.ts @@ -51,6 +51,9 @@ const FeatureFlagsSchema = z.object({ enableNewsletter: z.boolean().default(true), enableGdprCompliance: z.boolean().default(true), enableSiteBuilder: z.boolean().default(true), + enableFischerei: z.boolean().default(false), + enableMeetingProtocols: z.boolean().default(false), + enableVerbandsverwaltung: z.boolean().default(false), }); const featuresFlagConfig = FeatureFlagsSchema.parse({ @@ -137,6 +140,18 @@ const featuresFlagConfig = FeatureFlagsSchema.parse({ process.env.NEXT_PUBLIC_ENABLE_SITE_BUILDER, true, ), + enableFischerei: getBoolean( + process.env.NEXT_PUBLIC_ENABLE_FISCHEREI, + false, + ), + enableMeetingProtocols: getBoolean( + process.env.NEXT_PUBLIC_ENABLE_MEETING_PROTOCOLS, + false, + ), + enableVerbandsverwaltung: getBoolean( + process.env.NEXT_PUBLIC_ENABLE_VERBANDSVERWALTUNG, + false, + ), } satisfies z.output); export default featuresFlagConfig; diff --git a/apps/web/config/paths.config.ts b/apps/web/config/paths.config.ts index 71f4daedb..f7e9941ce 100644 --- a/apps/web/config/paths.config.ts +++ b/apps/web/config/paths.config.ts @@ -31,6 +31,9 @@ const PathsSchema = z.object({ accountDocuments: z.string().min(1), accountNewsletter: z.string().min(1), accountSiteBuilder: z.string().min(1), + accountFischerei: z.string().min(1), + accountMeetings: z.string().min(1), + accountVerband: z.string().min(1), }), }); @@ -65,6 +68,9 @@ const pathsConfig = PathsSchema.parse({ accountDocuments: `/home/[account]/documents`, accountNewsletter: `/home/[account]/newsletter`, accountSiteBuilder: `/home/[account]/site-builder`, + accountFischerei: `/home/[account]/fischerei`, + accountMeetings: '/home/[account]/meetings', + accountVerband: '/home/[account]/verband', }, } satisfies z.output); diff --git a/apps/web/i18n/messages/de/cms.json b/apps/web/i18n/messages/de/cms.json index 4154d8097..1fb592984 100644 --- a/apps/web/i18n/messages/de/cms.json +++ b/apps/web/i18n/messages/de/cms.json @@ -170,7 +170,34 @@ "holidayPasses": "Ferienpässe", "eventDate": "Datum", "eventLocation": "Ort", - "capacity": "Plätze" + "capacity": "Plätze", + "allEvents": "Alle Veranstaltungen", + "locations": "Orte", + "totalCapacity": "Kapazität gesamt", + "noEvents": "Keine Veranstaltungen vorhanden", + "noEventsDescription": "Erstellen Sie Ihre erste Veranstaltung, um loszulegen.", + "name": "Name", + "status": "Status", + "paginationPage": "Seite {page} von {totalPages}", + "paginationPrevious": "Vorherige", + "paginationNext": "Nächste", + "registrationsOverview": "Anmeldungen aller Veranstaltungen im Überblick", + "totalRegistrations": "Anmeldungen gesamt", + "withRegistrations": "Mit Anmeldungen", + "overviewByEvent": "Übersicht nach Veranstaltung", + "noEventsForRegistrations": "Erstellen Sie eine Veranstaltung, um Anmeldungen zu erhalten.", + "utilization": "Auslastung", + "event": "Veranstaltung", + "holidayPassesDescription": "Ferienpässe und Ferienprogramme verwalten", + "newHolidayPass": "Neuer Ferienpass", + "noHolidayPasses": "Keine Ferienpässe vorhanden", + "noHolidayPassesDescription": "Erstellen Sie Ihren ersten Ferienpass.", + "allHolidayPasses": "Alle Ferienpässe", + "year": "Jahr", + "price": "Preis", + "validFrom": "Gültig von", + "validUntil": "Gültig bis", + "newEventDescription": "Veranstaltung oder Ferienprogramm anlegen" }, "finance": { "title": "Finanzen", @@ -259,7 +286,15 @@ "finance.write": "Finanzen bearbeiten", "finance.sepa": "SEPA-Einzüge ausführen", "documents.generate": "Dokumente generieren", - "newsletter.send": "Newsletter versenden" + "newsletter.send": "Newsletter versenden", + "fischerei.read": "Fischerei lesen", + "fischerei.write": "Fischerei bearbeiten", + "meetings.read": "Sitzungsprotokolle lesen", + "meetings.write": "Sitzungsprotokolle bearbeiten", + "meetings.delete": "Sitzungsprotokolle löschen", + "verband.read": "Verbandsverwaltung lesen", + "verband.write": "Verbandsverwaltung bearbeiten", + "verband.delete": "Verbandsverwaltung löschen" }, "status": { "active": "Aktiv", @@ -267,5 +302,477 @@ "archived": "Archiviert", "locked": "Gesperrt", "deleted": "Gelöscht" + }, + "fischerei": { + "title": "Fischerei", + "description": "Gewässer, Fischarten, Besatz, Pachten, Fangbücher und Wettbewerbe verwalten", + "dashboard": { + "title": "Übersicht", + "watersCount": "Gewässer", + "speciesCount": "Fischarten", + "activeLeases": "Aktive Pachten", + "pendingCatchBooks": "Offene Fangbücher", + "upcomingCompetitions": "Kommende Wettbewerbe", + "stockingCostYtd": "Besatzkosten (lfd. Jahr)", + "recentStocking": "Letzte Besatzaktionen", + "pendingReview": "Zur Prüfung ausstehend" + }, + "waters": { + "title": "Gewässer", + "newWater": "Neues Gewässer", + "editWater": "Gewässer bearbeiten", + "name": "Name", + "shortName": "Kurzname", + "waterType": "Gewässertyp", + "description": "Beschreibung", + "surfaceArea": "Fläche (ha)", + "length": "Länge (m)", + "width": "Breite (m)", + "avgDepth": "Durchschnittstiefe (m)", + "maxDepth": "Maximaltiefe (m)", + "outflow": "Abfluss", + "location": "Lage/Standort", + "county": "Landkreis", + "gpsCoordinates": "GPS-Koordinaten", + "lfvNumber": "LFV-Nummer", + "lfvName": "LFV-Name", + "costShares": "Kostenanteile", + "electrofishing": "Elektrofischerei-Genehmigung beantragt", + "costCenter": "Kostenstelle", + "archived": "Archiviert", + "showArchived": "Archivierte anzeigen", + "speciesRules": "Fischarten & Regelungen", + "stockingHistory": "Besatzhistorie", + "leases": "Pachten", + "inspectors": "Kontrolleure", + "map": "Karte", + "catchStats": "Fangstatistik", + "waterTypes": { + "fluss": "Fluss", + "bach": "Bach", + "see": "See", + "teich": "Teich", + "weiher": "Weiher", + "kanal": "Kanal", + "stausee": "Stausee", + "baggersee": "Baggersee", + "sonstige": "Sonstige" + }, + "basicData": "Grunddaten", + "dimensions": "Abmessungen", + "geography": "Geografie", + "administration": "Verwaltung" + }, + "species": { + "title": "Fischarten", + "newSpecies": "Neue Fischart", + "editSpecies": "Fischart bearbeiten", + "name": "Name", + "nameLatin": "Lateinischer Name", + "nameLocal": "Lokaler Name", + "maxAge": "Max. Alter (Jahre)", + "maxWeight": "Max. Gewicht (kg)", + "maxLength": "Max. Länge (cm)", + "protectedMinSize": "Schonmaß (cm)", + "protectionPeriod": "Schonzeit", + "protectionStart": "Schonzeit Beginn (MM.TT)", + "protectionEnd": "Schonzeit Ende (MM.TT)", + "spawningSeason": "Sonderschonzeit (SZG)", + "kFactor": "K-Faktor", + "kFactorAvg": "K-Faktor (Durchschnitt)", + "kFactorMin": "K-Faktor (Min)", + "kFactorMax": "K-Faktor (Max)", + "pricePerUnit": "Preis pro Einheit", + "maxCatchPerDay": "Max. Fang/Tag", + "maxCatchPerYear": "Max. Fang/Jahr", + "individualRecording": "Einzelerfassung", + "active": "Aktiv", + "biometrics": "Biometrische Daten", + "protection": "Schutzbestimmungen", + "quotas": "Fangbegrenzungen" + }, + "stocking": { + "title": "Besatz", + "newStocking": "Besatz eintragen", + "date": "Besatzdatum", + "water": "Gewässer", + "species": "Fischart", + "quantity": "Anzahl (Stück)", + "weight": "Gewicht (kg)", + "ageClass": "Altersklasse", + "cost": "Kosten (EUR)", + "supplier": "Lieferant", + "remarks": "Bemerkungen", + "ageClasses": { + "brut": "Brut", + "soemmerlinge": "Sömmerlinge", + "einsoemmerig": "1-sömmrig", + "zweisoemmerig": "2-sömmrig", + "dreisoemmerig": "3-sömmrig", + "vorgestreckt": "Vorgestreckt", + "setzlinge": "Setzlinge", + "laichfische": "Laichfische", + "sonstige": "Sonstige" + }, + "totalCost": "Gesamtkosten", + "totalQuantity": "Gesamtmenge" + }, + "leases": { + "title": "Pachten", + "newLease": "Neue Pacht", + "editLease": "Pacht bearbeiten", + "lessor": "Verpächter", + "lessorAddress": "Adresse des Verpächters", + "startDate": "Beginn", + "endDate": "Ende", + "duration": "Laufzeit (Jahre)", + "initialAmount": "Anfangsbetrag (EUR)", + "fixedIncrease": "Feste jährl. Erhöhung (EUR)", + "percentageIncrease": "Prozentuale jährl. Erhöhung (%)", + "paymentMethod": "Zahlungsart", + "specialAgreements": "Sondervereinbarungen", + "currentAmount": "Aktueller Jahresbetrag", + "paymentMethods": { + "bar": "Bar", + "lastschrift": "Lastschrift", + "ueberweisung": "Überweisung" + } + }, + "catchBooks": { + "title": "Fangbücher", + "newCatchBook": "Neues Fangbuch", + "member": "Mitglied", + "year": "Jahr", + "fishingDays": "Angeltage", + "totalCatches": "Gesamtfänge", + "status": "Status", + "verification": "Bewertung", + "submit": "Einreichen", + "review": "Prüfen", + "approve": "Akzeptieren", + "reject": "Ablehnen", + "submitted": "Eingereicht am", + "checked": "Geprüft", + "flyFisher": "Fliegenfischer", + "cardNumbers": "Erlaubnisschein-Nr.", + "statuses": { + "offen": "Offen", + "eingereicht": "Eingereicht", + "geprueft": "Geprüft", + "akzeptiert": "Akzeptiert", + "abgelehnt": "Abgelehnt" + }, + "verifications": { + "sehrgut": "Sehr gut", + "gut": "Gut", + "ok": "OK", + "schlecht": "Schlecht", + "falsch": "Falsch", + "leer": "Leer" + } + }, + "catches": { + "title": "Fänge", + "newCatch": "Fang eintragen", + "date": "Datum", + "species": "Fischart", + "water": "Gewässer", + "quantity": "Anzahl", + "length": "Länge (cm)", + "weight": "Gewicht (g)", + "kFactor": "K-Faktor", + "sizeCategory": "Größenkategorie", + "gender": "Geschlecht", + "permit": "Erlaubnisschein" + }, + "permits": { + "title": "Erlaubnisscheine", + "newPermit": "Neuer Erlaubnisschein", + "name": "Bezeichnung", + "shortCode": "Kurzcode", + "primaryWater": "Hauptgewässer", + "totalQuantity": "Gesamtmenge", + "forSale": "Zum Verkauf", + "quotas": "Kontingente" + }, + "inspectors": { + "title": "Gewässer-Kontrolleure", + "assignInspector": "Kontrolleur zuweisen", + "removeInspector": "Kontrolleur entfernen", + "assignmentStart": "Beginn", + "assignmentEnd": "Ende" + }, + "competitions": { + "title": "Wettbewerbe", + "newCompetition": "Neuer Wettbewerb", + "name": "Bezeichnung", + "date": "Datum", + "water": "Gewässer", + "maxParticipants": "Max. Teilnehmer", + "scoring": "Wertung", + "scoreByCount": "Nach Anzahl", + "scoreByHeaviest": "Nach Schwerster", + "scoreByTotalWeight": "Nach Gesamtgewicht", + "scoreByLongest": "Nach Längstem", + "scoreByTotalLength": "Nach Gesamtlänge", + "participants": "Teilnehmer", + "addParticipant": "Teilnehmer hinzufügen", + "results": "Ergebnisse", + "computeResults": "Ergebnisse berechnen", + "categories": "Kategorien", + "rank": "Platz" + }, + "suppliers": { + "title": "Lieferanten", + "newSupplier": "Neuer Lieferant", + "name": "Name", + "contactPerson": "Ansprechpartner", + "phone": "Telefon", + "email": "E-Mail", + "address": "Adresse" + }, + "statistics": { + "title": "Statistiken", + "catchesBySpecies": "Fänge nach Fischart", + "catchesByWater": "Fänge nach Gewässer", + "catchesByYear": "Fänge nach Jahr", + "stockingOverview": "Besatzübersicht", + "totalCatches": "Gesamtfänge", + "totalWeight": "Gesamtgewicht (kg)", + "avgLength": "Durchschn. Länge (cm)", + "avgKFactor": "Durchschn. K-Faktor", + "filterYear": "Jahr filtern", + "filterWater": "Gewässer filtern" + }, + "export": { + "exportStocking": "Besatz exportieren", + "exportCatches": "Fänge exportieren", + "formatCsv": "CSV", + "formatExcel": "Excel" + } + }, + "meetings": { + "title": "Sitzungsprotokolle", + "description": "Sitzungen, Tagesordnungspunkte und Beschlüsse verwalten", + "dashboard": { + "title": "Übersicht", + "totalMeetings": "Sitzungen gesamt", + "openDecisions": "Offene Beschlüsse", + "upcomingMeetings": "Kommende Sitzungen", + "recentProtocols": "Letzte Protokolle" + }, + "bodies": { + "title": "Gremien", + "newBody": "Neues Gremium", + "editBody": "Gremium bearbeiten", + "deleteBody": "Gremium löschen", + "name": "Name", + "shortName": "Kurzname", + "description": "Beschreibung", + "chairperson": "Vorsitzende(r)", + "members": "Mitglieder", + "meetingCycle": "Sitzungszyklus", + "active": "Aktiv", + "cycles": { + "weekly": "Wöchentlich", + "biweekly": "Zweiwöchentlich", + "monthly": "Monatlich", + "quarterly": "Vierteljährlich", + "biannual": "Halbjährlich", + "annual": "Jährlich", + "asNeeded": "Nach Bedarf" + } + }, + "sessions": { + "title": "Sitzungen", + "newSession": "Neue Sitzung", + "editSession": "Sitzung bearbeiten", + "deleteSession": "Sitzung löschen", + "body": "Gremium", + "date": "Datum", + "startTime": "Beginn", + "endTime": "Ende", + "location": "Ort", + "status": "Status", + "agenda": "Tagesordnung", + "protocol": "Protokoll", + "attendees": "Anwesende", + "absentees": "Abwesende", + "guests": "Gäste", + "recorder": "Protokollführer(in)", + "statuses": { + "planned": "Geplant", + "inProgress": "Laufend", + "completed": "Abgeschlossen", + "cancelled": "Abgesagt" + } + }, + "agendaItems": { + "title": "Tagesordnungspunkte", + "newItem": "Neuer TOP", + "editItem": "TOP bearbeiten", + "deleteItem": "TOP löschen", + "number": "TOP-Nr.", + "subject": "Betreff", + "description": "Beschreibung", + "presenter": "Berichterstatter(in)", + "duration": "Dauer (Min.)", + "type": "Art", + "attachments": "Anlagen", + "notes": "Notizen", + "types": { + "information": "Information", + "discussion": "Diskussion", + "decision": "Beschluss", + "election": "Wahl", + "report": "Bericht", + "miscellaneous": "Verschiedenes" + } + }, + "decisions": { + "title": "Beschlüsse", + "newDecision": "Neuer Beschluss", + "editDecision": "Beschluss bearbeiten", + "deleteDecision": "Beschluss löschen", + "number": "Beschluss-Nr.", + "subject": "Betreff", + "text": "Beschlusstext", + "result": "Ergebnis", + "votesFor": "Ja-Stimmen", + "votesAgainst": "Nein-Stimmen", + "abstentions": "Enthaltungen", + "responsible": "Verantwortlich", + "deadline": "Frist", + "status": "Status", + "remarks": "Bemerkungen", + "results": { + "accepted": "Angenommen", + "rejected": "Abgelehnt", + "deferred": "Vertagt", + "withdrawn": "Zurückgezogen" + }, + "statuses": { + "open": "Offen", + "inProgress": "In Bearbeitung", + "completed": "Erledigt", + "overdue": "Überfällig" + } + }, + "attendance": { + "title": "Anwesenheit", + "present": "Anwesend", + "absent": "Abwesend", + "excused": "Entschuldigt", + "arrivedLate": "Verspätet erschienen", + "leftEarly": "Vorzeitig gegangen" + }, + "protocol": { + "generate": "Protokoll generieren", + "preview": "Vorschau", + "export": "Exportieren", + "sign": "Unterschreiben", + "finalize": "Fertigstellen", + "formatPdf": "PDF", + "formatDocx": "Word" + } + }, + "verband": { + "title": "Verbandsverwaltung", + "description": "Mitgliedsvereine, Kontaktpersonen, Beiträge und Statistiken verwalten", + "dashboard": { + "title": "Übersicht", + "totalClubs": "Mitgliedsvereine gesamt", + "totalMembers": "Mitglieder gesamt", + "pendingDues": "Ausstehende Beiträge", + "upcomingEvents": "Kommende Veranstaltungen" + }, + "clubs": { + "title": "Mitgliedsvereine", + "newClub": "Neuer Verein", + "editClub": "Verein bearbeiten", + "deleteClub": "Verein löschen", + "name": "Vereinsname", + "shortName": "Kurzname", + "number": "Vereinsnummer", + "address": "Adresse", + "postalCode": "PLZ", + "city": "Ort", + "phone": "Telefon", + "email": "E-Mail", + "website": "Webseite", + "founded": "Gründungsjahr", + "memberCount": "Mitgliederzahl", + "joinedDate": "Beitrittsdatum", + "status": "Status", + "contacts": "Kontaktpersonen", + "dues": "Beiträge", + "notes": "Bemerkungen", + "statuses": { + "active": "Aktiv", + "inactive": "Inaktiv", + "suspended": "Ruhend", + "withdrawn": "Ausgetreten" + } + }, + "contacts": { + "title": "Kontaktpersonen", + "newContact": "Neue Kontaktperson", + "editContact": "Kontaktperson bearbeiten", + "deleteContact": "Kontaktperson löschen", + "firstName": "Vorname", + "lastName": "Nachname", + "role": "Funktion", + "phone": "Telefon", + "email": "E-Mail", + "isPrimary": "Hauptkontakt", + "roles": { + "chairman": "Vorsitzende(r)", + "viceChairman": "Stellv. Vorsitzende(r)", + "treasurer": "Kassenwart(in)", + "secretary": "Schriftführer(in)", + "youthLeader": "Jugendleiter(in)", + "boardMember": "Vorstandsmitglied", + "delegate": "Delegierte(r)", + "other": "Sonstige" + } + }, + "dues": { + "title": "Beiträge", + "newDue": "Neuer Beitrag", + "editDue": "Beitrag bearbeiten", + "year": "Jahr", + "amount": "Betrag (EUR)", + "dueDate": "Fälligkeitsdatum", + "paidDate": "Bezahlt am", + "status": "Status", + "invoiceNumber": "Rechnungsnummer", + "remarks": "Bemerkungen", + "statuses": { + "open": "Offen", + "paid": "Bezahlt", + "overdue": "Überfällig", + "waived": "Erlassen", + "partial": "Teilbezahlt" + }, + "bulkCreate": "Beiträge generieren", + "bulkCreateDescription": "Beiträge für alle aktiven Vereine erstellen", + "totalOpen": "Gesamt offen", + "totalPaid": "Gesamt bezahlt" + }, + "statistics": { + "title": "Statistiken", + "membersByClub": "Mitglieder nach Verein", + "membersTrend": "Mitgliederentwicklung", + "duesOverview": "Beitragsübersicht", + "clubsByRegion": "Vereine nach Region", + "filterYear": "Jahr filtern" + }, + "export": { + "exportClubs": "Vereine exportieren", + "exportContacts": "Kontaktpersonen exportieren", + "exportDues": "Beiträge exportieren", + "formatCsv": "CSV", + "formatExcel": "Excel" + } } } diff --git a/apps/web/i18n/messages/de/common.json b/apps/web/i18n/messages/de/common.json index c2a96071b..99ba5a813 100644 --- a/apps/web/i18n/messages/de/common.json +++ b/apps/web/i18n/messages/de/common.json @@ -75,7 +75,10 @@ "documents": "Dokumente", "newsletter": "Newsletter", "events": "Veranstaltungen", - "siteBuilder": "Website" + "siteBuilder": "Website", + "fischerei": "Fischerei", + "meetings": "Sitzungsprotokolle", + "verband": "Verbandsverwaltung" }, "roles": { "owner": { diff --git a/apps/web/i18n/messages/de/marketing.json b/apps/web/i18n/messages/de/marketing.json index a97597998..efdb7a9ff 100644 --- a/apps/web/i18n/messages/de/marketing.json +++ b/apps/web/i18n/messages/de/marketing.json @@ -31,8 +31,8 @@ "privacyPolicy": "Datenschutzerklärung", "privacyPolicyDescription": "Unsere Datenschutzerklärung und Datennutzung", "contactDescription": "Kontaktieren Sie uns bei Fragen oder Feedback", - "contactHeading": "Senden Sie uns eine Nachricht", - "contactSubheading": "Wir melden uns schnellstmöglich bei Ihnen", + "contactHeading": "Wir sind für Sie da", + "contactSubheading": "Persönliche Beratung, Testzugänge und individuelle Angebote — sprechen Sie uns an.", "contactName": "Ihr Name", "contactEmail": "Ihre E-Mail-Adresse", "contactMessage": "Ihre Nachricht", @@ -41,78 +41,78 @@ "contactError": "Fehler beim Senden Ihrer Nachricht", "contactSuccessDescription": "Wir haben Ihre Nachricht erhalten und melden uns schnellstmöglich", "contactErrorDescription": "Beim Senden ist ein Fehler aufgetreten. Bitte versuchen Sie es später erneut", - "footerDescription": "Die All-in-One-Verwaltungsplattform für Vereine, Clubs und Organisationen. Entwickelt von Com.BISS GmbH.", - "copyright": "© Copyright {year} {product}. Alle Rechte vorbehalten.", + "footerDescription": "Vereins- und Verbandsverwaltung aus Bayern — persönlich, zuverlässig und fair seit 2004. Entwickelt von Com.BISS GmbH, Schierling.", + "copyright": "© 2004–{year} Com.BISS GmbH. Alle Rechte vorbehalten.", - "heroPill": "Die nächste Generation der Vereinsverwaltung", - "heroTitle": "Verwalten Sie Ihre Organisation. Einfach und effizient.", - "heroSubtitle": "MyEasyCMS ist die All-in-One-Plattform für Vereine, Clubs und Organisationen. Verwalten Sie Mitglieder, Kurse, Veranstaltungen, Finanzen und mehr — alles an einem Ort.", + "heroPill": "Seit 2004 — 22 Jahre Erfahrung", + "heroTitle": "Vereinsverwaltung, die mitwächst", + "heroSubtitle": "Von der Mitgliederverwaltung bis zum SEPA-Einzug — die Software, der Vereine und Verbände in ganz Bayern seit zwei Jahrzehnten vertrauen. 69.000 verwaltete Mitglieder, 90+ angebundene Vereine.", - "trustedBy": "Vertraut von Vereinen und Clubs in ganz Deutschland", - "trustAssociations": "Vereine", - "trustSchools": "Bildungseinrichtungen", - "trustClubs": "Sport- & Angelvereine", - "trustOrganizations": "Gemeinnützige Organisationen", + "trustedBy": "Vertraut von Vereinen und Verbänden in ganz Bayern", + "trustAssociations": "3 Bezirksfischereiverbände", + "trustSchools": "VHS & Bildungseinrichtungen", + "trustClubs": "90+ Vereine angebunden", + "trustOrganizations": "Stadt Regensburg seit 2010", - "featuresHeading": "Alles, was Ihre Organisation braucht", - "featuresSubheading": "Von der Mitgliederverwaltung bis zur Finanzbuchhaltung — alle Werkzeuge in einer modernen, benutzerfreundlichen Plattform.", + "featuresHeading": "Alles, was Ihr Verein braucht", + "featuresSubheading": "MYeasyCMS wurde speziell für die Bedürfnisse von Vereinen und Verbänden entwickelt — modular aufgebaut, webbasiert und über jeden Browser nutzbar.", "featuresLabel": "Kernmodule", "featureMembersTitle": "Mitgliederverwaltung", - "featureMembersDesc": "Verwalten Sie alle Mitglieder mit Abteilungen, Beitragsverfolgung, Mitgliedsausweisen, Anträgen und detaillierten Statistiken.", + "featureMembersDesc": "Stammdaten, Kontaktinformationen, Mandate, Ehrungen, Historie und Notizen — alles an einem Ort. Mit Abteilungen, Beitragsverfolgung und Mitgliedsausweisen.", "featureCoursesTitle": "Kursverwaltung", - "featureCoursesDesc": "Organisieren Sie Kurse mit Terminplanung, Dozentenzuweisung, Anwesenheitsverfolgung, Kategorien und Standorten.", + "featureCoursesDesc": "Kurs-, Dozenten- und Teilnehmerverwaltung mit Kalender, Ferien- und Feiertagsberücksichtigung, Kostenkalkulation und Online-Anmeldung.", "featureBookingsTitle": "Raumbuchungen", - "featureBookingsDesc": "Buchen Sie Räume und Ressourcen mit einem visuellen Kalender, verwalten Sie Gäste und prüfen Sie die Verfügbarkeit.", + "featureBookingsDesc": "Reservierungsverwaltung mit Belegungsübersicht, Ferienberücksichtigung und Schlüsselmanagement für den Gebäudezugang.", "featureEventsTitle": "Veranstaltungsverwaltung", - "featureEventsDesc": "Planen und verwalten Sie Veranstaltungen mit Anmeldungen, Ferienpässen und Teilnehmerverfolgung.", - "featureFinanceTitle": "Finanzen & Abrechnung", - "featureFinanceDesc": "Erstellen Sie Rechnungen, verwalten Sie Zahlungen und SEPA-Lastschrifteinzüge — behalten Sie Ihre Finanzen mühelos im Griff.", - "featureNewsletterTitle": "Newsletter", - "featureNewsletterDesc": "Erstellen und versenden Sie professionelle Newsletter mit Vorlagen. Halten Sie Ihre Mitglieder informiert.", + "featureEventsDesc": "Planen und verwalten Sie Veranstaltungen mit Anmeldungen, Ferienpässen und Teilnehmerverfolgung — in Zusammenarbeit mit der Stadt Regensburg entwickelt.", + "featureFinanceTitle": "Beiträge & SEPA", + "featureFinanceDesc": "Beitrags- und Gebührenverwaltung, SEPA-Lastschriftmandate, XML-Export und Kontenführung nach Geschäftsjahren. Rechnungen und Zahlungen im Griff.", + "featureNewsletterTitle": "E-Mail & Newsletter", + "featureNewsletterDesc": "E-Mail-Kommunikation mit Mitgliedern und Newsletter-Versand mit Vorlagen. Halten Sie Ihre Mitglieder informiert.", "showcaseHeading": "Ein leistungsstarkes Dashboard auf einen Blick", - "showcaseDescription": "Erhalten Sie einen vollständigen Überblick über Ihre Organisation mit unserem intuitiven Dashboard. Greifen Sie auf alles zu — Mitglieder, Kurse, Veranstaltungen und Finanzen — von einer zentralen Stelle aus.", + "showcaseDescription": "Erhalten Sie einen vollständigen Überblick über Ihre Organisation — Mitglieder, Kurse, offene Rechnungen und Veranstaltungen — alles von einer zentralen Stelle aus. Schnellaktionen für die häufigsten Aufgaben.", "additionalFeaturesHeading": "Und es gibt noch mehr", - "additionalFeaturesSubheading": "Zusätzliche Werkzeuge, die jeden Aspekt der täglichen Arbeit Ihrer Organisation vereinfachen.", + "additionalFeaturesSubheading": "Zusätzliche Werkzeuge, die jeden Aspekt der täglichen Vereinsarbeit vereinfachen.", "additionalFeaturesLabel": "Weitere Funktionen", - "featureDocumentsTitle": "Dokumentenverwaltung", - "featureDocumentsDesc": "Erstellen Sie Dokumente aus Vorlagen, verwalten Sie Dateien und halten Sie alle wichtigen Unterlagen organisiert.", - "featureSiteBuilderTitle": "Website-Baukasten", - "featureSiteBuilderDesc": "Erstellen und verwalten Sie die Website Ihrer Organisation ohne Programmierkenntnisse. Aktualisieren Sie Inhalte ganz einfach.", + "featureDocumentsTitle": "Dokumente & Ausweise", + "featureDocumentsDesc": "Mitgliedsausweise, Rechnungen, Etiketten, Berichte, Briefe und Zertifikate — aus Vorlagen generiert, exportierbar als PDF und Excel.", + "featureSiteBuilderTitle": "Vereins-Website", + "featureSiteBuilderDesc": "Erstellen Sie die öffentliche Website Ihres Vereins ohne Programmierkenntnisse — mit Drag-and-Drop-Editor, Veranstaltungen und Kursangebot direkt aus dem CMS.", "featureModulesTitle": "Individuelle Module", - "featureModulesDesc": "Erweitern Sie die Plattform mit maßgeschneiderten Modulen für Ihre spezifischen Anforderungen. Importieren Sie Daten und passen Sie Einstellungen an.", + "featureModulesDesc": "Erweitern Sie die Plattform mit eigenen Datenmodulen für Gewässer, Fangbücher, Arbeitsdienste oder beliebige weitere Vereinsdaten.", - "whyChooseHeading": "Warum Organisationen MyEasyCMS wählen", - "whyChooseDescription": "Entwickelt mit über 20 Jahren Erfahrung im Dienste von Vereinen, Clubs und gemeinnützigen Organisationen in ganz Deutschland.", - "whyResponsiveTitle": "Mobilfreundlich", - "whyResponsiveDesc": "Greifen Sie von jedem Gerät auf Ihre Daten zu. Unser responsives Design funktioniert perfekt auf Desktop, Tablet und Smartphone.", - "whySecureTitle": "Sicher & Zuverlässig", - "whySecureDesc": "Ihre Daten sind mit erstklassiger Sicherheit geschützt. Regelmäßige Backups stellen sicher, dass nichts verloren geht.", - "whySupportTitle": "Persönlicher Support", - "whySupportDesc": "Erhalten Sie direkten, persönlichen Support von unserem Team. Wir sprechen Ihre Sprache und verstehen Ihre Bedürfnisse.", - "whyGdprTitle": "DSGVO-konform", - "whyGdprDesc": "Vollständig konform mit der europäischen Datenschutz-Grundverordnung. Die Daten Ihrer Mitglieder werden sorgfältig behandelt.", + "whyChooseHeading": "Warum Vereine Com.BISS vertrauen", + "whyChooseDescription": "2004 schlossen sich fünf Frauen zusammen und gründeten eine Web-Agentur. Seitdem arbeiten wir Hand in Hand mit unseren Kunden — persönlich, fair und zuverlässig. Geschäftsführerinnen: Brit Schiergl und Elisabeth Zehetbauer.", + "whyResponsiveTitle": "Persönlich", + "whyResponsiveDesc": "Wir kennen unsere Kunden und deren Anforderungen. Kein anonymes Ticketsystem — direkter Kontakt mit den Menschen, die Ihre Software entwickeln.", + "whySecureTitle": "Zuverlässig", + "whySecureDesc": "22 Jahre Kundenbeziehungen sprechen für sich. Wir unterstützen Sie auch außerhalb der üblichen Arbeitszeiten — schnell und unkompliziert.", + "whySupportTitle": "Fair", + "whySupportDesc": "Faire Preise, schnelle und flexible Umsetzung der Kundenwünsche. Neuentwicklungen und Änderungen zu festen Preisen — keine Überraschungen.", + "whyGdprTitle": "100% Server in Deutschland", + "whyGdprDesc": "Sitz in Schierling bei Regensburg. Server in Deutschland. DSGVO-konform. Ihre Daten bleiben hier.", "howItWorksHeading": "In drei einfachen Schritten loslegen", - "howItWorksSubheading": "Die Einrichtung Ihrer Organisation auf MyEasyCMS dauert nur wenige Minuten.", - "howStep1Title": "Konto erstellen", - "howStep1Desc": "Registrieren Sie sich kostenlos und richten Sie Ihr Organisationsprofil ein. Keine Kreditkarte erforderlich.", + "howItWorksSubheading": "Die Einrichtung Ihres Vereins auf MYeasyCMS dauert nur wenige Minuten.", + "howStep1Title": "Testzugang anfragen", + "howStep1Desc": "Fordern Sie einen kostenlosen Testzugang an — wir richten alles für Sie ein und führen Sie persönlich durch die Plattform.", "howStep2Title": "Module konfigurieren", - "howStep2Desc": "Aktivieren Sie die benötigten Module — Mitglieder, Kurse, Veranstaltungen, Finanzen — und passen Sie diese an Ihren Workflow an.", - "howStep3Title": "Team einladen", - "howStep3Desc": "Fügen Sie Teammitglieder mit verschiedenen Rollen und Berechtigungen hinzu. Verwalten Sie Ihre Organisation gemeinsam.", + "howStep2Desc": "Aktivieren Sie die benötigten Module — Mitglieder, Beiträge, Gewässer, SEPA — und passen Sie diese an Ihren Verein an.", + "howStep3Title": "Vereinsverwaltung starten", + "howStep3Desc": "Importieren Sie Ihre bestehenden Mitgliederlisten und legen Sie sofort los — mit persönlicher Unterstützung durch unser Team.", - "pricingPillLabel": "Kostenlos starten", - "pricingPillText": "Keine Kreditkarte erforderlich.", - "pricingHeading": "Faire Preise für alle Arten von Organisationen", - "pricingSubheading": "Starten Sie mit unserem kostenlosen Tarif und upgraden Sie, wenn Sie bereit sind.", + "pricingPillLabel": "Faire Preise", + "pricingPillText": "Keine Begrenzung der Benutzeranzahl", + "pricingHeading": "Faire Preise nach Vereinsgröße", + "pricingSubheading": "Alle Funktionen inklusive. Keine versteckten Kosten. Persönliche Einrichtung bei jedem Tarif.", - "ctaHeading": "Bereit, die Verwaltung Ihrer Organisation zu vereinfachen?", - "ctaDescription": "Schließen Sie sich hunderten von Vereinen, Clubs und Organisationen an, die MyEasyCMS bereits nutzen.", - "ctaButtonPrimary": "Jetzt kostenlos starten", - "ctaButtonSecondary": "Kontakt aufnehmen", - "ctaNote": "Keine Kreditkarte erforderlich. Kostenloser Tarif verfügbar." + "ctaHeading": "Bereit für eine Verwaltung, die einfach funktioniert?", + "ctaDescription": "Fordern Sie einen kostenlosen Testzugang an — oder lassen Sie sich persönlich beraten. Telefon: 09451 9499-09.", + "ctaButtonPrimary": "Kostenlosen Testzugang anfragen", + "ctaButtonSecondary": "Persönlich beraten lassen", + "ctaNote": "Kein Risiko. Persönliche Einrichtung inklusive. DSGVO-konform." } diff --git a/apps/web/i18n/messages/en/cms.json b/apps/web/i18n/messages/en/cms.json index 8ede39e99..04f8acd71 100644 --- a/apps/web/i18n/messages/en/cms.json +++ b/apps/web/i18n/messages/en/cms.json @@ -170,7 +170,34 @@ "holidayPasses": "Holiday Passes", "eventDate": "Date", "eventLocation": "Location", - "capacity": "Capacity" + "capacity": "Capacity", + "allEvents": "All Events", + "locations": "Locations", + "totalCapacity": "Total Capacity", + "noEvents": "No events yet", + "noEventsDescription": "Create your first event to get started.", + "name": "Name", + "status": "Status", + "paginationPage": "Page {page} of {totalPages}", + "paginationPrevious": "Previous", + "paginationNext": "Next", + "registrationsOverview": "Overview of registrations across all events", + "totalRegistrations": "Total Registrations", + "withRegistrations": "With Registrations", + "overviewByEvent": "Overview by Event", + "noEventsForRegistrations": "Create an event to start receiving registrations.", + "utilization": "Utilization", + "event": "Event", + "holidayPassesDescription": "Manage holiday passes and programs", + "newHolidayPass": "New Holiday Pass", + "noHolidayPasses": "No holiday passes yet", + "noHolidayPassesDescription": "Create your first holiday pass.", + "allHolidayPasses": "All Holiday Passes", + "year": "Year", + "price": "Price", + "validFrom": "Valid From", + "validUntil": "Valid Until", + "newEventDescription": "Create an event or holiday program" }, "finance": { "title": "Finance", diff --git a/apps/web/i18n/messages/en/common.json b/apps/web/i18n/messages/en/common.json index aa7a4fd06..1212393ba 100644 --- a/apps/web/i18n/messages/en/common.json +++ b/apps/web/i18n/messages/en/common.json @@ -75,7 +75,10 @@ "siteBuilder": "Website", "finance": "Finance", "documents": "Documents", - "newsletter": "Newsletter" + "newsletter": "Newsletter", + "fischerei": "Fisheries", + "meetings": "Meeting Protocols", + "verband": "Federation Management" }, "roles": { "owner": { diff --git a/apps/web/lib/status-badges.ts b/apps/web/lib/status-badges.ts new file mode 100644 index 000000000..a1152049b --- /dev/null +++ b/apps/web/lib/status-badges.ts @@ -0,0 +1,127 @@ +export const MEMBER_STATUS_VARIANT: Record = { + active: 'default', + inactive: 'secondary', + pending: 'outline', + resigned: 'destructive', + excluded: 'destructive', +}; + +export const MEMBER_STATUS_LABEL: Record = { + active: 'Aktiv', + inactive: 'Inaktiv', + pending: 'Ausstehend', + resigned: 'Ausgetreten', + excluded: 'Ausgeschlossen', +}; + +export const INVOICE_STATUS_VARIANT: Record = { + draft: 'outline', + sent: 'secondary', + paid: 'default', + overdue: 'destructive', + cancelled: 'destructive', +}; + +export const INVOICE_STATUS_LABEL: Record = { + draft: 'Entwurf', + sent: 'Gesendet', + paid: 'Bezahlt', + overdue: 'Überfällig', + cancelled: 'Storniert', +}; + +export const BATCH_STATUS_VARIANT: Record = { + draft: 'outline', + submitted: 'secondary', + processing: 'secondary', + completed: 'default', + failed: 'destructive', +}; + +export const BATCH_STATUS_LABEL: Record = { + draft: 'Entwurf', + submitted: 'Eingereicht', + processing: 'In Bearbeitung', + completed: 'Abgeschlossen', + failed: 'Fehlgeschlagen', +}; + +export const NEWSLETTER_STATUS_VARIANT: Record = { + draft: 'outline', + scheduled: 'secondary', + sending: 'secondary', + sent: 'default', + failed: 'destructive', +}; + +export const NEWSLETTER_STATUS_LABEL: Record = { + draft: 'Entwurf', + scheduled: 'Geplant', + sending: 'Wird gesendet', + sent: 'Gesendet', + failed: 'Fehlgeschlagen', +}; + +export const EVENT_STATUS_VARIANT: Record = { + planned: 'outline', + open: 'secondary', + full: 'secondary', + running: 'default', + completed: 'default', + cancelled: 'destructive', +}; + +export const EVENT_STATUS_LABEL: Record = { + planned: 'Geplant', + open: 'Offen', + full: 'Ausgebucht', + running: 'Laufend', + completed: 'Abgeschlossen', + cancelled: 'Abgesagt', +}; + +export const COURSE_STATUS_VARIANT: Record = { + planned: 'outline', + open: 'default', + active: 'default', + running: 'secondary', + completed: 'secondary', + cancelled: 'destructive', +}; + +export const COURSE_STATUS_LABEL: Record = { + planned: 'Geplant', + open: 'Offen', + active: 'Aktiv', + running: 'Laufend', + completed: 'Abgeschlossen', + cancelled: 'Abgesagt', +}; + +export const APPLICATION_STATUS_VARIANT: Record = { + submitted: 'outline', + review: 'secondary', + approved: 'default', + rejected: 'destructive', +}; + +export const APPLICATION_STATUS_LABEL: Record = { + submitted: 'Eingereicht', + review: 'In Prüfung', + approved: 'Genehmigt', + rejected: 'Abgelehnt', +}; + +export const NEWSLETTER_RECIPIENT_STATUS_VARIANT: Record = { + pending: 'secondary', + sent: 'default', + failed: 'destructive', + bounced: 'destructive', +}; + +export const NEWSLETTER_RECIPIENT_STATUS_LABEL: Record = { + pending: 'Ausstehend', + sent: 'Gesendet', + failed: 'Fehlgeschlagen', + bounced: 'Zurückgewiesen', +}; diff --git a/apps/web/package.json b/apps/web/package.json index aa85ddcfa..12e2d774d 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -84,8 +84,11 @@ "zod": "catalog:" }, "devDependencies": { + "@kit/fischerei": "workspace:*", "@kit/site-builder": "workspace:*", + "@kit/sitzungsprotokolle": "workspace:*", "@kit/tsconfig": "workspace:*", + "@kit/verbandsverwaltung": "workspace:*", "@next/bundle-analyzer": "catalog:", "@tailwindcss/postcss": "catalog:", "@types/react": "catalog:", diff --git a/apps/web/supabase/migrations/20260412000001_fischerei.sql b/apps/web/supabase/migrations/20260412000001_fischerei.sql new file mode 100644 index 000000000..969600ced --- /dev/null +++ b/apps/web/supabase/migrations/20260412000001_fischerei.sql @@ -0,0 +1,862 @@ +/* + * ------------------------------------------------------- + * Fischerei (Fishing Association Management) Schema + * Waters, species, stocking, leases, catch books, + * catches, permits, inspectors, competitions + * ------------------------------------------------------- + */ + +-- ===================================================== +-- 1. Enums +-- ===================================================== + +CREATE TYPE public.water_type AS ENUM( + 'fluss', 'bach', 'see', 'teich', 'weiher', 'kanal', 'stausee', 'baggersee', 'sonstige' +); + +CREATE TYPE public.fish_age_class AS ENUM( + 'brut', 'soemmerlinge', 'einsoemmerig', 'zweisoemmerig', 'dreisoemmerig', 'vorgestreckt', 'setzlinge', 'laichfische', 'sonstige' +); + +CREATE TYPE public.catch_book_status AS ENUM( + 'offen', 'eingereicht', 'geprueft', 'akzeptiert', 'abgelehnt' +); + +CREATE TYPE public.catch_book_verification AS ENUM( + 'sehrgut', 'gut', 'ok', 'schlecht', 'falsch', 'leer' +); + +CREATE TYPE public.lease_payment_method AS ENUM( + 'bar', 'lastschrift', 'ueberweisung' +); + +CREATE TYPE public.fish_gender AS ENUM( + 'maennlich', 'weiblich', 'unbekannt' +); + +CREATE TYPE public.fish_size_category AS ENUM( + 'gross', 'mittel', 'klein' +); + +-- ===================================================== +-- 2. Extend app_permissions +-- ===================================================== + +ALTER TYPE public.app_permissions ADD VALUE IF NOT EXISTS 'fischerei.read'; +ALTER TYPE public.app_permissions ADD VALUE IF NOT EXISTS 'fischerei.write'; + +-- ===================================================== +-- 3. cost_centers (shared, may already exist) +-- ===================================================== + +CREATE TABLE IF NOT EXISTS public.cost_centers ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + account_id uuid NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE, + name text NOT NULL, + code text, + description text, + is_active boolean NOT NULL DEFAULT true, + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS ix_cost_centers_account ON public.cost_centers(account_id); +ALTER TABLE public.cost_centers ENABLE ROW LEVEL SECURITY; +REVOKE ALL ON public.cost_centers FROM authenticated, service_role; +GRANT SELECT, INSERT, UPDATE, DELETE ON public.cost_centers TO authenticated; +GRANT ALL ON public.cost_centers TO service_role; + +CREATE POLICY cost_centers_select ON public.cost_centers FOR SELECT TO authenticated + USING (public.has_role_on_account(account_id)); +CREATE POLICY cost_centers_mutate ON public.cost_centers FOR ALL TO authenticated + USING (public.has_permission(auth.uid(), account_id, 'finance.write'::public.app_permissions)); + +-- ===================================================== +-- 4. fish_suppliers +-- ===================================================== + +CREATE TABLE IF NOT EXISTS public.fish_suppliers ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + account_id uuid NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE, + name text NOT NULL, + contact_person text, + phone text, + email text, + address text, + notes text, + is_active boolean NOT NULL DEFAULT true, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX ix_fish_suppliers_account ON public.fish_suppliers(account_id); +ALTER TABLE public.fish_suppliers ENABLE ROW LEVEL SECURITY; +REVOKE ALL ON public.fish_suppliers FROM authenticated, service_role; +GRANT SELECT, INSERT, UPDATE, DELETE ON public.fish_suppliers TO authenticated; +GRANT ALL ON public.fish_suppliers TO service_role; + +CREATE POLICY fish_suppliers_select ON public.fish_suppliers FOR SELECT TO authenticated + USING (public.has_role_on_account(account_id)); +CREATE POLICY fish_suppliers_insert ON public.fish_suppliers FOR INSERT TO authenticated + WITH CHECK (public.has_permission(auth.uid(), account_id, 'fischerei.write'::public.app_permissions)); +CREATE POLICY fish_suppliers_update ON public.fish_suppliers FOR UPDATE TO authenticated + USING (public.has_permission(auth.uid(), account_id, 'fischerei.write'::public.app_permissions)); +CREATE POLICY fish_suppliers_delete ON public.fish_suppliers FOR DELETE TO authenticated + USING (public.has_permission(auth.uid(), account_id, 'fischerei.write'::public.app_permissions)); + +CREATE TRIGGER trg_fish_suppliers_updated_at + BEFORE UPDATE ON public.fish_suppliers + FOR EACH ROW EXECUTE FUNCTION public.update_account_settings_timestamp(); + +-- ===================================================== +-- 5. waters (ve_gewaesser) +-- ===================================================== + +CREATE TABLE IF NOT EXISTS public.waters ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + account_id uuid NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE, + + -- Identity + name text NOT NULL, + short_name text, + water_type public.water_type NOT NULL DEFAULT 'sonstige', + description text, + + -- Dimensions + surface_area_ha numeric(10,2), + length_m numeric(10,0), + width_m numeric(10,2), + avg_depth_m numeric(6,2), + max_depth_m numeric(6,2), + + -- Geography + outflow text, + location text, + classification_order integer, + county text, + geo_lat numeric(10,7), + geo_lng numeric(10,7), + + -- LFV (Landesfischereiverband) + lfv_number text, + lfv_name text, + + -- Cost allocation + cost_share_ds numeric(5,2) DEFAULT 0, + cost_share_kalk numeric(5,2) DEFAULT 0, + + -- Electrofishing + electrofishing_permit_requested boolean NOT NULL DEFAULT false, + + -- HejFish integration + hejfish_id text, + + -- References + cost_center_id uuid REFERENCES public.cost_centers(id) ON DELETE SET NULL, + + -- Flags + is_archived boolean NOT NULL DEFAULT false, + + -- Meta + created_by uuid REFERENCES auth.users(id) ON DELETE SET NULL, + updated_by uuid REFERENCES auth.users(id) ON DELETE SET NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX ix_waters_account ON public.waters(account_id); +CREATE INDEX ix_waters_name ON public.waters(account_id, name); +CREATE INDEX ix_waters_type ON public.waters(account_id, water_type); +CREATE INDEX ix_waters_archived ON public.waters(account_id, is_archived); + +ALTER TABLE public.waters ENABLE ROW LEVEL SECURITY; +REVOKE ALL ON public.waters FROM authenticated, service_role; +GRANT SELECT, INSERT, UPDATE, DELETE ON public.waters TO authenticated; +GRANT ALL ON public.waters TO service_role; + +CREATE POLICY waters_select ON public.waters FOR SELECT TO authenticated + USING (public.has_role_on_account(account_id)); +CREATE POLICY waters_insert ON public.waters FOR INSERT TO authenticated + WITH CHECK (public.has_permission(auth.uid(), account_id, 'fischerei.write'::public.app_permissions)); +CREATE POLICY waters_update ON public.waters FOR UPDATE TO authenticated + USING (public.has_permission(auth.uid(), account_id, 'fischerei.write'::public.app_permissions)); +CREATE POLICY waters_delete ON public.waters FOR DELETE TO authenticated + USING (public.has_permission(auth.uid(), account_id, 'fischerei.write'::public.app_permissions)); + +CREATE TRIGGER trg_waters_updated_at + BEFORE UPDATE ON public.waters + FOR EACH ROW EXECUTE FUNCTION public.update_account_settings_timestamp(); + +-- ===================================================== +-- 6. fish_species (ve_fischarten) +-- ===================================================== + +CREATE TABLE IF NOT EXISTS public.fish_species ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + account_id uuid NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE, + + -- Names + name text NOT NULL, + name_latin text, + name_local text, + + -- Status + is_active boolean NOT NULL DEFAULT true, + + -- Biometrics + max_age_years integer, + max_weight_kg numeric(8,2), + max_length_cm numeric(6,1), + + -- Protection (Schonmaß / Schonzeit) + protected_min_size_cm numeric(5,1), + protection_period_start text, -- MM.DD format + protection_period_end text, -- MM.DD format + + -- Spawning season (Sonderschonzeit / SZG) + spawning_season_start text, -- MM.DD format + spawning_season_end text, -- MM.DD format + has_special_spawning_season boolean NOT NULL DEFAULT false, + + -- Condition factor (K-Faktor) + k_factor_avg numeric(6,3), + k_factor_min numeric(6,3), + k_factor_max numeric(6,3), + + -- Quotas + price_per_unit numeric(8,2), + max_catch_per_day integer, + max_catch_per_year integer, + + -- Recording + individual_recording boolean NOT NULL DEFAULT false, + + -- Meta + sort_order integer NOT NULL DEFAULT 0, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX ix_fish_species_account ON public.fish_species(account_id); +CREATE INDEX ix_fish_species_name ON public.fish_species(account_id, name); +CREATE INDEX ix_fish_species_active ON public.fish_species(account_id, is_active); + +ALTER TABLE public.fish_species ENABLE ROW LEVEL SECURITY; +REVOKE ALL ON public.fish_species FROM authenticated, service_role; +GRANT SELECT, INSERT, UPDATE, DELETE ON public.fish_species TO authenticated; +GRANT ALL ON public.fish_species TO service_role; + +CREATE POLICY fish_species_select ON public.fish_species FOR SELECT TO authenticated + USING (public.has_role_on_account(account_id)); +CREATE POLICY fish_species_insert ON public.fish_species FOR INSERT TO authenticated + WITH CHECK (public.has_permission(auth.uid(), account_id, 'fischerei.write'::public.app_permissions)); +CREATE POLICY fish_species_update ON public.fish_species FOR UPDATE TO authenticated + USING (public.has_permission(auth.uid(), account_id, 'fischerei.write'::public.app_permissions)); +CREATE POLICY fish_species_delete ON public.fish_species FOR DELETE TO authenticated + USING (public.has_permission(auth.uid(), account_id, 'fischerei.write'::public.app_permissions)); + +CREATE TRIGGER trg_fish_species_updated_at + BEFORE UPDATE ON public.fish_species + FOR EACH ROW EXECUTE FUNCTION public.update_account_settings_timestamp(); + +-- ===================================================== +-- 7. water_species_rules (ve_gewaesser_fischart) +-- ===================================================== + +CREATE TABLE IF NOT EXISTS public.water_species_rules ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + water_id uuid NOT NULL REFERENCES public.waters(id) ON DELETE CASCADE, + species_id uuid NOT NULL REFERENCES public.fish_species(id) ON DELETE CASCADE, + + -- Overrides (null = use species default) + min_size_cm numeric(5,1), + protection_period_start text, + protection_period_end text, + max_catch_per_day integer, + max_catch_per_year integer, + + created_at timestamptz NOT NULL DEFAULT now(), + UNIQUE(water_id, species_id) +); + +CREATE INDEX ix_water_species_rules_water ON public.water_species_rules(water_id); +CREATE INDEX ix_water_species_rules_species ON public.water_species_rules(species_id); + +ALTER TABLE public.water_species_rules ENABLE ROW LEVEL SECURITY; +REVOKE ALL ON public.water_species_rules FROM authenticated, service_role; +GRANT SELECT, INSERT, UPDATE, DELETE ON public.water_species_rules TO authenticated; +GRANT ALL ON public.water_species_rules TO service_role; + +CREATE POLICY water_species_rules_select ON public.water_species_rules FOR SELECT TO authenticated + USING (EXISTS (SELECT 1 FROM public.waters w WHERE w.id = water_species_rules.water_id AND public.has_role_on_account(w.account_id))); +CREATE POLICY water_species_rules_mutate ON public.water_species_rules FOR ALL TO authenticated + USING (EXISTS (SELECT 1 FROM public.waters w WHERE w.id = water_species_rules.water_id AND public.has_permission(auth.uid(), w.account_id, 'fischerei.write'::public.app_permissions))); + +-- ===================================================== +-- 8. fish_stocking (ve_besatz) +-- ===================================================== + +CREATE TABLE IF NOT EXISTS public.fish_stocking ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + account_id uuid NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE, + water_id uuid NOT NULL REFERENCES public.waters(id) ON DELETE CASCADE, + species_id uuid NOT NULL REFERENCES public.fish_species(id) ON DELETE RESTRICT, + + stocking_date date NOT NULL, + quantity integer NOT NULL DEFAULT 0, + weight_kg numeric(8,2), + age_class public.fish_age_class NOT NULL DEFAULT 'sonstige', + cost_euros numeric(10,2), + supplier_id uuid REFERENCES public.fish_suppliers(id) ON DELETE SET NULL, + remarks text, + + created_by uuid REFERENCES auth.users(id) ON DELETE SET NULL, + updated_by uuid REFERENCES auth.users(id) ON DELETE SET NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX ix_fish_stocking_account ON public.fish_stocking(account_id); +CREATE INDEX ix_fish_stocking_water ON public.fish_stocking(water_id); +CREATE INDEX ix_fish_stocking_species ON public.fish_stocking(species_id); +CREATE INDEX ix_fish_stocking_date ON public.fish_stocking(stocking_date DESC); + +ALTER TABLE public.fish_stocking ENABLE ROW LEVEL SECURITY; +REVOKE ALL ON public.fish_stocking FROM authenticated, service_role; +GRANT SELECT, INSERT, UPDATE, DELETE ON public.fish_stocking TO authenticated; +GRANT ALL ON public.fish_stocking TO service_role; + +CREATE POLICY fish_stocking_select ON public.fish_stocking FOR SELECT TO authenticated + USING (public.has_role_on_account(account_id)); +CREATE POLICY fish_stocking_insert ON public.fish_stocking FOR INSERT TO authenticated + WITH CHECK (public.has_permission(auth.uid(), account_id, 'fischerei.write'::public.app_permissions)); +CREATE POLICY fish_stocking_update ON public.fish_stocking FOR UPDATE TO authenticated + USING (public.has_permission(auth.uid(), account_id, 'fischerei.write'::public.app_permissions)); +CREATE POLICY fish_stocking_delete ON public.fish_stocking FOR DELETE TO authenticated + USING (public.has_permission(auth.uid(), account_id, 'fischerei.write'::public.app_permissions)); + +CREATE TRIGGER trg_fish_stocking_updated_at + BEFORE UPDATE ON public.fish_stocking + FOR EACH ROW EXECUTE FUNCTION public.update_account_settings_timestamp(); + +-- ===================================================== +-- 9. fishing_leases (ve_pachten) +-- ===================================================== + +CREATE TABLE IF NOT EXISTS public.fishing_leases ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + account_id uuid NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE, + water_id uuid NOT NULL REFERENCES public.waters(id) ON DELETE CASCADE, + + -- Lessor + lessor_name text NOT NULL, + lessor_address text, + lessor_phone text, + lessor_email text, + + -- Lease terms + start_date date NOT NULL, + end_date date, + duration_years integer, + initial_amount numeric(10,2) NOT NULL DEFAULT 0, + fixed_annual_increase numeric(10,2) DEFAULT 0, + percentage_annual_increase numeric(5,2) DEFAULT 0, + + -- Payment + payment_method public.lease_payment_method DEFAULT 'ueberweisung', + account_holder text, + iban text, + bic text, + + -- Location details + location_details text, + special_agreements text, + + -- Status + is_archived boolean NOT NULL DEFAULT false, + + -- Meta + created_by uuid REFERENCES auth.users(id) ON DELETE SET NULL, + updated_by uuid REFERENCES auth.users(id) ON DELETE SET NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX ix_fishing_leases_account ON public.fishing_leases(account_id); +CREATE INDEX ix_fishing_leases_water ON public.fishing_leases(water_id); +CREATE INDEX ix_fishing_leases_dates ON public.fishing_leases(start_date, end_date); + +ALTER TABLE public.fishing_leases ENABLE ROW LEVEL SECURITY; +REVOKE ALL ON public.fishing_leases FROM authenticated, service_role; +GRANT SELECT, INSERT, UPDATE, DELETE ON public.fishing_leases TO authenticated; +GRANT ALL ON public.fishing_leases TO service_role; + +CREATE POLICY fishing_leases_select ON public.fishing_leases FOR SELECT TO authenticated + USING (public.has_role_on_account(account_id)); +CREATE POLICY fishing_leases_insert ON public.fishing_leases FOR INSERT TO authenticated + WITH CHECK (public.has_permission(auth.uid(), account_id, 'fischerei.write'::public.app_permissions)); +CREATE POLICY fishing_leases_update ON public.fishing_leases FOR UPDATE TO authenticated + USING (public.has_permission(auth.uid(), account_id, 'fischerei.write'::public.app_permissions)); +CREATE POLICY fishing_leases_delete ON public.fishing_leases FOR DELETE TO authenticated + USING (public.has_permission(auth.uid(), account_id, 'fischerei.write'::public.app_permissions)); + +CREATE TRIGGER trg_fishing_leases_updated_at + BEFORE UPDATE ON public.fishing_leases + FOR EACH ROW EXECUTE FUNCTION public.update_account_settings_timestamp(); + +-- ===================================================== +-- 10. fishing_permits (ve_gewaesserkarten) +-- ===================================================== + +CREATE TABLE IF NOT EXISTS public.fishing_permits ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + account_id uuid NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE, + name text NOT NULL, + short_code text, + primary_water_id uuid REFERENCES public.waters(id) ON DELETE SET NULL, + total_quantity integer, + cost_center_id uuid REFERENCES public.cost_centers(id) ON DELETE SET NULL, + hejfish_id text, + is_for_sale boolean NOT NULL DEFAULT true, + is_archived boolean NOT NULL DEFAULT false, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX ix_fishing_permits_account ON public.fishing_permits(account_id); +ALTER TABLE public.fishing_permits ENABLE ROW LEVEL SECURITY; +REVOKE ALL ON public.fishing_permits FROM authenticated, service_role; +GRANT SELECT, INSERT, UPDATE, DELETE ON public.fishing_permits TO authenticated; +GRANT ALL ON public.fishing_permits TO service_role; + +CREATE POLICY fishing_permits_select ON public.fishing_permits FOR SELECT TO authenticated + USING (public.has_role_on_account(account_id)); +CREATE POLICY fishing_permits_mutate ON public.fishing_permits FOR ALL TO authenticated + USING (public.has_permission(auth.uid(), account_id, 'fischerei.write'::public.app_permissions)); + +CREATE TRIGGER trg_fishing_permits_updated_at + BEFORE UPDATE ON public.fishing_permits + FOR EACH ROW EXECUTE FUNCTION public.update_account_settings_timestamp(); + +-- ===================================================== +-- 11. permit_quotas (ve_gewaesserk_kontingent) +-- ===================================================== + +CREATE TABLE IF NOT EXISTS public.permit_quotas ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + permit_id uuid NOT NULL REFERENCES public.fishing_permits(id) ON DELETE CASCADE, + business_year integer NOT NULL, + category_name text, + quota_quantity integer NOT NULL DEFAULT 0, + conversion_factor numeric(5,2) DEFAULT 1.00, + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX ix_permit_quotas_permit ON public.permit_quotas(permit_id); +ALTER TABLE public.permit_quotas ENABLE ROW LEVEL SECURITY; +REVOKE ALL ON public.permit_quotas FROM authenticated, service_role; +GRANT SELECT, INSERT, UPDATE, DELETE ON public.permit_quotas TO authenticated; +GRANT ALL ON public.permit_quotas TO service_role; + +CREATE POLICY permit_quotas_select ON public.permit_quotas FOR SELECT TO authenticated + USING (EXISTS (SELECT 1 FROM public.fishing_permits p WHERE p.id = permit_quotas.permit_id AND public.has_role_on_account(p.account_id))); +CREATE POLICY permit_quotas_mutate ON public.permit_quotas FOR ALL TO authenticated + USING (EXISTS (SELECT 1 FROM public.fishing_permits p WHERE p.id = permit_quotas.permit_id AND public.has_permission(auth.uid(), p.account_id, 'fischerei.write'::public.app_permissions))); + +-- ===================================================== +-- 12. catch_books (ve_mitglieder_fangbuch) +-- ===================================================== + +CREATE TABLE IF NOT EXISTS public.catch_books ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + account_id uuid NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE, + member_id uuid NOT NULL REFERENCES public.members(id) ON DELETE CASCADE, + year integer NOT NULL, + + -- Denormalized for reporting + member_name text, + member_birth_date date, + + -- Usage + fishing_days_count integer NOT NULL DEFAULT 0, + total_fish_caught integer NOT NULL DEFAULT 0, + + -- Associated permits + card_numbers text, + is_fly_fisher boolean NOT NULL DEFAULT false, + + -- Verification workflow + status public.catch_book_status NOT NULL DEFAULT 'offen', + verification public.catch_book_verification, + is_checked boolean NOT NULL DEFAULT false, + is_submitted boolean NOT NULL DEFAULT false, + submitted_at timestamptz, + + -- HejFish + is_hejfish boolean NOT NULL DEFAULT false, + is_empty boolean NOT NULL DEFAULT false, + not_fished boolean NOT NULL DEFAULT false, + + remarks text, + + -- Meta + created_by uuid REFERENCES auth.users(id) ON DELETE SET NULL, + updated_by uuid REFERENCES auth.users(id) ON DELETE SET NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + + UNIQUE(account_id, member_id, year) +); + +CREATE INDEX ix_catch_books_account ON public.catch_books(account_id); +CREATE INDEX ix_catch_books_member ON public.catch_books(member_id); +CREATE INDEX ix_catch_books_year ON public.catch_books(account_id, year); +CREATE INDEX ix_catch_books_status ON public.catch_books(account_id, status); + +ALTER TABLE public.catch_books ENABLE ROW LEVEL SECURITY; +REVOKE ALL ON public.catch_books FROM authenticated, service_role; +GRANT SELECT, INSERT, UPDATE, DELETE ON public.catch_books TO authenticated; +GRANT ALL ON public.catch_books TO service_role; + +CREATE POLICY catch_books_select ON public.catch_books FOR SELECT TO authenticated + USING (public.has_role_on_account(account_id)); +CREATE POLICY catch_books_insert ON public.catch_books FOR INSERT TO authenticated + WITH CHECK (public.has_permission(auth.uid(), account_id, 'fischerei.write'::public.app_permissions)); +CREATE POLICY catch_books_update ON public.catch_books FOR UPDATE TO authenticated + USING (public.has_permission(auth.uid(), account_id, 'fischerei.write'::public.app_permissions)); +CREATE POLICY catch_books_delete ON public.catch_books FOR DELETE TO authenticated + USING (public.has_permission(auth.uid(), account_id, 'fischerei.write'::public.app_permissions)); + +CREATE TRIGGER trg_catch_books_updated_at + BEFORE UPDATE ON public.catch_books + FOR EACH ROW EXECUTE FUNCTION public.update_account_settings_timestamp(); + +-- ===================================================== +-- 13. catches (ve_faenge) +-- ===================================================== + +CREATE TABLE IF NOT EXISTS public.catches ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + catch_book_id uuid NOT NULL REFERENCES public.catch_books(id) ON DELETE CASCADE, + species_id uuid NOT NULL REFERENCES public.fish_species(id) ON DELETE RESTRICT, + water_id uuid REFERENCES public.waters(id) ON DELETE SET NULL, + member_id uuid REFERENCES public.members(id) ON DELETE SET NULL, + + catch_date date NOT NULL, + quantity integer NOT NULL DEFAULT 1, + length_cm numeric(5,1), + weight_g numeric(8,0), + + -- Size category + size_category public.fish_size_category, + gender public.fish_gender, + + -- Flags + is_empty_entry boolean NOT NULL DEFAULT false, + has_error boolean NOT NULL DEFAULT false, + + -- HejFish + hejfish_id text, + + -- Competition link + competition_id uuid, + competition_participant_id uuid, + + -- Permit + permit_id uuid REFERENCES public.fishing_permits(id) ON DELETE SET NULL, + + remarks text, + created_at timestamptz NOT NULL DEFAULT now() +); + +-- K-factor computed: weight_g / (length_cm^3) * 100000 +-- This is done application-side, not as a generated column, because +-- it requires both non-null values and the formula is specific to fisheries. + +CREATE INDEX ix_catches_catch_book ON public.catches(catch_book_id); +CREATE INDEX ix_catches_species ON public.catches(species_id); +CREATE INDEX ix_catches_water ON public.catches(water_id); +CREATE INDEX ix_catches_member ON public.catches(member_id); +CREATE INDEX ix_catches_date ON public.catches(catch_date DESC); + +ALTER TABLE public.catches ENABLE ROW LEVEL SECURITY; +REVOKE ALL ON public.catches FROM authenticated, service_role; +GRANT SELECT, INSERT, UPDATE, DELETE ON public.catches TO authenticated; +GRANT ALL ON public.catches TO service_role; + +CREATE POLICY catches_select ON public.catches FOR SELECT TO authenticated + USING (EXISTS (SELECT 1 FROM public.catch_books cb WHERE cb.id = catches.catch_book_id AND public.has_role_on_account(cb.account_id))); +CREATE POLICY catches_mutate ON public.catches FOR ALL TO authenticated + USING (EXISTS (SELECT 1 FROM public.catch_books cb WHERE cb.id = catches.catch_book_id AND public.has_permission(auth.uid(), cb.account_id, 'fischerei.write'::public.app_permissions))); + +-- ===================================================== +-- 14. water_inspectors (ve_kontrolleur_gewaesser) +-- ===================================================== + +CREATE TABLE IF NOT EXISTS public.water_inspectors ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + account_id uuid NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE, + water_id uuid NOT NULL REFERENCES public.waters(id) ON DELETE CASCADE, + member_id uuid NOT NULL REFERENCES public.members(id) ON DELETE CASCADE, + assignment_start date NOT NULL DEFAULT current_date, + assignment_end date, + created_at timestamptz NOT NULL DEFAULT now(), + UNIQUE(water_id, member_id) +); + +CREATE INDEX ix_water_inspectors_account ON public.water_inspectors(account_id); +CREATE INDEX ix_water_inspectors_water ON public.water_inspectors(water_id); +CREATE INDEX ix_water_inspectors_member ON public.water_inspectors(member_id); + +ALTER TABLE public.water_inspectors ENABLE ROW LEVEL SECURITY; +REVOKE ALL ON public.water_inspectors FROM authenticated, service_role; +GRANT SELECT, INSERT, UPDATE, DELETE ON public.water_inspectors TO authenticated; +GRANT ALL ON public.water_inspectors TO service_role; + +CREATE POLICY water_inspectors_select ON public.water_inspectors FOR SELECT TO authenticated + USING (public.has_role_on_account(account_id)); +CREATE POLICY water_inspectors_mutate ON public.water_inspectors FOR ALL TO authenticated + USING (public.has_permission(auth.uid(), account_id, 'fischerei.write'::public.app_permissions)); + +-- ===================================================== +-- 15. competition_categories (ve_fanglisten_rubriken) +-- ===================================================== + +CREATE TABLE IF NOT EXISTS public.competition_categories ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + account_id uuid NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE, + name text NOT NULL, + sort_order integer NOT NULL DEFAULT 0, + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX ix_competition_categories_account ON public.competition_categories(account_id); +ALTER TABLE public.competition_categories ENABLE ROW LEVEL SECURITY; +REVOKE ALL ON public.competition_categories FROM authenticated, service_role; +GRANT SELECT, INSERT, UPDATE, DELETE ON public.competition_categories TO authenticated; +GRANT ALL ON public.competition_categories TO service_role; + +CREATE POLICY competition_categories_select ON public.competition_categories FOR SELECT TO authenticated + USING (public.has_role_on_account(account_id)); +CREATE POLICY competition_categories_mutate ON public.competition_categories FOR ALL TO authenticated + USING (public.has_permission(auth.uid(), account_id, 'fischerei.write'::public.app_permissions)); + +-- ===================================================== +-- 16. competitions (ve_fanglisten) +-- ===================================================== + +CREATE TABLE IF NOT EXISTS public.competitions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + account_id uuid NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE, + event_id uuid REFERENCES public.events(id) ON DELETE SET NULL, + name text NOT NULL, + competition_date date NOT NULL, + permit_id uuid REFERENCES public.fishing_permits(id) ON DELETE SET NULL, + water_id uuid REFERENCES public.waters(id) ON DELETE SET NULL, + max_participants integer, + + -- Scoring flags + score_by_count boolean NOT NULL DEFAULT false, + score_by_heaviest boolean NOT NULL DEFAULT false, + score_by_total_weight boolean NOT NULL DEFAULT true, + score_by_longest boolean NOT NULL DEFAULT false, + score_by_total_length boolean NOT NULL DEFAULT false, + + -- Separation + separate_member_guest_scoring boolean NOT NULL DEFAULT false, + + -- Result counts + result_count_weight integer DEFAULT 3, + result_count_length integer DEFAULT 3, + result_count_count integer DEFAULT 3, + + -- Meta + created_by uuid REFERENCES auth.users(id) ON DELETE SET NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX ix_competitions_account ON public.competitions(account_id); +CREATE INDEX ix_competitions_date ON public.competitions(competition_date DESC); + +ALTER TABLE public.competitions ENABLE ROW LEVEL SECURITY; +REVOKE ALL ON public.competitions FROM authenticated, service_role; +GRANT SELECT, INSERT, UPDATE, DELETE ON public.competitions TO authenticated; +GRANT ALL ON public.competitions TO service_role; + +CREATE POLICY competitions_select ON public.competitions FOR SELECT TO authenticated + USING (public.has_role_on_account(account_id)); +CREATE POLICY competitions_insert ON public.competitions FOR INSERT TO authenticated + WITH CHECK (public.has_permission(auth.uid(), account_id, 'fischerei.write'::public.app_permissions)); +CREATE POLICY competitions_update ON public.competitions FOR UPDATE TO authenticated + USING (public.has_permission(auth.uid(), account_id, 'fischerei.write'::public.app_permissions)); +CREATE POLICY competitions_delete ON public.competitions FOR DELETE TO authenticated + USING (public.has_permission(auth.uid(), account_id, 'fischerei.write'::public.app_permissions)); + +CREATE TRIGGER trg_competitions_updated_at + BEFORE UPDATE ON public.competitions + FOR EACH ROW EXECUTE FUNCTION public.update_account_settings_timestamp(); + +-- ===================================================== +-- 17. competition_participants (ve_fanglisten_teilnehmer) +-- ===================================================== + +CREATE TABLE IF NOT EXISTS public.competition_participants ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + competition_id uuid NOT NULL REFERENCES public.competitions(id) ON DELETE CASCADE, + member_id uuid REFERENCES public.members(id) ON DELETE SET NULL, + category_id uuid REFERENCES public.competition_categories(id) ON DELETE SET NULL, + + -- Guest/external participant info + participant_name text NOT NULL, + birth_date date, + address text, + phone text, + email text, + + -- Status + participated boolean NOT NULL DEFAULT false, + + -- Results (computed from catches) + total_catch_count integer NOT NULL DEFAULT 0, + total_weight_g integer NOT NULL DEFAULT 0, + total_length_cm numeric(8,1) NOT NULL DEFAULT 0, + heaviest_catch_g integer NOT NULL DEFAULT 0, + longest_catch_cm numeric(5,1) NOT NULL DEFAULT 0, + + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX ix_competition_participants_competition ON public.competition_participants(competition_id); +CREATE INDEX ix_competition_participants_member ON public.competition_participants(member_id); + +ALTER TABLE public.competition_participants ENABLE ROW LEVEL SECURITY; +REVOKE ALL ON public.competition_participants FROM authenticated, service_role; +GRANT SELECT, INSERT, UPDATE, DELETE ON public.competition_participants TO authenticated; +GRANT ALL ON public.competition_participants TO service_role; + +CREATE POLICY competition_participants_select ON public.competition_participants FOR SELECT TO authenticated + USING (EXISTS (SELECT 1 FROM public.competitions c WHERE c.id = competition_participants.competition_id AND public.has_role_on_account(c.account_id))); +CREATE POLICY competition_participants_mutate ON public.competition_participants FOR ALL TO authenticated + USING (EXISTS (SELECT 1 FROM public.competitions c WHERE c.id = competition_participants.competition_id AND public.has_permission(auth.uid(), c.account_id, 'fischerei.write'::public.app_permissions))); + +-- Add FK from catches to competitions +ALTER TABLE public.catches + ADD CONSTRAINT fk_catches_competition FOREIGN KEY (competition_id) REFERENCES public.competitions(id) ON DELETE SET NULL; +ALTER TABLE public.catches + ADD CONSTRAINT fk_catches_competition_participant FOREIGN KEY (competition_participant_id) REFERENCES public.competition_participants(id) ON DELETE SET NULL; + +-- ===================================================== +-- 18. hejfish_sync (integration state tracking) +-- ===================================================== + +CREATE TABLE IF NOT EXISTS public.hejfish_sync ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + account_id uuid NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE, + entity_type text NOT NULL, -- 'water', 'species', 'member', 'catch' + local_id uuid NOT NULL, + hejfish_id text NOT NULL, + last_synced_at timestamptz NOT NULL DEFAULT now(), + sync_data jsonb, + UNIQUE(account_id, entity_type, local_id) +); + +CREATE INDEX ix_hejfish_sync_account ON public.hejfish_sync(account_id); +CREATE INDEX ix_hejfish_sync_entity ON public.hejfish_sync(entity_type, local_id); + +ALTER TABLE public.hejfish_sync ENABLE ROW LEVEL SECURITY; +REVOKE ALL ON public.hejfish_sync FROM authenticated, service_role; +GRANT SELECT, INSERT, UPDATE, DELETE ON public.hejfish_sync TO authenticated; +GRANT ALL ON public.hejfish_sync TO service_role; + +CREATE POLICY hejfish_sync_select ON public.hejfish_sync FOR SELECT TO authenticated + USING (public.has_role_on_account(account_id)); +CREATE POLICY hejfish_sync_mutate ON public.hejfish_sync FOR ALL TO authenticated + USING (public.has_permission(auth.uid(), account_id, 'fischerei.write'::public.app_permissions)); + +-- ===================================================== +-- 19. Aggregate function for catch statistics +-- ===================================================== + +CREATE OR REPLACE FUNCTION public.get_catch_statistics( + p_account_id uuid, + p_year integer DEFAULT NULL, + p_water_id uuid DEFAULT NULL +) +RETURNS TABLE( + species_id uuid, + species_name text, + total_count bigint, + total_weight_kg numeric, + avg_length_cm numeric, + avg_weight_g numeric, + avg_k_factor numeric +) +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +BEGIN + RETURN QUERY + SELECT + c.species_id, + fs.name AS species_name, + COUNT(*)::bigint AS total_count, + ROUND(COALESCE(SUM(c.weight_g) / 1000.0, 0), 2) AS total_weight_kg, + ROUND(AVG(c.length_cm), 1) AS avg_length_cm, + ROUND(AVG(c.weight_g), 0) AS avg_weight_g, + ROUND(AVG( + CASE WHEN c.length_cm > 0 AND c.weight_g > 0 + THEN c.weight_g / POWER(c.length_cm, 3) * 100000 + ELSE NULL END + ), 3) AS avg_k_factor + FROM public.catches c + JOIN public.catch_books cb ON cb.id = c.catch_book_id + JOIN public.fish_species fs ON fs.id = c.species_id + WHERE cb.account_id = p_account_id + AND (p_year IS NULL OR cb.year = p_year) + AND (p_water_id IS NULL OR c.water_id = p_water_id) + AND c.is_empty_entry = false + GROUP BY c.species_id, fs.name + ORDER BY total_count DESC; +END; +$$; + +GRANT EXECUTE ON FUNCTION public.get_catch_statistics(uuid, integer, uuid) + TO authenticated, service_role; + +-- ===================================================== +-- 20. Function to compute lease amount for a given year +-- ===================================================== + +CREATE OR REPLACE FUNCTION public.compute_lease_amount( + p_initial_amount numeric, + p_fixed_increase numeric, + p_percentage_increase numeric, + p_start_year integer, + p_target_year integer +) +RETURNS numeric +LANGUAGE plpgsql +IMMUTABLE +AS $$ +DECLARE + v_amount numeric; + v_year_offset integer; +BEGIN + v_year_offset := p_target_year - p_start_year; + IF v_year_offset <= 0 THEN + RETURN p_initial_amount; + END IF; + + IF p_percentage_increase > 0 THEN + v_amount := p_initial_amount * POWER(1 + p_percentage_increase / 100.0, v_year_offset); + ELSE + v_amount := p_initial_amount + (p_fixed_increase * v_year_offset); + END IF; + + RETURN ROUND(v_amount, 2); +END; +$$; + +GRANT EXECUTE ON FUNCTION public.compute_lease_amount(numeric, numeric, numeric, integer, integer) + TO authenticated, service_role; diff --git a/apps/web/supabase/migrations/20260412000002_fischerei_permission_seeds.sql b/apps/web/supabase/migrations/20260412000002_fischerei_permission_seeds.sql new file mode 100644 index 000000000..9d9741c69 --- /dev/null +++ b/apps/web/supabase/migrations/20260412000002_fischerei_permission_seeds.sql @@ -0,0 +1,7 @@ +-- Seed fischerei permissions for existing roles + +INSERT INTO public.role_permissions (role, permission) VALUES + ('owner', 'fischerei.read'), + ('owner', 'fischerei.write'), + ('member', 'fischerei.read') +ON CONFLICT (role, permission) DO NOTHING; diff --git a/apps/web/supabase/migrations/20260413000001_sitzungsprotokolle.sql b/apps/web/supabase/migrations/20260413000001_sitzungsprotokolle.sql new file mode 100644 index 000000000..558501a86 --- /dev/null +++ b/apps/web/supabase/migrations/20260413000001_sitzungsprotokolle.sql @@ -0,0 +1,224 @@ +/* + * ------------------------------------------------------- + * Sitzungsprotokolle (Meeting Protocols) Schema + * Meeting minutes, agenda items, tasks, attachments + * ------------------------------------------------------- + */ + +-- ===================================================== +-- 1. Extend app_permissions +-- ===================================================== + +ALTER TYPE public.app_permissions ADD VALUE IF NOT EXISTS 'meetings.read'; +ALTER TYPE public.app_permissions ADD VALUE IF NOT EXISTS 'meetings.write'; + +-- ===================================================== +-- 2. Enums +-- ===================================================== + +CREATE TYPE public.meeting_item_status AS ENUM( + 'offen', + 'in_bearbeitung', + 'erledigt', + 'vertagt' +); + +-- ===================================================== +-- 3. meeting_protocols +-- ===================================================== + +CREATE TABLE IF NOT EXISTS public.meeting_protocols ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + account_id uuid NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE, + + -- Identity + title text NOT NULL, + protocol_number text, + + -- Schedule + meeting_date date NOT NULL, + meeting_time time, + end_time time, + location text, + + -- Participants + chair text, + recorder text, + attendees jsonb NOT NULL DEFAULT '[]'::jsonb, + absent jsonb NOT NULL DEFAULT '[]'::jsonb, + + -- Content + summary text, + + -- Status workflow + status text NOT NULL DEFAULT 'draft' + CHECK (status IN ('draft', 'review', 'approved', 'archived')), + + -- Follow-up + next_meeting_date date, + + -- Flags + is_archived boolean NOT NULL DEFAULT false, + + -- Meta + created_by uuid REFERENCES auth.users(id) ON DELETE SET NULL, + updated_by uuid REFERENCES auth.users(id) ON DELETE SET NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX ix_meeting_protocols_account ON public.meeting_protocols(account_id); +CREATE INDEX ix_meeting_protocols_date ON public.meeting_protocols(account_id, meeting_date DESC); +CREATE INDEX ix_meeting_protocols_status ON public.meeting_protocols(account_id, status); +CREATE INDEX ix_meeting_protocols_archived ON public.meeting_protocols(account_id, is_archived); + +ALTER TABLE public.meeting_protocols ENABLE ROW LEVEL SECURITY; +REVOKE ALL ON public.meeting_protocols FROM authenticated, service_role; +GRANT SELECT, INSERT, UPDATE, DELETE ON public.meeting_protocols TO authenticated; +GRANT ALL ON public.meeting_protocols TO service_role; + +CREATE POLICY meeting_protocols_select ON public.meeting_protocols FOR SELECT TO authenticated + USING (public.has_role_on_account(account_id)); +CREATE POLICY meeting_protocols_insert ON public.meeting_protocols FOR INSERT TO authenticated + WITH CHECK (public.has_permission(auth.uid(), account_id, 'meetings.write'::public.app_permissions)); +CREATE POLICY meeting_protocols_update ON public.meeting_protocols FOR UPDATE TO authenticated + USING (public.has_permission(auth.uid(), account_id, 'meetings.write'::public.app_permissions)); +CREATE POLICY meeting_protocols_delete ON public.meeting_protocols FOR DELETE TO authenticated + USING (public.has_permission(auth.uid(), account_id, 'meetings.write'::public.app_permissions)); + +CREATE TRIGGER trg_meeting_protocols_updated_at + BEFORE UPDATE ON public.meeting_protocols + FOR EACH ROW EXECUTE FUNCTION public.update_account_settings_timestamp(); + +-- ===================================================== +-- 4. meeting_protocol_items (agenda items / TOPs) +-- ===================================================== + +CREATE TABLE IF NOT EXISTS public.meeting_protocol_items ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + protocol_id uuid NOT NULL REFERENCES public.meeting_protocols(id) ON DELETE CASCADE, + + -- Ordering + item_number integer NOT NULL DEFAULT 0, + sort_order integer NOT NULL DEFAULT 0, + + -- Content + title text NOT NULL, + content text, + + -- Classification + item_type text NOT NULL DEFAULT 'information' + CHECK (item_type IN ('information', 'discussion', 'decision', 'task')), + + -- Decision tracking + decision_text text, + + -- Task tracking + status public.meeting_item_status NOT NULL DEFAULT 'offen', + responsible_person text, + due_date date, + + -- Meta + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX ix_meeting_protocol_items_protocol ON public.meeting_protocol_items(protocol_id); +CREATE INDEX ix_meeting_protocol_items_status ON public.meeting_protocol_items(status); +CREATE INDEX ix_meeting_protocol_items_type ON public.meeting_protocol_items(item_type); +CREATE INDEX ix_meeting_protocol_items_due ON public.meeting_protocol_items(due_date) + WHERE due_date IS NOT NULL AND status != 'erledigt'; + +ALTER TABLE public.meeting_protocol_items ENABLE ROW LEVEL SECURITY; +REVOKE ALL ON public.meeting_protocol_items FROM authenticated, service_role; +GRANT SELECT, INSERT, UPDATE, DELETE ON public.meeting_protocol_items TO authenticated; +GRANT ALL ON public.meeting_protocol_items TO service_role; + +CREATE POLICY meeting_protocol_items_select ON public.meeting_protocol_items FOR SELECT TO authenticated + USING (EXISTS ( + SELECT 1 FROM public.meeting_protocols mp + WHERE mp.id = meeting_protocol_items.protocol_id + AND public.has_role_on_account(mp.account_id) + )); +CREATE POLICY meeting_protocol_items_mutate ON public.meeting_protocol_items FOR ALL TO authenticated + USING (EXISTS ( + SELECT 1 FROM public.meeting_protocols mp + WHERE mp.id = meeting_protocol_items.protocol_id + AND public.has_permission(auth.uid(), mp.account_id, 'meetings.write'::public.app_permissions) + )); + +CREATE TRIGGER trg_meeting_protocol_items_updated_at + BEFORE UPDATE ON public.meeting_protocol_items + FOR EACH ROW EXECUTE FUNCTION public.update_account_settings_timestamp(); + +-- ===================================================== +-- 5. meeting_protocol_attachments +-- ===================================================== + +CREATE TABLE IF NOT EXISTS public.meeting_protocol_attachments ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + protocol_id uuid NOT NULL REFERENCES public.meeting_protocols(id) ON DELETE CASCADE, + item_id uuid REFERENCES public.meeting_protocol_items(id) ON DELETE SET NULL, + + -- File info + file_name text NOT NULL, + file_path text NOT NULL, + file_size bigint, + content_type text, + + -- Meta + created_by uuid REFERENCES auth.users(id) ON DELETE SET NULL, + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX ix_meeting_protocol_attachments_protocol ON public.meeting_protocol_attachments(protocol_id); +CREATE INDEX ix_meeting_protocol_attachments_item ON public.meeting_protocol_attachments(item_id) + WHERE item_id IS NOT NULL; + +ALTER TABLE public.meeting_protocol_attachments ENABLE ROW LEVEL SECURITY; +REVOKE ALL ON public.meeting_protocol_attachments FROM authenticated, service_role; +GRANT SELECT, INSERT, UPDATE, DELETE ON public.meeting_protocol_attachments TO authenticated; +GRANT ALL ON public.meeting_protocol_attachments TO service_role; + +CREATE POLICY meeting_protocol_attachments_select ON public.meeting_protocol_attachments FOR SELECT TO authenticated + USING (EXISTS ( + SELECT 1 FROM public.meeting_protocols mp + WHERE mp.id = meeting_protocol_attachments.protocol_id + AND public.has_role_on_account(mp.account_id) + )); +CREATE POLICY meeting_protocol_attachments_mutate ON public.meeting_protocol_attachments FOR ALL TO authenticated + USING (EXISTS ( + SELECT 1 FROM public.meeting_protocols mp + WHERE mp.id = meeting_protocol_attachments.protocol_id + AND public.has_permission(auth.uid(), mp.account_id, 'meetings.write'::public.app_permissions) + )); + +-- ===================================================== +-- 6. View: open_meeting_tasks +-- ===================================================== + +CREATE OR REPLACE VIEW public.open_meeting_tasks AS +SELECT + mpi.id AS item_id, + mpi.protocol_id, + mp.account_id, + mp.title AS protocol_title, + mp.meeting_date, + mpi.item_number, + mpi.title AS task_title, + mpi.content AS task_description, + mpi.responsible_person, + mpi.due_date, + mpi.status, + CASE + WHEN mpi.due_date < current_date AND mpi.status != 'erledigt' THEN true + ELSE false + END AS is_overdue +FROM public.meeting_protocol_items mpi +JOIN public.meeting_protocols mp ON mp.id = mpi.protocol_id +WHERE mpi.item_type = 'task' + AND mpi.status != 'erledigt'; + +-- Grant access to the view (RLS on underlying tables still applies) +GRANT SELECT ON public.open_meeting_tasks TO authenticated; +GRANT SELECT ON public.open_meeting_tasks TO service_role; diff --git a/apps/web/supabase/migrations/20260413000002_verbandsverwaltung.sql b/apps/web/supabase/migrations/20260413000002_verbandsverwaltung.sql new file mode 100644 index 000000000..e41624039 --- /dev/null +++ b/apps/web/supabase/migrations/20260413000002_verbandsverwaltung.sql @@ -0,0 +1,377 @@ +/* + * ------------------------------------------------------- + * Verbandsverwaltung (Association Management) Schema + * Association types, member clubs, contacts, fees, + * billing, notes, history + * ------------------------------------------------------- + */ + +-- ===================================================== +-- 1. Extend app_permissions +-- ===================================================== + +ALTER TYPE public.app_permissions ADD VALUE IF NOT EXISTS 'verband.read'; +ALTER TYPE public.app_permissions ADD VALUE IF NOT EXISTS 'verband.write'; + +-- ===================================================== +-- 2. association_types (Verbandsarten) +-- ===================================================== + +CREATE TABLE IF NOT EXISTS public.association_types ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + account_id uuid NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE, + name text NOT NULL, + description text, + sort_order integer NOT NULL DEFAULT 0, + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX ix_association_types_account ON public.association_types(account_id); + +ALTER TABLE public.association_types ENABLE ROW LEVEL SECURITY; +REVOKE ALL ON public.association_types FROM authenticated, service_role; +GRANT SELECT, INSERT, UPDATE, DELETE ON public.association_types TO authenticated; +GRANT ALL ON public.association_types TO service_role; + +CREATE POLICY association_types_select ON public.association_types FOR SELECT TO authenticated + USING (public.has_role_on_account(account_id)); +CREATE POLICY association_types_mutate ON public.association_types FOR ALL TO authenticated + USING (public.has_permission(auth.uid(), account_id, 'verband.write'::public.app_permissions)); + +-- ===================================================== +-- 3. member_clubs (Mitgliedsvereine) +-- ===================================================== + +CREATE TABLE IF NOT EXISTS public.member_clubs ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + account_id uuid NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE, + + -- Identity + name text NOT NULL, + short_name text, + club_number text, + association_type_id uuid REFERENCES public.association_types(id) ON DELETE SET NULL, + + -- Contact + address_street text, + address_zip text, + address_city text, + phone text, + email text, + website text, + + -- Bank details + iban text, + bic text, + account_holder text, + + -- Stats + member_count integer NOT NULL DEFAULT 0, + youth_count integer NOT NULL DEFAULT 0, + founding_year integer, + + -- Flags + is_active boolean NOT NULL DEFAULT true, + is_archived boolean NOT NULL DEFAULT false, + + -- Meta + notes text, + custom_data jsonb NOT NULL DEFAULT '{}'::jsonb, + created_by uuid REFERENCES auth.users(id) ON DELETE SET NULL, + updated_by uuid REFERENCES auth.users(id) ON DELETE SET NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX ix_member_clubs_account ON public.member_clubs(account_id); +CREATE INDEX ix_member_clubs_name ON public.member_clubs(account_id, name); +CREATE INDEX ix_member_clubs_type ON public.member_clubs(account_id, association_type_id); +CREATE INDEX ix_member_clubs_active ON public.member_clubs(account_id, is_active); +CREATE INDEX ix_member_clubs_archived ON public.member_clubs(account_id, is_archived); + +ALTER TABLE public.member_clubs ENABLE ROW LEVEL SECURITY; +REVOKE ALL ON public.member_clubs FROM authenticated, service_role; +GRANT SELECT, INSERT, UPDATE, DELETE ON public.member_clubs TO authenticated; +GRANT ALL ON public.member_clubs TO service_role; + +CREATE POLICY member_clubs_select ON public.member_clubs FOR SELECT TO authenticated + USING (public.has_role_on_account(account_id)); +CREATE POLICY member_clubs_insert ON public.member_clubs FOR INSERT TO authenticated + WITH CHECK (public.has_permission(auth.uid(), account_id, 'verband.write'::public.app_permissions)); +CREATE POLICY member_clubs_update ON public.member_clubs FOR UPDATE TO authenticated + USING (public.has_permission(auth.uid(), account_id, 'verband.write'::public.app_permissions)); +CREATE POLICY member_clubs_delete ON public.member_clubs FOR DELETE TO authenticated + USING (public.has_permission(auth.uid(), account_id, 'verband.write'::public.app_permissions)); + +CREATE TRIGGER trg_member_clubs_updated_at + BEFORE UPDATE ON public.member_clubs + FOR EACH ROW EXECUTE FUNCTION public.update_account_settings_timestamp(); + +-- ===================================================== +-- 4. club_roles (Vereinsfunktionen) +-- ===================================================== + +CREATE TABLE IF NOT EXISTS public.club_roles ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + account_id uuid NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE, + name text NOT NULL, + description text, + sort_order integer NOT NULL DEFAULT 0, + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX ix_club_roles_account ON public.club_roles(account_id); + +ALTER TABLE public.club_roles ENABLE ROW LEVEL SECURITY; +REVOKE ALL ON public.club_roles FROM authenticated, service_role; +GRANT SELECT, INSERT, UPDATE, DELETE ON public.club_roles TO authenticated; +GRANT ALL ON public.club_roles TO service_role; + +CREATE POLICY club_roles_select ON public.club_roles FOR SELECT TO authenticated + USING (public.has_role_on_account(account_id)); +CREATE POLICY club_roles_mutate ON public.club_roles FOR ALL TO authenticated + USING (public.has_permission(auth.uid(), account_id, 'verband.write'::public.app_permissions)); + +-- ===================================================== +-- 5. club_contacts (Ansprechpartner) +-- ===================================================== + +CREATE TABLE IF NOT EXISTS public.club_contacts ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + club_id uuid NOT NULL REFERENCES public.member_clubs(id) ON DELETE CASCADE, + role_id uuid REFERENCES public.club_roles(id) ON DELETE SET NULL, + + -- Person info + first_name text NOT NULL, + last_name text NOT NULL, + email text, + phone text, + mobile text, + + -- Period + valid_from date, + valid_until date, + is_active boolean NOT NULL DEFAULT true, + + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX ix_club_contacts_club ON public.club_contacts(club_id); +CREATE INDEX ix_club_contacts_role ON public.club_contacts(role_id); + +ALTER TABLE public.club_contacts ENABLE ROW LEVEL SECURITY; +REVOKE ALL ON public.club_contacts FROM authenticated, service_role; +GRANT SELECT, INSERT, UPDATE, DELETE ON public.club_contacts TO authenticated; +GRANT ALL ON public.club_contacts TO service_role; + +CREATE POLICY club_contacts_select ON public.club_contacts FOR SELECT TO authenticated + USING (EXISTS ( + SELECT 1 FROM public.member_clubs mc + WHERE mc.id = club_contacts.club_id + AND public.has_role_on_account(mc.account_id) + )); +CREATE POLICY club_contacts_mutate ON public.club_contacts FOR ALL TO authenticated + USING (EXISTS ( + SELECT 1 FROM public.member_clubs mc + WHERE mc.id = club_contacts.club_id + AND public.has_permission(auth.uid(), mc.account_id, 'verband.write'::public.app_permissions) + )); + +CREATE TRIGGER trg_club_contacts_updated_at + BEFORE UPDATE ON public.club_contacts + FOR EACH ROW EXECUTE FUNCTION public.update_account_settings_timestamp(); + +-- ===================================================== +-- 6. club_fee_types (Beitragsarten) +-- ===================================================== + +CREATE TABLE IF NOT EXISTS public.club_fee_types ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + account_id uuid NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE, + name text NOT NULL, + description text, + default_amount numeric(10,2) NOT NULL DEFAULT 0, + is_per_member boolean NOT NULL DEFAULT false, + is_active boolean NOT NULL DEFAULT true, + sort_order integer NOT NULL DEFAULT 0, + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX ix_club_fee_types_account ON public.club_fee_types(account_id); + +ALTER TABLE public.club_fee_types ENABLE ROW LEVEL SECURITY; +REVOKE ALL ON public.club_fee_types FROM authenticated, service_role; +GRANT SELECT, INSERT, UPDATE, DELETE ON public.club_fee_types TO authenticated; +GRANT ALL ON public.club_fee_types TO service_role; + +CREATE POLICY club_fee_types_select ON public.club_fee_types FOR SELECT TO authenticated + USING (public.has_role_on_account(account_id)); +CREATE POLICY club_fee_types_mutate ON public.club_fee_types FOR ALL TO authenticated + USING (public.has_permission(auth.uid(), account_id, 'verband.write'::public.app_permissions)); + +-- ===================================================== +-- 7. club_fee_billings (Beitragsabrechnungen) +-- ===================================================== + +CREATE TABLE IF NOT EXISTS public.club_fee_billings ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + club_id uuid NOT NULL REFERENCES public.member_clubs(id) ON DELETE CASCADE, + fee_type_id uuid NOT NULL REFERENCES public.club_fee_types(id) ON DELETE RESTRICT, + + -- Billing period + billing_year integer NOT NULL, + billing_period text DEFAULT 'annual' + CHECK (billing_period IN ('annual', 'semi_annual', 'quarterly')), + + -- Amounts + amount numeric(10,2) NOT NULL DEFAULT 0, + member_count_at_billing integer, + + -- Payment tracking + status text NOT NULL DEFAULT 'open' + CHECK (status IN ('open', 'invoiced', 'paid', 'overdue', 'cancelled')), + invoice_number text, + invoice_date date, + paid_date date, + paid_amount numeric(10,2), + + remarks text, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX ix_club_fee_billings_club ON public.club_fee_billings(club_id); +CREATE INDEX ix_club_fee_billings_fee_type ON public.club_fee_billings(fee_type_id); +CREATE INDEX ix_club_fee_billings_year ON public.club_fee_billings(billing_year); +CREATE INDEX ix_club_fee_billings_status ON public.club_fee_billings(status); + +ALTER TABLE public.club_fee_billings ENABLE ROW LEVEL SECURITY; +REVOKE ALL ON public.club_fee_billings FROM authenticated, service_role; +GRANT SELECT, INSERT, UPDATE, DELETE ON public.club_fee_billings TO authenticated; +GRANT ALL ON public.club_fee_billings TO service_role; + +CREATE POLICY club_fee_billings_select ON public.club_fee_billings FOR SELECT TO authenticated + USING (EXISTS ( + SELECT 1 FROM public.member_clubs mc + WHERE mc.id = club_fee_billings.club_id + AND public.has_role_on_account(mc.account_id) + )); +CREATE POLICY club_fee_billings_mutate ON public.club_fee_billings FOR ALL TO authenticated + USING (EXISTS ( + SELECT 1 FROM public.member_clubs mc + WHERE mc.id = club_fee_billings.club_id + AND public.has_permission(auth.uid(), mc.account_id, 'verband.write'::public.app_permissions) + )); + +CREATE TRIGGER trg_club_fee_billings_updated_at + BEFORE UPDATE ON public.club_fee_billings + FOR EACH ROW EXECUTE FUNCTION public.update_account_settings_timestamp(); + +-- ===================================================== +-- 8. club_notes (Vereinsnotizen) +-- ===================================================== + +CREATE TABLE IF NOT EXISTS public.club_notes ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + club_id uuid NOT NULL REFERENCES public.member_clubs(id) ON DELETE CASCADE, + title text, + content text NOT NULL, + note_date date NOT NULL DEFAULT current_date, + created_by uuid REFERENCES auth.users(id) ON DELETE SET NULL, + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX ix_club_notes_club ON public.club_notes(club_id); +CREATE INDEX ix_club_notes_date ON public.club_notes(note_date DESC); + +ALTER TABLE public.club_notes ENABLE ROW LEVEL SECURITY; +REVOKE ALL ON public.club_notes FROM authenticated, service_role; +GRANT SELECT, INSERT, UPDATE, DELETE ON public.club_notes TO authenticated; +GRANT ALL ON public.club_notes TO service_role; + +CREATE POLICY club_notes_select ON public.club_notes FOR SELECT TO authenticated + USING (EXISTS ( + SELECT 1 FROM public.member_clubs mc + WHERE mc.id = club_notes.club_id + AND public.has_role_on_account(mc.account_id) + )); +CREATE POLICY club_notes_mutate ON public.club_notes FOR ALL TO authenticated + USING (EXISTS ( + SELECT 1 FROM public.member_clubs mc + WHERE mc.id = club_notes.club_id + AND public.has_permission(auth.uid(), mc.account_id, 'verband.write'::public.app_permissions) + )); + +-- ===================================================== +-- 9. association_history (Verbandschronik) +-- ===================================================== + +CREATE TABLE IF NOT EXISTS public.association_history ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + account_id uuid NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE, + club_id uuid REFERENCES public.member_clubs(id) ON DELETE SET NULL, + + -- Event + event_type text NOT NULL DEFAULT 'note' + CHECK (event_type IN ('joined', 'left', 'name_change', 'merge', 'split', 'status_change', 'note')), + event_date date NOT NULL DEFAULT current_date, + description text NOT NULL, + + -- Metadata + old_value text, + new_value text, + + created_by uuid REFERENCES auth.users(id) ON DELETE SET NULL, + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX ix_association_history_account ON public.association_history(account_id); +CREATE INDEX ix_association_history_club ON public.association_history(club_id); +CREATE INDEX ix_association_history_date ON public.association_history(event_date DESC); +CREATE INDEX ix_association_history_type ON public.association_history(event_type); + +ALTER TABLE public.association_history ENABLE ROW LEVEL SECURITY; +REVOKE ALL ON public.association_history FROM authenticated, service_role; +GRANT SELECT, INSERT, UPDATE, DELETE ON public.association_history TO authenticated; +GRANT ALL ON public.association_history TO service_role; + +CREATE POLICY association_history_select ON public.association_history FOR SELECT TO authenticated + USING (public.has_role_on_account(account_id)); +CREATE POLICY association_history_insert ON public.association_history FOR INSERT TO authenticated + WITH CHECK (public.has_permission(auth.uid(), account_id, 'verband.write'::public.app_permissions)); +CREATE POLICY association_history_update ON public.association_history FOR UPDATE TO authenticated + USING (public.has_permission(auth.uid(), account_id, 'verband.write'::public.app_permissions)); +CREATE POLICY association_history_delete ON public.association_history FOR DELETE TO authenticated + USING (public.has_permission(auth.uid(), account_id, 'verband.write'::public.app_permissions)); + +-- ===================================================== +-- 10. Dashboard stats RPC +-- ===================================================== + +CREATE OR REPLACE FUNCTION public.get_verband_dashboard_stats(p_account_id uuid) +RETURNS TABLE( + total_clubs bigint, + active_clubs bigint, + total_members bigint, + total_youth bigint, + open_fees bigint, + open_fees_amount numeric +) +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +BEGIN + RETURN QUERY + SELECT + (SELECT COUNT(*) FROM public.member_clubs WHERE account_id = p_account_id AND is_archived = false)::bigint AS total_clubs, + (SELECT COUNT(*) FROM public.member_clubs WHERE account_id = p_account_id AND is_active = true AND is_archived = false)::bigint AS active_clubs, + (SELECT COALESCE(SUM(member_count), 0) FROM public.member_clubs WHERE account_id = p_account_id AND is_archived = false)::bigint AS total_members, + (SELECT COALESCE(SUM(youth_count), 0) FROM public.member_clubs WHERE account_id = p_account_id AND is_archived = false)::bigint AS total_youth, + (SELECT COUNT(*) FROM public.club_fee_billings cfb JOIN public.member_clubs mc ON mc.id = cfb.club_id WHERE mc.account_id = p_account_id AND cfb.status IN ('open', 'overdue'))::bigint AS open_fees, + (SELECT COALESCE(SUM(cfb.amount), 0) FROM public.club_fee_billings cfb JOIN public.member_clubs mc ON mc.id = cfb.club_id WHERE mc.account_id = p_account_id AND cfb.status IN ('open', 'overdue'))::numeric AS open_fees_amount; +END; +$$; + +GRANT EXECUTE ON FUNCTION public.get_verband_dashboard_stats(uuid) TO authenticated, service_role; diff --git a/apps/web/supabase/migrations/20260413000003_universal_permission_seeds.sql b/apps/web/supabase/migrations/20260413000003_universal_permission_seeds.sql new file mode 100644 index 000000000..d277e8c4b --- /dev/null +++ b/apps/web/supabase/migrations/20260413000003_universal_permission_seeds.sql @@ -0,0 +1,10 @@ +-- Seed meetings + verband permissions for existing roles + +INSERT INTO public.role_permissions (role, permission) VALUES + ('owner', 'meetings.read'), + ('owner', 'meetings.write'), + ('member', 'meetings.read'), + ('owner', 'verband.read'), + ('owner', 'verband.write'), + ('member', 'verband.read') +ON CONFLICT (role, permission) DO NOTHING; diff --git a/docker-compose.yml b/docker-compose.yml index 68bef5333..5b180200f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,30 +1,65 @@ -version: '3.8' - -# MyEasyCMS v2 — Docker Compose for Dokploy deployment -# Supabase (self-hosted) + Next.js app +# MyEasyCMS v2 — Docker Compose for Production / Dokploy +# +# Equivalent to `supabase start` but portable for self-hosted deployment. +# For local development, use `pnpm supabase:web:start` instead. +# +# ⚠️ First deploy: `docker compose up -d` creates the DB from scratch with +# all Supabase roles/schemas via the image's built-in init scripts, then +# runs app migrations from the mounted volume. services: # ===================================================== - # Supabase Stack + # Supabase Postgres # ===================================================== - supabase-db: image: supabase/postgres:15.8.1.060 restart: unless-stopped volumes: - supabase-db-data:/var/lib/postgresql/data - - ./apps/web/supabase/migrations:/docker-entrypoint-initdb.d/migrations + # Post-migration hook: sets passwords on all Supabase roles AFTER + # migrate.sh creates them. 'zzz-' prefix ensures it runs last. + - ./docker/db/zzz-role-passwords.sh:/docker-entrypoint-initdb.d/zzz-role-passwords.sh:ro + # App migrations — mounted to a separate path (NOT /docker-entrypoint-initdb.d/migrations!) + - ./apps/web/supabase/migrations:/app-migrations:ro environment: - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-your-super-secret-password} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} POSTGRES_DB: postgres ports: - - "5432:5432" + - "${DB_PORT:-5432}:5432" healthcheck: - test: ["CMD-SHELL", "pg_isready -U postgres"] + test: ["CMD-SHELL", "pg_isready -U postgres -d postgres"] interval: 10s timeout: 5s - retries: 5 + retries: 10 + # Run app migrations, seed, and dev patches after DB is healthy + supabase-db-migrate: + image: supabase/postgres:15.8.1.060 + depends_on: + supabase-db: + condition: service_healthy + volumes: + - ./apps/web/supabase/migrations:/app-migrations:ro + - ./apps/web/supabase/seed.sql:/app-seed/seed.sql:ro + - ./docker/db/dev-bootstrap.sh:/app-seed/dev-bootstrap.sh:ro + environment: + PGPASSWORD: ${POSTGRES_PASSWORD} + entrypoint: ["/bin/sh", "-c"] + command: + - | + echo "Running app migrations..." + for sql in /app-migrations/*.sql; do + echo " → $$sql" + psql -h supabase-db -U supabase_admin -d postgres -v ON_ERROR_STOP=0 -f "$$sql" 2>&1 || true + done + echo "✅ App migrations complete." + echo "" + sh /app-seed/dev-bootstrap.sh + restart: "no" + + # ===================================================== + # Supabase Auth (GoTrue) + # ===================================================== supabase-auth: image: supabase/gotrue:v2.172.1 restart: unless-stopped @@ -36,7 +71,7 @@ services: GOTRUE_API_PORT: 9999 API_EXTERNAL_URL: ${API_EXTERNAL_URL:-http://localhost:8000} GOTRUE_DB_DRIVER: postgres - GOTRUE_DB_DATABASE_URL: postgres://postgres:${POSTGRES_PASSWORD:-your-super-secret-password}@supabase-db:5432/postgres?search_path=auth + GOTRUE_DB_DATABASE_URL: postgres://supabase_auth_admin:${POSTGRES_PASSWORD}@supabase-db:5432/postgres?search_path=auth GOTRUE_SITE_URL: ${SITE_URL:-https://myeasycms.de} GOTRUE_URI_ALLOW_LIST: ${ADDITIONAL_REDIRECT_URLS:-} GOTRUE_DISABLE_SIGNUP: ${DISABLE_SIGNUP:-false} @@ -44,7 +79,7 @@ services: GOTRUE_JWT_AUD: authenticated GOTRUE_JWT_DEFAULT_GROUP_NAME: authenticated GOTRUE_JWT_EXP: ${JWT_EXPIRY:-3600} - GOTRUE_JWT_SECRET: ${JWT_SECRET:-your-super-secret-jwt-token-with-at-least-32-characters} + GOTRUE_JWT_SECRET: ${JWT_SECRET} GOTRUE_EXTERNAL_EMAIL_ENABLED: true GOTRUE_MAILER_AUTOCONFIRM: ${ENABLE_EMAIL_AUTOCONFIRM:-false} GOTRUE_SMTP_HOST: ${SMTP_HOST:-} @@ -56,7 +91,15 @@ services: GOTRUE_MAILER_URLPATHS_CONFIRMATION: /auth/v1/verify GOTRUE_MAILER_URLPATHS_RECOVERY: /auth/v1/verify GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE: /auth/v1/verify + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:9999/health"] + interval: 10s + timeout: 5s + retries: 5 + # ===================================================== + # Supabase REST (PostgREST) + # ===================================================== supabase-rest: image: postgrest/postgrest:v12.2.8 restart: unless-stopped @@ -64,33 +107,155 @@ services: supabase-db: condition: service_healthy environment: - PGRST_DB_URI: postgres://authenticator:${POSTGRES_PASSWORD:-your-super-secret-password}@supabase-db:5432/postgres + PGRST_DB_URI: postgres://authenticator:${POSTGRES_PASSWORD}@supabase-db:5432/postgres PGRST_DB_SCHEMAS: public,storage,graphql_public PGRST_DB_ANON_ROLE: anon - PGRST_JWT_SECRET: ${JWT_SECRET:-your-super-secret-jwt-token-with-at-least-32-characters} + PGRST_JWT_SECRET: ${JWT_SECRET} PGRST_DB_USE_LEGACY_GUCS: "false" + healthcheck: + test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:3000/ || exit 1"] + interval: 10s + timeout: 5s + retries: 5 + # ===================================================== + # Supabase Realtime + # ===================================================== + supabase-realtime: + image: supabase/realtime:v2.33.70 + restart: unless-stopped + depends_on: + supabase-db: + condition: service_healthy + environment: + PORT: 4000 + DB_HOST: supabase-db + DB_PORT: 5432 + DB_USER: supabase_admin + DB_PASSWORD: ${POSTGRES_PASSWORD} + DB_NAME: postgres + DB_AFTER_CONNECT_QUERY: "SET search_path TO _realtime" + DB_ENC_KEY: supabaserealtime + API_JWT_SECRET: ${JWT_SECRET} + SECRET_KEY_BASE: ${SECRET_KEY_BASE:-UpNVntn3cDxHJpq99YMc1T1AQgQpc8kfYTuRgBiYa15BLrx8etQoXz3gZv1/u2oq} + ERL_AFLAGS: "-proto_dist inet_tcp" + DNS_NODES: "''" + RLIMIT_NOFILE: "10000" + APP_NAME: realtime + SEED_SELF_HOST: "true" + REPLICATION_MODE: RLS + REPLICATION_POLL_INTERVAL: 100 + SECURE_CHANNELS: "true" + SLOT_NAME: supabase_realtime_rls + TEMPORARY_SLOT: "true" + MAX_RECORD_BYTES: 1048576 + + # ===================================================== + # Supabase Storage + # ===================================================== supabase-storage: image: supabase/storage-api:v1.22.7 restart: unless-stopped depends_on: supabase-db: condition: service_healthy + supabase-rest: + condition: service_started + supabase-imgproxy: + condition: service_started volumes: - supabase-storage-data:/var/lib/storage environment: ANON_KEY: ${SUPABASE_ANON_KEY} SERVICE_KEY: ${SUPABASE_SERVICE_ROLE_KEY} POSTGREST_URL: http://supabase-rest:3000 - PGRST_JWT_SECRET: ${JWT_SECRET:-your-super-secret-jwt-token-with-at-least-32-characters} - DATABASE_URL: postgres://supabase_storage_admin:${POSTGRES_PASSWORD:-your-super-secret-password}@supabase-db:5432/postgres + PGRST_JWT_SECRET: ${JWT_SECRET} + DATABASE_URL: postgres://supabase_storage_admin:${POSTGRES_PASSWORD}@supabase-db:5432/postgres FILE_SIZE_LIMIT: 52428800 STORAGE_BACKEND: file FILE_STORAGE_BACKEND_PATH: /var/lib/storage TENANT_ID: stub REGION: local GLOBAL_S3_BUCKET: stub + IMGPROXY_URL: http://supabase-imgproxy:8080 + healthcheck: + test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:5000/status || exit 1"] + interval: 10s + timeout: 5s + retries: 5 + # ===================================================== + # Supabase imgproxy (image transformations) + # ===================================================== + supabase-imgproxy: + image: darthsim/imgproxy:v3.8.0 + restart: unless-stopped + environment: + IMGPROXY_BIND: ":8080" + IMGPROXY_LOCAL_FILESYSTEM_ROOT: / + IMGPROXY_USE_ETAG: "true" + IMGPROXY_ENABLE_WEBP_DETECTION: "true" + + # ===================================================== + # Supabase pg_meta (DB introspection for Studio) + # ===================================================== + supabase-meta: + image: supabase/postgres-meta:v0.84.2 + restart: unless-stopped + depends_on: + supabase-db: + condition: service_healthy + environment: + PG_META_PORT: 8080 + PG_META_DB_HOST: supabase-db + PG_META_DB_PORT: 5432 + PG_META_DB_NAME: postgres + PG_META_DB_USER: supabase_admin + PG_META_DB_PASSWORD: ${POSTGRES_PASSWORD} + + # ===================================================== + # Supabase Studio (Dashboard) + # ===================================================== + supabase-studio: + image: supabase/studio:latest + restart: unless-stopped + depends_on: + - supabase-meta + - supabase-kong + ports: + - "${STUDIO_PORT:-54323}:3000" + environment: + STUDIO_PG_META_URL: http://supabase-meta:8080 + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + DEFAULT_ORGANIZATION_NAME: MyEasyCMS + DEFAULT_PROJECT_NAME: MyEasyCMS v2 + SUPABASE_URL: http://supabase-kong:8000 + SUPABASE_PUBLIC_URL: ${API_EXTERNAL_URL:-http://localhost:8000} + SUPABASE_ANON_KEY: ${SUPABASE_ANON_KEY} + SUPABASE_SERVICE_KEY: ${SUPABASE_SERVICE_ROLE_KEY} + AUTH_JWT_SECRET: ${JWT_SECRET} + NEXT_PUBLIC_ENABLE_LOGS: "true" + NEXT_ANALYTICS_BACKEND_PROVIDER: postgres + healthcheck: + test: ["CMD", "node", "-e", "require('http').get('http://localhost:3000/api/profile', (r) => r.statusCode === 200 ? process.exit(0) : process.exit(1))"] + interval: 10s + timeout: 5s + retries: 5 + + # ===================================================== + # Supabase Inbucket (Email testing) + # ===================================================== + supabase-inbucket: + image: inbucket/inbucket:3.0.4 + restart: unless-stopped + ports: + - "${INBUCKET_PORT:-54324}:9000" + volumes: + - supabase-inbucket-data:/storage + + # ===================================================== + # Supabase Kong (API Gateway) + # ===================================================== supabase-kong: image: kong:2.8.1 restart: unless-stopped @@ -98,9 +263,13 @@ services: - supabase-auth - supabase-rest - supabase-storage + - supabase-realtime ports: - "${KONG_HTTP_PORT:-8000}:8000" - "${KONG_HTTPS_PORT:-8443}:8443" + - "${APP_PORT:-3000}:3000" + entrypoint: > + sh -c "sed 's|\$${SUPABASE_ANON_KEY}|'\"$$SUPABASE_ANON_KEY\"'|g; s|\$${SUPABASE_SERVICE_KEY}|'\"$$SUPABASE_SERVICE_KEY\"'|g' /var/lib/kong/kong.yml.tpl > /tmp/kong.yml && KONG_DECLARATIVE_CONFIG=/tmp/kong.yml /docker-entrypoint.sh kong docker-start" environment: KONG_DATABASE: "off" KONG_DECLARATIVE_CONFIG: /var/lib/kong/kong.yml @@ -111,28 +280,38 @@ services: SUPABASE_ANON_KEY: ${SUPABASE_ANON_KEY} SUPABASE_SERVICE_KEY: ${SUPABASE_SERVICE_ROLE_KEY} volumes: - - ./docker/kong.yml:/var/lib/kong/kong.yml:ro + - ./docker/kong.yml:/var/lib/kong/kong.yml.tpl:ro + healthcheck: + test: ["CMD", "kong", "health"] + interval: 10s + timeout: 5s + retries: 5 # ===================================================== # Next.js App # ===================================================== - app: build: context: . dockerfile: Dockerfile restart: unless-stopped depends_on: - - supabase-kong - ports: - - "${APP_PORT:-3000}:3000" + supabase-kong: + condition: service_healthy + supabase-db-migrate: + condition: service_completed_successfully + # App shares Kong's network namespace — localhost:8000 inside the container + # reaches Kong directly. This keeps the same URL for browser AND server, + # so Supabase cookie names match without any code changes. + network_mode: "service:supabase-kong" environment: NODE_ENV: production - NEXT_PUBLIC_SITE_URL: ${SITE_URL:-https://myeasycms.de} - NEXT_PUBLIC_SUPABASE_URL: http://supabase-kong:8000 + NEXT_PUBLIC_SITE_URL: ${SITE_URL:-http://localhost:3000} + NEXT_PUBLIC_SUPABASE_URL: http://localhost:8000 NEXT_PUBLIC_SUPABASE_PUBLIC_KEY: ${SUPABASE_ANON_KEY} SUPABASE_SECRET_KEY: ${SUPABASE_SERVICE_ROLE_KEY} SUPABASE_DB_WEBHOOK_SECRET: ${DB_WEBHOOK_SECRET:-webhooksecret} + EMAIL_SENDER: ${EMAIL_SENDER:-noreply@myeasycms.de} NEXT_PUBLIC_PRODUCT_NAME: MyEasyCMS NEXT_PUBLIC_DEFAULT_LOCALE: de NEXT_PUBLIC_ENABLE_THEME_TOGGLE: "true" @@ -145,3 +324,4 @@ services: volumes: supabase-db-data: supabase-storage-data: + supabase-inbucket-data: diff --git a/docker/db/dev-bootstrap.sh b/docker/db/dev-bootstrap.sh new file mode 100755 index 000000000..18f7a9d6c --- /dev/null +++ b/docker/db/dev-bootstrap.sh @@ -0,0 +1,51 @@ +#!/bin/sh +set -e + +# =========================================================================== +# Docker dev bootstrap — runs AFTER app migrations +# Seeds the DB, removes MFA, patches is_super_admin for local dev (no aal2). +# =========================================================================== + +PSQL="psql -v ON_ERROR_STOP=0 --no-password --no-psqlrc -U supabase_admin -d postgres -h supabase-db" + +echo "🌱 Running seed.sql..." +$PSQL -f /app-seed/seed.sql 2>&1 || true + +echo "🔓 Removing MFA factors for dev..." +$PSQL -c "DELETE FROM auth.mfa_factors;" 2>&1 || true +$PSQL -c "DELETE FROM auth.mfa_challenges;" 2>&1 || true + +echo "🔄 Fixing auth sequences after seed import..." +$PSQL -c "SELECT setval('auth.refresh_tokens_id_seq', (SELECT COALESCE(MAX(id), 0) + 1 FROM auth.refresh_tokens));" 2>&1 || true +$PSQL -c "SELECT setval(pg_get_serial_sequence('public.role_permissions', 'id'), (SELECT COALESCE(MAX(id), 0) + 1 FROM public.role_permissions));" 2>&1 || true + +echo "🔧 Patching is_super_admin() — skip aal2 for local dev..." +cat <<'EOSQL' | $PSQL +CREATE OR REPLACE FUNCTION public.is_super_admin() RETURNS boolean +LANGUAGE plpgsql SECURITY DEFINER AS $fn$ +declare r boolean; +begin + select (auth.jwt() ->> 'app_metadata')::jsonb ->> 'role' = 'super-admin' into r; + return coalesce(r, false); +end $fn$; +EOSQL + +echo "🌐 Adding anon read policy for public club pages..." +$PSQL -c "DO \$\$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_policy WHERE polname = 'accounts_public_read') THEN + CREATE POLICY accounts_public_read ON public.accounts FOR SELECT TO anon + USING (is_personal_account = false AND id IN (SELECT account_id FROM public.site_settings WHERE is_public = true)); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_policy WHERE polname = 'events_public_read') THEN + CREATE POLICY events_public_read ON public.events FOR SELECT TO anon + USING (account_id IN (SELECT account_id FROM public.site_settings WHERE is_public = true)); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_policy WHERE polname = 'courses_public_read') THEN + CREATE POLICY courses_public_read ON public.courses FOR SELECT TO anon + USING (account_id IN (SELECT account_id FROM public.site_settings WHERE is_public = true)); + END IF; +END \$\$;" 2>&1 || true +$PSQL -c "GRANT SELECT ON public.events TO anon;" 2>&1 || true +$PSQL -c "GRANT SELECT ON public.courses TO anon;" 2>&1 || true + +echo "✅ Dev bootstrap complete." diff --git a/docker/db/zzz-role-passwords.sh b/docker/db/zzz-role-passwords.sh new file mode 100755 index 000000000..e00420c97 --- /dev/null +++ b/docker/db/zzz-role-passwords.sh @@ -0,0 +1,34 @@ +#!/bin/bash +set -e + +# =========================================================================== +# Supabase role password bootstrap +# +# Runs AFTER migrate.sh (zzz- prefix ensures alphabetical ordering). +# By this point all roles exist (created by init-scripts/00000000000000-initial-schema.sql). +# Sets passwords so PostgREST, Storage, Auth, and Studio can connect via TCP. +# =========================================================================== + +psql -v ON_ERROR_STOP=1 --no-password --no-psqlrc -U supabase_admin -d postgres <<-EOSQL + -- PostgREST connects as authenticator + ALTER ROLE authenticator WITH LOGIN PASSWORD '${POSTGRES_PASSWORD}'; + + -- Storage API connects as supabase_storage_admin + ALTER ROLE supabase_storage_admin WITH LOGIN PASSWORD '${POSTGRES_PASSWORD}'; + + -- GoTrue (Auth) connects as supabase_auth_admin + ALTER ROLE supabase_auth_admin WITH LOGIN PASSWORD '${POSTGRES_PASSWORD}'; + + -- Studio / pg_meta connects as dashboard_user + ALTER ROLE dashboard_user WITH LOGIN PASSWORD '${POSTGRES_PASSWORD}'; + + -- postgres (created by migrate.sh, needs password for TCP auth) + ALTER ROLE postgres WITH PASSWORD '${POSTGRES_PASSWORD}'; + + -- Realtime needs the _realtime schema + CREATE SCHEMA IF NOT EXISTS _realtime; + GRANT ALL ON SCHEMA _realtime TO supabase_admin; + GRANT USAGE ON SCHEMA _realtime TO postgres, anon, authenticated, service_role; +EOSQL + +echo "✅ All Supabase role passwords set successfully." diff --git a/docker/kong.yml b/docker/kong.yml index 9b6cde544..b8953e7ea 100644 --- a/docker/kong.yml +++ b/docker/kong.yml @@ -46,6 +46,17 @@ services: - anon - admin + # Realtime + - name: realtime-v1 + url: http://supabase-realtime:4000/socket/ + routes: + - name: realtime-v1-routes + strip_path: true + paths: + - /realtime/v1/ + plugins: + - name: cors + # Storage - name: storage-v1 url: http://supabase-storage:5000/ @@ -56,3 +67,21 @@ services: - /storage/v1/ plugins: - name: cors + + # pg_meta + - name: meta + url: http://supabase-meta:8080/ + routes: + - name: meta-routes + strip_path: true + paths: + - /pg/ + plugins: + - name: key-auth + config: + hide_credentials: false + - name: acl + config: + hide_groups_header: true + allow: + - admin diff --git a/packages/features/auth/src/components/password-input.tsx b/packages/features/auth/src/components/password-input.tsx index dedf56c35..c67acdf2d 100644 --- a/packages/features/auth/src/components/password-input.tsx +++ b/packages/features/auth/src/components/password-input.tsx @@ -24,6 +24,7 @@ export function PasswordInput(props: React.ComponentProps<'input'>) { data-test="password-input" type={showPassword ? 'text' : 'password'} placeholder={'************'} + required {...props} /> diff --git a/packages/features/course-management/src/components/create-course-form.tsx b/packages/features/course-management/src/components/create-course-form.tsx index 75ae980b9..cd0def0ba 100644 --- a/packages/features/course-management/src/components/create-course-form.tsx +++ b/packages/features/course-management/src/components/create-course-form.tsx @@ -42,7 +42,7 @@ export function CreateCourseForm({ accountId, account }: Props) { onSuccess: ({ data }) => { if (data?.success) { toast.success('Kurs erfolgreich erstellt'); - router.push(`/home/${account}/courses-cms`); + router.push(`/home/${account}/courses`); } }, onError: ({ error }) => { diff --git a/packages/features/event-management/src/components/create-event-form.tsx b/packages/features/event-management/src/components/create-event-form.tsx index bdcd42098..a2d9137c4 100644 --- a/packages/features/event-management/src/components/create-event-form.tsx +++ b/packages/features/event-management/src/components/create-event-form.tsx @@ -45,7 +45,7 @@ export function CreateEventForm({ accountId, account }: Props) { onSuccess: ({ data }) => { if (data?.success) { toast.success('Veranstaltung erfolgreich erstellt'); - router.push(`/home/${account}/events-cms`); + router.push(`/home/${account}/events`); } }, onError: ({ error }) => { diff --git a/packages/features/event-management/src/server/api.ts b/packages/features/event-management/src/server/api.ts index 953eab97f..77597603d 100644 --- a/packages/features/event-management/src/server/api.ts +++ b/packages/features/event-management/src/server/api.ts @@ -6,6 +6,7 @@ import type { CreateEventInput } from '../schema/event.schema'; /* eslint-disable @typescript-eslint/no-explicit-any */ export function createEventManagementApi(client: SupabaseClient) { + const PAGE_SIZE = 25; const db = client; return { @@ -14,10 +15,28 @@ export function createEventManagementApi(client: SupabaseClient) { .eq('account_id', accountId).order('event_date', { ascending: false }); if (opts?.status) query = query.eq('status', opts.status); const page = opts?.page ?? 1; - query = query.range((page - 1) * 25, page * 25 - 1); + query = query.range((page - 1) * PAGE_SIZE, page * PAGE_SIZE - 1); const { data, error, count } = await query; if (error) throw error; - return { data: data ?? [], total: count ?? 0 }; + const total = count ?? 0; + return { data: data ?? [], total, page, pageSize: PAGE_SIZE, totalPages: Math.max(1, Math.ceil(total / PAGE_SIZE)) }; + }, + + async getRegistrationCounts(eventIds: string[]) { + if (eventIds.length === 0) return {} as Record; + const { data, error } = await client + .from('event_registrations') + .select('event_id', { count: 'exact', head: false }) + .in('event_id', eventIds) + .in('status', ['pending', 'confirmed']); + if (error) throw error; + + const counts: Record = {}; + for (const row of data ?? []) { + const eid = (row as Record).event_id as string; + counts[eid] = (counts[eid] ?? 0) + 1; + } + return counts; }, async getEvent(eventId: string) { diff --git a/packages/features/fischerei/package.json b/packages/features/fischerei/package.json new file mode 100644 index 000000000..c3c3d0eaa --- /dev/null +++ b/packages/features/fischerei/package.json @@ -0,0 +1,40 @@ +{ + "name": "@kit/fischerei", + "version": "0.1.0", + "private": true, + "typesVersions": { + "*": { + "*": [ + "src/*" + ] + } + }, + "exports": { + "./api": "./src/server/api.ts", + "./schema/*": "./src/schema/*.ts", + "./components": "./src/components/index.ts", + "./actions/*": "./src/server/actions/*.ts", + "./lib/*": "./src/lib/*.ts" + }, + "scripts": { + "clean": "git clean -xdf .turbo node_modules", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@hookform/resolvers": "catalog:", + "@kit/next": "workspace:*", + "@kit/shared": "workspace:*", + "@kit/supabase": "workspace:*", + "@kit/tsconfig": "workspace:*", + "@kit/ui": "workspace:*", + "@supabase/supabase-js": "catalog:", + "@types/react": "catalog:", + "lucide-react": "catalog:", + "next": "catalog:", + "next-safe-action": "catalog:", + "react": "catalog:", + "react-hook-form": "catalog:", + "recharts": "catalog:", + "zod": "catalog:" + } +} diff --git a/packages/features/fischerei/src/components/catch-books-data-table.tsx b/packages/features/fischerei/src/components/catch-books-data-table.tsx new file mode 100644 index 000000000..df40f972c --- /dev/null +++ b/packages/features/fischerei/src/components/catch-books-data-table.tsx @@ -0,0 +1,212 @@ +'use client'; + +import { useCallback } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; + +import { Check } from 'lucide-react'; + +import { Badge } from '@kit/ui/badge'; +import { Button } from '@kit/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card'; + +import { + CATCH_BOOK_STATUS_LABELS, + CATCH_BOOK_STATUS_COLORS, +} from '../lib/fischerei-constants'; + +interface CatchBooksDataTableProps { + data: Array>; + total: number; + page: number; + pageSize: number; + account: string; +} + +const STATUS_OPTIONS = [ + { value: '', label: 'Alle Status' }, + { value: 'offen', label: 'Offen' }, + { value: 'eingereicht', label: 'Eingereicht' }, + { value: 'geprueft', label: 'Geprüft' }, + { value: 'akzeptiert', label: 'Akzeptiert' }, + { value: 'abgelehnt', label: 'Abgelehnt' }, +] as const; + +export function CatchBooksDataTable({ + data, + total, + page, + pageSize, + account, +}: CatchBooksDataTableProps) { + const router = useRouter(); + const searchParams = useSearchParams(); + + const currentYear = searchParams.get('year') ?? ''; + const currentStatus = searchParams.get('status') ?? ''; + const totalPages = Math.max(1, Math.ceil(total / pageSize)); + + const updateParams = useCallback( + (updates: Record) => { + const params = new URLSearchParams(searchParams.toString()); + for (const [key, value] of Object.entries(updates)) { + if (value) { + params.set(key, value); + } else { + params.delete(key); + } + } + if (!('page' in updates)) params.delete('page'); + router.push(`?${params.toString()}`); + }, + [router, searchParams], + ); + + const handleYearChange = useCallback( + (e: React.ChangeEvent) => { + updateParams({ year: e.target.value }); + }, + [updateParams], + ); + + const handleStatusChange = useCallback( + (e: React.ChangeEvent) => { + updateParams({ status: e.target.value }); + }, + [updateParams], + ); + + const handlePageChange = useCallback( + (newPage: number) => { + updateParams({ page: String(newPage) }); + }, + [updateParams], + ); + + // Build year options from current year going back 5 years + const thisYear = new Date().getFullYear(); + const yearOptions = Array.from({ length: 6 }, (_, i) => thisYear - i); + + return ( +
+ {/* Toolbar */} +
+
+ + + +
+
+ + {/* Table */} + + + Fangbücher ({total}) + + + {data.length === 0 ? ( +
+

Keine Fangbücher vorhanden

+

+ Es wurden noch keine Fangbücher angelegt. +

+
+ ) : ( +
+ + + + + + + + + + + + {data.map((cb) => { + const members = cb.members as Record | null; + const memberName = members + ? `${String(members.first_name ?? '')} ${String(members.last_name ?? '')}`.trim() + : String(cb.member_name ?? '—'); + const status = String(cb.status ?? 'offen'); + + return ( + + router.push( + `/home/${account}/fischerei/catch-books/${String(cb.id)}`, + ) + } + > + + + + + + + ); + })} + +
MitgliedJahrAngeltageFängeStatus
{memberName}{String(cb.year)} + {String(cb.fishing_days_count ?? 0)} + + {String(cb.total_fish_caught ?? 0)} + + + {CATCH_BOOK_STATUS_LABELS[status] ?? status} + +
+
+ )} + + {totalPages > 1 && ( +
+

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

+
+ + +
+
+ )} +
+
+
+ ); +} diff --git a/packages/features/fischerei/src/components/competitions-data-table.tsx b/packages/features/fischerei/src/components/competitions-data-table.tsx new file mode 100644 index 000000000..8436e97ed --- /dev/null +++ b/packages/features/fischerei/src/components/competitions-data-table.tsx @@ -0,0 +1,146 @@ +'use client'; + +import { useCallback } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import Link from 'next/link'; + +import { Plus } from 'lucide-react'; + +import { Button } from '@kit/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card'; + +interface CompetitionsDataTableProps { + data: Array>; + total: number; + page: number; + pageSize: number; + account: string; +} + +export function CompetitionsDataTable({ + data, + total, + page, + pageSize, + account, +}: CompetitionsDataTableProps) { + const router = useRouter(); + const searchParams = useSearchParams(); + const totalPages = Math.max(1, Math.ceil(total / pageSize)); + + const updateParams = useCallback( + (updates: Record) => { + const params = new URLSearchParams(searchParams.toString()); + for (const [key, value] of Object.entries(updates)) { + if (value) { + params.set(key, value); + } else { + params.delete(key); + } + } + if (!('page' in updates)) params.delete('page'); + router.push(`?${params.toString()}`); + }, + [router, searchParams], + ); + + const handlePageChange = useCallback( + (newPage: number) => { + updateParams({ page: String(newPage) }); + }, + [updateParams], + ); + + return ( +
+ {/* Toolbar */} +
+

Wettbewerbe ({total})

+ + + +
+ + {/* Table */} + + + {data.length === 0 ? ( +
+

Keine Wettbewerbe vorhanden

+

+ Erstellen Sie Ihren ersten Wettbewerb. +

+ + + +
+ ) : ( +
+ + + + + + + + + + + {data.map((comp) => { + const waters = comp.waters as Record | null; + + return ( + + router.push( + `/home/${account}/fischerei/competitions/${String(comp.id)}`, + ) + } + > + + + + + + ); + })} + +
NameDatumGewässerMax. Teilnehmer
{String(comp.name)} + {comp.competition_date + ? new Date(String(comp.competition_date)).toLocaleDateString('de-DE') + : '—'} + + {waters ? String(waters.name) : '—'} + + {comp.max_participants != null + ? String(comp.max_participants) + : '—'} +
+
+ )} + + {totalPages > 1 && ( +
+

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

+
+ + +
+
+ )} +
+
+
+ ); +} diff --git a/packages/features/fischerei/src/components/create-species-form.tsx b/packages/features/fischerei/src/components/create-species-form.tsx new file mode 100644 index 000000000..ce26faa45 --- /dev/null +++ b/packages/features/fischerei/src/components/create-species-form.tsx @@ -0,0 +1,245 @@ +'use client'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { useForm } from 'react-hook-form'; +import { useAction } from 'next-safe-action/hooks'; +import { useRouter } from 'next/navigation'; + +import { Button } from '@kit/ui/button'; +import { Input } from '@kit/ui/input'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@kit/ui/form'; +import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card'; +import { toast } from '@kit/ui/sonner'; + +import { CreateFishSpeciesSchema } from '../schema/fischerei.schema'; +import { createSpecies } from '../server/actions/fischerei-actions'; + +interface CreateSpeciesFormProps { + accountId: string; + account: string; + species?: Record; +} + +export function CreateSpeciesForm({ + accountId, + account, + species, +}: CreateSpeciesFormProps) { + const router = useRouter(); + const isEdit = !!species; + + const form = useForm({ + resolver: zodResolver(CreateFishSpeciesSchema), + defaultValues: { + accountId, + name: (species?.name as string) ?? '', + nameLatin: (species?.name_latin as string) ?? '', + nameLocal: (species?.name_local as string) ?? '', + isActive: species?.is_active != null ? Boolean(species.is_active) : true, + protectedMinSizeCm: species?.protected_min_size_cm != null ? Number(species.protected_min_size_cm) : undefined, + protectionPeriodStart: (species?.protection_period_start as string) ?? '', + protectionPeriodEnd: (species?.protection_period_end as string) ?? '', + maxCatchPerDay: species?.max_catch_per_day != null ? Number(species.max_catch_per_day) : undefined, + maxCatchPerYear: species?.max_catch_per_year != null ? Number(species.max_catch_per_year) : undefined, + individualRecording: species?.individual_recording != null ? Boolean(species.individual_recording) : false, + }, + }); + + const { execute, isPending } = useAction(createSpecies, { + onSuccess: ({ data }) => { + if (data?.success) { + toast.success(isEdit ? 'Fischart aktualisiert' : 'Fischart erstellt'); + router.push(`/home/${account}/fischerei/species`); + } + }, + onError: ({ error }) => { + toast.error(error.serverError ?? 'Fehler beim Speichern'); + }, + }); + + return ( +
+ execute(data))} + className="space-y-6" + > + {/* Card 1: Grunddaten */} + + + Grunddaten + + + ( + + Name * + + + + + + )} + /> + ( + + Lateinischer Name + + + + + + )} + /> + ( + + Lokaler Name + + + + + + )} + /> + + + + {/* Card 2: Schutzbestimmungen */} + + + Schutzbestimmungen + + + ( + + Schonmaß (cm) + + + field.onChange(e.target.value ? Number(e.target.value) : undefined) + } + /> + + + + )} + /> + ( + + Schonzeit Beginn (MM.TT) + + + + + + )} + /> + ( + + Schonzeit Ende (MM.TT) + + + + + + )} + /> + + + + {/* Card 3: Fangbegrenzungen */} + + + Fangbegrenzungen + + + ( + + Max. Fang/Tag + + + field.onChange(e.target.value ? Number(e.target.value) : undefined) + } + /> + + + + )} + /> + ( + + Max. Fang/Jahr + + + field.onChange(e.target.value ? Number(e.target.value) : undefined) + } + /> + + + + )} + /> + + + + {/* Submit */} +
+ + +
+
+ + ); +} diff --git a/packages/features/fischerei/src/components/create-stocking-form.tsx b/packages/features/fischerei/src/components/create-stocking-form.tsx new file mode 100644 index 000000000..78f16d4c5 --- /dev/null +++ b/packages/features/fischerei/src/components/create-stocking-form.tsx @@ -0,0 +1,257 @@ +'use client'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { useForm } from 'react-hook-form'; +import { useAction } from 'next-safe-action/hooks'; +import { useRouter } from 'next/navigation'; + +import { Button } from '@kit/ui/button'; +import { Input } from '@kit/ui/input'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@kit/ui/form'; +import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card'; +import { toast } from '@kit/ui/sonner'; + +import { CreateStockingSchema } from '../schema/fischerei.schema'; +import { createStocking } from '../server/actions/fischerei-actions'; + +interface CreateStockingFormProps { + accountId: string; + account: string; + waters: Array<{ id: string; name: string }>; + species: Array<{ id: string; name: string }>; +} + +export function CreateStockingForm({ + accountId, + account, + waters, + species, +}: CreateStockingFormProps) { + const router = useRouter(); + + const form = useForm({ + resolver: zodResolver(CreateStockingSchema), + defaultValues: { + accountId, + waterId: '', + speciesId: '', + stockingDate: new Date().toISOString().split('T')[0], + quantity: 0, + weightKg: undefined as number | undefined, + ageClass: 'sonstige' as const, + costEuros: undefined as number | undefined, + supplierId: undefined as string | undefined, + remarks: '', + }, + }); + + const { execute, isPending } = useAction(createStocking, { + onSuccess: ({ data }) => { + if (data?.success) { + toast.success('Besatz eingetragen'); + router.push(`/home/${account}/fischerei/stocking`); + } + }, + onError: ({ error }) => { + toast.error(error.serverError ?? 'Fehler beim Speichern'); + }, + }); + + return ( +
+ execute(data))} + className="space-y-6" + > + + + Besatzdaten + + + ( + + Gewässer * + + + + + + )} + /> + ( + + Fischart * + + + + + + )} + /> + ( + + Besatzdatum * + + + + + + )} + /> + ( + + Anzahl (Stück) * + + field.onChange(Number(e.target.value))} + /> + + + + )} + /> + ( + + Gewicht (kg) + + + field.onChange(e.target.value ? Number(e.target.value) : undefined) + } + /> + + + + )} + /> + ( + + Altersklasse + + + + + + )} + /> + ( + + Kosten (EUR) + + + field.onChange(e.target.value ? Number(e.target.value) : undefined) + } + /> + + + + )} + /> +
+ ( + + Bemerkungen + +