feat: complete CMS v2 with Docker, Fischerei, Meetings, Verband modules + UX audit fixes
Some checks failed
Workflow / ʦ TypeScript (push) Failing after 6m26s
Workflow / ⚫️ Test (push) Has been skipped

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
This commit is contained in:
Zaid Marzguioui
2026-03-31 16:35:46 +02:00
parent 16648c92eb
commit ebd0fd4638
176 changed files with 17133 additions and 981 deletions

1
.build-cache-buster Normal file
View File

@@ -0,0 +1 @@
Di. 31 März 2026 01:21:52 CEST

View File

@@ -1,6 +1,7 @@
node_modules node_modules
.next **/.next
.turbo .turbo
**/.turbo
.git .git
*.md *.md
.env* .env*
@@ -10,3 +11,8 @@ apps/dev-tool
.gitnexus .gitnexus
.gsd .gsd
.claude .claude
.gemini
.junie
.github
docs
**/*.tsbuildinfo

23
.env Normal file
View File

@@ -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

View File

@@ -1,10 +1,12 @@
# ===================================================== # =====================================================
# MyEasyCMS v2 — Environment Variables # MyEasyCMS v2 — Environment Variables (Production)
# Copy to .env and fill in your values # Copy to .env and fill in your values
# ===================================================== # =====================================================
# --- Supabase --- # --- Supabase Database ---
POSTGRES_PASSWORD=change-me-to-a-strong-password POSTGRES_PASSWORD=change-me-to-a-strong-password
# --- Supabase Auth ---
JWT_SECRET=change-me-to-at-least-32-characters-long-secret JWT_SECRET=change-me-to-at-least-32-characters-long-secret
# Generate these with: npx supabase gen keys # 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 SITE_URL=https://myeasycms.de
APP_PORT=3000 APP_PORT=3000
# --- Kong --- # --- Kong (API Gateway) ---
KONG_HTTP_PORT=8000 KONG_HTTP_PORT=8000
KONG_HTTPS_PORT=8443 KONG_HTTPS_PORT=8443
API_EXTERNAL_URL=https://api.myeasycms.de API_EXTERNAL_URL=https://api.myeasycms.de
# --- Studio (Dashboard) ---
STUDIO_PORT=54323
# --- Inbucket (Email testing — dev only) ---
INBUCKET_PORT=54324
# --- Email (SMTP) --- # --- Email (SMTP) ---
SMTP_HOST=smtp.example.com SMTP_HOST=smtp.example.com
SMTP_PORT=587 SMTP_PORT=587
@@ -27,7 +35,7 @@ SMTP_USER=noreply@myeasycms.de
SMTP_PASS=your-smtp-password SMTP_PASS=your-smtp-password
SMTP_ADMIN_EMAIL=admin@myeasycms.de SMTP_ADMIN_EMAIL=admin@myeasycms.de
# --- Auth --- # --- Auth Settings ---
ENABLE_EMAIL_AUTOCONFIRM=false ENABLE_EMAIL_AUTOCONFIRM=false
DISABLE_SIGNUP=false DISABLE_SIGNUP=false
JWT_EXPIRY=3600 JWT_EXPIRY=3600

View File

@@ -2,23 +2,15 @@ FROM node:22-alpine AS base
RUN corepack enable && corepack prepare pnpm@latest --activate RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app WORKDIR /app
# --- Install deps --- # --- Install + Build in one stage ---
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 ---
FROM base AS builder FROM base AS builder
COPY --from=deps /app/ ./ ARG CACHE_BUST=1
COPY . . COPY . .
RUN pnpm install --no-frozen-lockfile
ENV NEXT_TELEMETRY_DISABLED=1 ENV NEXT_TELEMETRY_DISABLED=1
ENV NEXT_PUBLIC_SITE_URL=https://myeasycms.de ENV NEXT_PUBLIC_SITE_URL=https://myeasycms.de
ENV NEXT_PUBLIC_SUPABASE_URL=placeholder ENV NEXT_PUBLIC_SUPABASE_URL=http://localhost:8000
ENV NEXT_PUBLIC_SUPABASE_PUBLIC_KEY=placeholder ENV NEXT_PUBLIC_SUPABASE_PUBLIC_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzc0ODgzNDYwLCJleHAiOjIwOTAyNDM0NjB9.hbeQae1gYRTcJQ_6m7MPjoDlYFhp4tsszEQD2g5q6vY
RUN pnpm --filter web build RUN pnpm --filter web build
# --- Run --- # --- Run ---

501
QA_TEST_PLAN.md Normal file
View File

@@ -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 <script>alert(1)</script> 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

View File

@@ -1,6 +1,7 @@
import { createClient } from '@supabase/supabase-js'; import { createClient } from '@supabase/supabase-js';
import { notFound } from 'next/navigation'; import { notFound } from 'next/navigation';
import { SiteRenderer } from '@kit/site-builder/components'; import { SiteRenderer } from '@kit/site-builder/components';
import type { SiteData } from '@kit/site-builder/context';
interface Props { params: Promise<{ slug: string; page: string[] }> } interface Props { params: Promise<{ slug: string; page: string[] }> }
@@ -23,9 +24,26 @@ export default async function ClubSubPage({ params }: Props) {
.eq('account_id', account.id).eq('slug', pageSlug).eq('is_published', true).maybeSingle(); .eq('account_id', account.id).eq('slug', pageSlug).eq('is_published', true).maybeSingle();
if (!sitePageData) notFound(); 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 ( return (
<div style={{ '--primary': settings.primary_color, fontFamily: settings.font_family } as React.CSSProperties}> <div style={{ '--primary': settings.primary_color, fontFamily: settings.font_family } as React.CSSProperties}>
<SiteRenderer data={(sitePageData.puck_data ?? {}) as Record<string, unknown>} /> <SiteRenderer data={(sitePageData.puck_data ?? {}) as Record<string, unknown>} siteData={siteData} />
</div> </div>
); );
} }

View File

@@ -1,34 +1,48 @@
import { createClient } from '@supabase/supabase-js'; import { createClient } from '@supabase/supabase-js';
import { notFound } from 'next/navigation'; import { notFound } from 'next/navigation';
import { SiteRenderer } from '@kit/site-builder/components'; import { SiteRenderer } from '@kit/site-builder/components';
import type { SiteData } from '@kit/site-builder/context';
interface Props { params: Promise<{ slug: string }> } interface Props { params: Promise<{ slug: string }> }
export default async function ClubHomePage({ params }: Props) { export default async function ClubHomePage({ params }: Props) {
const { slug } = await params; const { slug } = await params;
// Use anon client for public access
const supabase = createClient( const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_PUBLIC_KEY!, process.env.NEXT_PUBLIC_SUPABASE_PUBLIC_KEY!,
); );
// Resolve slug → account
const { data: account } = await supabase.from('accounts').select('id, name').eq('slug', slug).single(); const { data: account } = await supabase.from('accounts').select('id, name').eq('slug', slug).single();
if (!account) notFound(); 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(); const { data: settings } = await supabase.from('site_settings').select('*').eq('account_id', account.id).eq('is_public', true).maybeSingle();
if (!settings) notFound(); if (!settings) notFound();
// Get homepage
const { data: page } = await supabase.from('site_pages').select('*') const { data: page } = await supabase.from('site_pages').select('*')
.eq('account_id', account.id).eq('is_homepage', true).eq('is_published', true).maybeSingle(); .eq('account_id', account.id).eq('is_homepage', true).eq('is_published', true).maybeSingle();
if (!page) notFound(); 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 ( return (
<div style={{ '--primary': settings.primary_color, '--secondary': settings.secondary_color, fontFamily: settings.font_family } as React.CSSProperties}> <div style={{ '--primary': settings.primary_color, '--secondary': settings.secondary_color, fontFamily: settings.font_family } as React.CSSProperties}>
<SiteRenderer data={(page.puck_data ?? {}) as Record<string, unknown>} /> <SiteRenderer data={(page.puck_data ?? {}) as Record<string, unknown>} siteData={siteData} />
</div> </div>
); );
} }

View File

@@ -4,6 +4,7 @@ import Link from 'next/link';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { Home, LogOut, Menu } from 'lucide-react'; import { Home, LogOut, Menu } from 'lucide-react';
import * as z from 'zod';
import { AccountSelector } from '@kit/accounts/account-selector'; import { AccountSelector } from '@kit/accounts/account-selector';
import { useSignOut } from '@kit/supabase/hooks/use-sign-out'; import { useSignOut } from '@kit/supabase/hooks/use-sign-out';
@@ -21,11 +22,11 @@ import {
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@kit/ui/dropdown-menu'; } from '@kit/ui/dropdown-menu';
import { NavigationConfigSchema } from '@kit/ui/navigation-schema';
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
import featureFlagsConfig from '~/config/feature-flags.config'; import featureFlagsConfig from '~/config/feature-flags.config';
import pathsConfig from '~/config/paths.config'; import pathsConfig from '~/config/paths.config';
import { getTeamAccountSidebarConfig } from '~/config/team-account-navigation.config';
type Accounts = Array<{ type Accounts = Array<{
label: string | null; label: string | null;
@@ -43,11 +44,12 @@ export const TeamAccountLayoutMobileNavigation = (
account: string; account: string;
userId: string; userId: string;
accounts: Accounts; accounts: Accounts;
config: z.output<typeof NavigationConfigSchema>;
}>, }>,
) => { ) => {
const signOut = useSignOut(); const signOut = useSignOut();
const Links = getTeamAccountSidebarConfig(props.account).routes.map( const Links = props.config.routes.map(
(item, index) => { (item, index) => {
if ('children' in item) { if ('children' in item) {
return item.children.map((child) => { return item.children.map((child) => {

View File

@@ -1,11 +1,13 @@
import * as z from 'zod';
import { JWTUserData } from '@kit/supabase/types'; import { JWTUserData } from '@kit/supabase/types';
import { If } from '@kit/ui/if'; import { If } from '@kit/ui/if';
import { NavigationConfigSchema } from '@kit/ui/navigation-schema';
import { Sidebar, SidebarContent, SidebarHeader } from '@kit/ui/sidebar'; import { Sidebar, SidebarContent, SidebarHeader } from '@kit/ui/sidebar';
import type { AccountModel } from '~/components/workspace-dropdown'; import type { AccountModel } from '~/components/workspace-dropdown';
import { WorkspaceDropdown } from '~/components/workspace-dropdown'; import { WorkspaceDropdown } from '~/components/workspace-dropdown';
import featureFlagsConfig from '~/config/feature-flags.config'; 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 { TeamAccountNotifications } from '~/home/[account]/_components/team-account-notifications';
import { TeamAccountLayoutSidebarNavigation } from './team-account-layout-sidebar-navigation'; import { TeamAccountLayoutSidebarNavigation } from './team-account-layout-sidebar-navigation';
@@ -15,10 +17,10 @@ export function TeamAccountLayoutSidebar(props: {
accountId: string; accountId: string;
accounts: AccountModel[]; accounts: AccountModel[];
user: JWTUserData; user: JWTUserData;
config: z.output<typeof NavigationConfigSchema>;
}) { }) {
const { account, accounts, user } = props; const { account, accounts, user, config } = props;
const config = getTeamAccountSidebarConfig(account);
const collapsible = config.sidebarCollapsedStyle; const collapsible = config.sidebarCollapsedStyle;
return ( return (

View File

@@ -1,13 +1,15 @@
import * as z from 'zod';
import { import {
BorderedNavigationMenu, BorderedNavigationMenu,
BorderedNavigationMenuItem, BorderedNavigationMenuItem,
} from '@kit/ui/bordered-navigation-menu'; } from '@kit/ui/bordered-navigation-menu';
import { If } from '@kit/ui/if'; import { If } from '@kit/ui/if';
import { NavigationConfigSchema } from '@kit/ui/navigation-schema';
import { AppLogo } from '~/components/app-logo'; import { AppLogo } from '~/components/app-logo';
import { ProfileAccountDropdownContainer } from '~/components/personal-account-dropdown-container'; import { ProfileAccountDropdownContainer } from '~/components/personal-account-dropdown-container';
import featureFlagsConfig from '~/config/feature-flags.config'; 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'; import { TeamAccountAccountsSelector } from '~/home/[account]/_components/team-account-accounts-selector';
// local imports // local imports
@@ -16,10 +18,11 @@ import { TeamAccountNotifications } from './team-account-notifications';
export function TeamAccountNavigationMenu(props: { export function TeamAccountNavigationMenu(props: {
workspace: TeamAccountWorkspace; workspace: TeamAccountWorkspace;
config: z.output<typeof NavigationConfigSchema>;
}) { }) {
const { account, user, accounts } = props.workspace; const { account, user, accounts } = props.workspace;
const routes = getTeamAccountSidebarConfig(account.slug).routes.reduce< const routes = props.config.routes.reduce<
Array<{ Array<{
path: string; path: string;
label: string; label: string;

View File

@@ -21,9 +21,8 @@ import {
CardTitle, CardTitle,
} from '@kit/ui/card'; } from '@kit/ui/card';
import { createBookingManagementApi } from '@kit/booking-management/api';
import { CmsPageShell } from '~/components/cms-page-shell'; import { CmsPageShell } from '~/components/cms-page-shell';
import { AccountNotFound } from '~/components/account-not-found';
interface PageProps { interface PageProps {
params: Promise<{ account: string; bookingId: string }>; params: Promise<{ account: string; bookingId: string }>;
@@ -60,9 +59,13 @@ export default async function BookingDetailPage({ params }: PageProps) {
.eq('slug', account) .eq('slug', account)
.single(); .single();
if (!acct) return <div>Konto nicht gefunden</div>; if (!acct) {
return (
const api = createBookingManagementApi(client); <CmsPageShell account={account} title="Buchungsdetails">
<AccountNotFound />
</CmsPageShell>
);
}
// Load booking directly // Load booking directly
const { data: booking } = await client const { data: booking } = await client
@@ -117,7 +120,6 @@ export default async function BookingDetailPage({ params }: PageProps) {
</Link> </Link>
<div> <div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<h1 className="text-2xl font-bold">Buchungsdetails</h1>
<Badge variant={STATUS_BADGE_VARIANT[status] ?? 'secondary'}> <Badge variant={STATUS_BADGE_VARIANT[status] ?? 'secondary'}>
{STATUS_LABEL[status] ?? status} {STATUS_LABEL[status] ?? status}
</Badge> </Badge>

View File

@@ -10,6 +10,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { createBookingManagementApi } from '@kit/booking-management/api'; import { createBookingManagementApi } from '@kit/booking-management/api';
import { CmsPageShell } from '~/components/cms-page-shell'; import { CmsPageShell } from '~/components/cms-page-shell';
import { AccountNotFound } from '~/components/account-not-found';
interface PageProps { interface PageProps {
params: Promise<{ account: string }>; params: Promise<{ account: string }>;
@@ -56,7 +57,13 @@ export default async function BookingCalendarPage({ params }: PageProps) {
.eq('slug', account) .eq('slug', account)
.single(); .single();
if (!acct) return <div>Konto nicht gefunden</div>; if (!acct) {
return (
<CmsPageShell account={account} title="Belegungskalender">
<AccountNotFound />
</CmsPageShell>
);
}
const api = createBookingManagementApi(client); const api = createBookingManagementApi(client);
@@ -128,12 +135,9 @@ export default async function BookingCalendarPage({ params }: PageProps) {
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
</Button> </Button>
</Link> </Link>
<div> <p className="text-muted-foreground">
<h1 className="text-2xl font-bold">Belegungskalender</h1> Zimmerauslastung im Überblick
<p className="text-muted-foreground"> </p>
Zimmerauslastung im Überblick
</p>
</div>
</div> </div>
</div> </div>

View File

@@ -8,6 +8,7 @@ import { createBookingManagementApi } from '@kit/booking-management/api';
import { CmsPageShell } from '~/components/cms-page-shell'; import { CmsPageShell } from '~/components/cms-page-shell';
import { EmptyState } from '~/components/empty-state'; import { EmptyState } from '~/components/empty-state';
import { AccountNotFound } from '~/components/account-not-found';
interface PageProps { interface PageProps {
params: Promise<{ account: string }>; params: Promise<{ account: string }>;
@@ -23,7 +24,13 @@ export default async function GuestsPage({ params }: PageProps) {
.eq('slug', account) .eq('slug', account)
.single(); .single();
if (!acct) return <div>Konto nicht gefunden</div>; if (!acct) {
return (
<CmsPageShell account={account} title="Gäste">
<AccountNotFound />
</CmsPageShell>
);
}
const api = createBookingManagementApi(client); const api = createBookingManagementApi(client);
const guests = await api.listGuests(acct.id); const guests = await api.listGuests(acct.id);
@@ -32,10 +39,7 @@ export default async function GuestsPage({ params }: PageProps) {
<CmsPageShell account={account} title="Gäste"> <CmsPageShell account={account} title="Gäste">
<div className="flex w-full flex-col gap-6"> <div className="flex w-full flex-col gap-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <p className="text-muted-foreground">Gästeverwaltung</p>
<h1 className="text-2xl font-bold">Gäste</h1>
<p className="text-muted-foreground">Gästeverwaltung</p>
</div>
<Button> <Button>
<Plus className="mr-2 h-4 w-4" /> <Plus className="mr-2 h-4 w-4" />
Neuer Gast Neuer Gast

View File

@@ -2,6 +2,7 @@ import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { createBookingManagementApi } from '@kit/booking-management/api'; import { createBookingManagementApi } from '@kit/booking-management/api';
import { CreateBookingForm } from '@kit/booking-management/components'; import { CreateBookingForm } from '@kit/booking-management/components';
import { CmsPageShell } from '~/components/cms-page-shell'; import { CmsPageShell } from '~/components/cms-page-shell';
import { AccountNotFound } from '~/components/account-not-found';
interface Props { params: Promise<{ account: string }> } interface Props { params: Promise<{ account: string }> }
@@ -9,7 +10,13 @@ export default async function NewBookingPage({ params }: Props) {
const { account } = await params; const { account } = await params;
const client = getSupabaseServerClient(); const client = getSupabaseServerClient();
const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single(); const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single();
if (!acct) return <div>Konto nicht gefunden</div>; if (!acct) {
return (
<CmsPageShell account={account} title="Neue Buchung">
<AccountNotFound />
</CmsPageShell>
);
}
const api = createBookingManagementApi(client); const api = createBookingManagementApi(client);
const rooms = await api.listRooms(acct.id); const rooms = await api.listRooms(acct.id);

View File

@@ -1,22 +1,27 @@
import Link from 'next/link'; import Link from 'next/link';
import { BedDouble, CalendarCheck, Plus, Euro } from 'lucide-react'; import { BedDouble, CalendarCheck, Plus, Euro, Search } from 'lucide-react';
import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Badge } from '@kit/ui/badge'; import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button'; import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { Input } from '@kit/ui/input';
import { createBookingManagementApi } from '@kit/booking-management/api'; import { createBookingManagementApi } from '@kit/booking-management/api';
import { CmsPageShell } from '~/components/cms-page-shell'; import { CmsPageShell } from '~/components/cms-page-shell';
import { EmptyState } from '~/components/empty-state'; import { EmptyState } from '~/components/empty-state';
import { StatsCard } from '~/components/stats-card'; import { StatsCard } from '~/components/stats-card';
import { AccountNotFound } from '~/components/account-not-found';
interface PageProps { interface PageProps {
params: Promise<{ account: string }>; params: Promise<{ account: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
} }
const PAGE_SIZE = 25;
const STATUS_BADGE_VARIANT: Record< const STATUS_BADGE_VARIANT: Record<
string, string,
'secondary' | 'default' | 'info' | 'outline' | 'destructive' 'secondary' | 'default' | 'info' | 'outline' | 'destructive'
@@ -37,8 +42,9 @@ const STATUS_LABEL: Record<string, string> = {
no_show: 'Nicht erschienen', no_show: 'Nicht erschienen',
}; };
export default async function BookingsPage({ params }: PageProps) { export default async function BookingsPage({ params, searchParams }: PageProps) {
const { account } = await params; const { account } = await params;
const search = await searchParams;
const client = getSupabaseServerClient(); const client = getSupabaseServerClient();
const { data: acct } = await client const { data: acct } = await client
@@ -47,31 +53,70 @@ export default async function BookingsPage({ params }: PageProps) {
.eq('slug', account) .eq('slug', account)
.single(); .single();
if (!acct) return <div>Konto nicht gefunden</div>; if (!acct) {
return (
<CmsPageShell account={account} title="Buchungen">
<AccountNotFound />
</CmsPageShell>
);
}
const searchQuery = typeof search.q === 'string' ? search.q : '';
const page = Number(search.page) || 1;
const api = createBookingManagementApi(client); const api = createBookingManagementApi(client);
const rooms = await api.listRooms(acct.id);
const [rooms, bookings] = await Promise.all([ // Fetch bookings with joined room & guest names (avoids displaying raw UUIDs)
api.listRooms(acct.id), const bookingsQuery = client
api.listBookings(acct.id, { page: 1 }), .from('bookings')
]); .select(
'*, room:rooms(id, room_number, name), guest:guests(id, first_name, last_name)',
{ count: 'exact' },
)
.eq('account_id', acct.id)
.order('check_in', { ascending: false })
.range((page - 1) * PAGE_SIZE, page * PAGE_SIZE - 1);
const activeBookings = bookings.data.filter( const { data: bookingsRaw, count: bookingsTotal } = await bookingsQuery;
(b: Record<string, unknown>) =>
b.status === 'confirmed' || b.status === 'checked_in', /* eslint-disable @typescript-eslint/no-explicit-any */
let bookingsData = (bookingsRaw ?? []) as Array<Record<string, any>>;
const total = bookingsTotal ?? 0;
// Post-filter by search query (guest name or room name/number)
if (searchQuery) {
const q = searchQuery.toLowerCase();
bookingsData = bookingsData.filter((b) => {
const room = b.room as Record<string, string> | null;
const guest = b.guest as Record<string, string> | null;
const roomName = (room?.name ?? '').toLowerCase();
const roomNumber = (room?.room_number ?? '').toLowerCase();
const guestFirst = (guest?.first_name ?? '').toLowerCase();
const guestLast = (guest?.last_name ?? '').toLowerCase();
return (
roomName.includes(q) ||
roomNumber.includes(q) ||
guestFirst.includes(q) ||
guestLast.includes(q)
);
});
}
const activeBookings = bookingsData.filter(
(b) => b.status === 'confirmed' || b.status === 'checked_in',
); );
const totalPages = Math.ceil(total / PAGE_SIZE);
return ( return (
<CmsPageShell account={account} title="Buchungen"> <CmsPageShell account={account} title="Buchungen">
<div className="flex w-full flex-col gap-6"> <div className="flex w-full flex-col gap-6">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <p className="text-muted-foreground">
<h1 className="text-2xl font-bold">Buchungen</h1> Zimmer und Buchungen verwalten
<p className="text-muted-foreground"> </p>
Zimmer und Buchungen verwalten
</p>
</div>
<Link href={`/home/${account}/bookings/new`}> <Link href={`/home/${account}/bookings/new`}>
<Button> <Button>
@@ -95,24 +140,61 @@ export default async function BookingsPage({ params }: PageProps) {
/> />
<StatsCard <StatsCard
title="Gesamt" title="Gesamt"
value={bookings.total} value={total}
icon={<Euro className="h-5 w-5" />} icon={<Euro className="h-5 w-5" />}
/> />
</div> </div>
{/* Search */}
<form className="flex items-center gap-2">
<div className="relative max-w-sm flex-1">
<Search className="absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
name="q"
defaultValue={searchQuery}
placeholder="Gast oder Zimmer suchen…"
className="pl-9"
/>
</div>
<Button type="submit" variant="secondary" size="sm">
Suchen
</Button>
{searchQuery && (
<Link href={`/home/${account}/bookings`}>
<Button type="button" variant="ghost" size="sm">
Zurücksetzen
</Button>
</Link>
)}
</form>
{/* Table or Empty State */} {/* Table or Empty State */}
{bookings.data.length === 0 ? ( {bookingsData.length === 0 ? (
<EmptyState <EmptyState
icon={<BedDouble className="h-8 w-8" />} icon={<BedDouble className="h-8 w-8" />}
title="Keine Buchungen vorhanden" title={
description="Erstellen Sie Ihre erste Buchung, um loszulegen." searchQuery
actionLabel="Neue Buchung" ? 'Keine Buchungen gefunden'
actionHref={`/home/${account}/bookings/new`} : '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`
}
/> />
) : ( ) : (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Alle Buchungen ({bookings.total})</CardTitle> <CardTitle>
{searchQuery
? `Ergebnisse (${bookingsData.length})`
: `Alle Buchungen (${total})`}
</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="rounded-md border"> <div className="rounded-md border">
@@ -128,51 +210,104 @@ export default async function BookingsPage({ params }: PageProps) {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{bookings.data.map((booking: Record<string, unknown>) => ( {bookingsData.map((booking) => {
<tr const room = booking.room as Record<string, string> | null;
key={String(booking.id)} const guest = booking.guest as Record<string, string> | null;
className="border-b hover:bg-muted/30"
> return (
<td className="p-3"> <tr
<Link key={String(booking.id)}
href={`/home/${account}/bookings/${String(booking.id)}`} className="border-b hover:bg-muted/30"
className="font-medium hover:underline" >
> <td className="p-3">
{String(booking.room_id ?? '—')} <Link
</Link> href={`/home/${account}/bookings/${String(booking.id)}`}
</td> className="font-medium hover:underline"
<td className="p-3"> >
{String(booking.guest_id ?? '—')} {room
</td> ? `${room.room_number}${room.name ? ` ${room.name}` : ''}`
<td className="p-3"> : '—'}
{booking.check_in </Link>
? new Date(String(booking.check_in)).toLocaleDateString('de-DE') </td>
: '—'} <td className="p-3">
</td> {guest
<td className="p-3"> ? `${guest.first_name} ${guest.last_name}`
{booking.check_out : '—'}
? new Date(String(booking.check_out)).toLocaleDateString('de-DE') </td>
: '—'} <td className="p-3">
</td> {booking.check_in
<td className="p-3"> ? new Date(
<Badge String(booking.check_in),
variant={ ).toLocaleDateString('de-DE')
STATUS_BADGE_VARIANT[String(booking.status)] ?? 'secondary' : '—'}
} </td>
> <td className="p-3">
{STATUS_LABEL[String(booking.status)] ?? String(booking.status)} {booking.check_out
</Badge> ? new Date(
</td> String(booking.check_out),
<td className="p-3 text-right"> ).toLocaleDateString('de-DE')
{booking.total_price != null : '—'}
? `${Number(booking.total_price).toFixed(2)}` </td>
: '—'} <td className="p-3">
</td> <Badge
</tr> variant={
))} STATUS_BADGE_VARIANT[String(booking.status)] ??
'secondary'
}
>
{STATUS_LABEL[String(booking.status)] ??
String(booking.status)}
</Badge>
</td>
<td className="p-3 text-right">
{booking.total_price != null
? `${Number(booking.total_price).toFixed(2)}`
: '—'}
</td>
</tr>
);
})}
</tbody> </tbody>
</table> </table>
</div> </div>
{/* Pagination */}
{totalPages > 1 && !searchQuery && (
<div className="flex items-center justify-between border-t px-2 py-4">
<p className="text-sm text-muted-foreground">
Seite {page} von {totalPages} ({total} Einträge)
</p>
<div className="flex items-center gap-2">
{page > 1 ? (
<Link
href={`/home/${account}/bookings?page=${page - 1}`}
>
<Button variant="outline" size="sm">
Zurück
</Button>
</Link>
) : (
<Button variant="outline" size="sm" disabled>
Zurück
</Button>
)}
{page < totalPages ? (
<Link
href={`/home/${account}/bookings?page=${page + 1}`}
>
<Button variant="outline" size="sm">
Weiter
</Button>
</Link>
) : (
<Button variant="outline" size="sm" disabled>
Weiter
</Button>
)}
</div>
</div>
)}
</CardContent> </CardContent>
</Card> </Card>
)} )}

View File

@@ -9,6 +9,7 @@ import { createBookingManagementApi } from '@kit/booking-management/api';
import { CmsPageShell } from '~/components/cms-page-shell'; import { CmsPageShell } from '~/components/cms-page-shell';
import { EmptyState } from '~/components/empty-state'; import { EmptyState } from '~/components/empty-state';
import { AccountNotFound } from '~/components/account-not-found';
interface PageProps { interface PageProps {
params: Promise<{ account: string }>; params: Promise<{ account: string }>;
@@ -24,7 +25,13 @@ export default async function RoomsPage({ params }: PageProps) {
.eq('slug', account) .eq('slug', account)
.single(); .single();
if (!acct) return <div>Konto nicht gefunden</div>; if (!acct) {
return (
<CmsPageShell account={account} title="Zimmer">
<AccountNotFound />
</CmsPageShell>
);
}
const api = createBookingManagementApi(client); const api = createBookingManagementApi(client);
const rooms = await api.listRooms(acct.id); const rooms = await api.listRooms(acct.id);
@@ -33,10 +40,7 @@ export default async function RoomsPage({ params }: PageProps) {
<CmsPageShell account={account} title="Zimmer"> <CmsPageShell account={account} title="Zimmer">
<div className="flex w-full flex-col gap-6"> <div className="flex w-full flex-col gap-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <p className="text-muted-foreground">Zimmerverwaltung</p>
<h1 className="text-2xl font-bold">Zimmer</h1>
<p className="text-muted-foreground">Zimmerverwaltung</p>
</div>
<Button> <Button>
<Plus className="mr-2 h-4 w-4" /> <Plus className="mr-2 h-4 w-4" />
Neues Zimmer Neues Zimmer

View File

@@ -10,6 +10,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { createCourseManagementApi } from '@kit/course-management/api'; import { createCourseManagementApi } from '@kit/course-management/api';
import { CmsPageShell } from '~/components/cms-page-shell'; import { CmsPageShell } from '~/components/cms-page-shell';
import { AccountNotFound } from '~/components/account-not-found';
interface PageProps { interface PageProps {
params: Promise<{ account: string }>; params: Promise<{ account: string }>;
@@ -51,7 +52,7 @@ export default async function CourseCalendarPage({ params }: PageProps) {
.eq('slug', account) .eq('slug', account)
.single(); .single();
if (!acct) return <div>Konto nicht gefunden</div>; if (!acct) return <AccountNotFound />;
const api = createCourseManagementApi(client); const api = createCourseManagementApi(client);
const courses = await api.listCourses(acct.id, { page: 1, pageSize: 100 }); const courses = await api.listCourses(acct.id, { page: 1, pageSize: 100 });
@@ -119,12 +120,9 @@ export default async function CourseCalendarPage({ params }: PageProps) {
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
</Button> </Button>
</Link> </Link>
<div> <p className="text-muted-foreground">
<h1 className="text-2xl font-bold">Kurskalender</h1> Kurstermine im Überblick
<p className="text-muted-foreground"> </p>
Kurstermine im Überblick
</p>
</div>
</div> </div>
</div> </div>

View File

@@ -8,6 +8,7 @@ import { createCourseManagementApi } from '@kit/course-management/api';
import { CmsPageShell } from '~/components/cms-page-shell'; import { CmsPageShell } from '~/components/cms-page-shell';
import { EmptyState } from '~/components/empty-state'; import { EmptyState } from '~/components/empty-state';
import { AccountNotFound } from '~/components/account-not-found';
interface PageProps { interface PageProps {
params: Promise<{ account: string }>; params: Promise<{ account: string }>;
@@ -23,7 +24,7 @@ export default async function CategoriesPage({ params }: PageProps) {
.eq('slug', account) .eq('slug', account)
.single(); .single();
if (!acct) return <div>Konto nicht gefunden</div>; if (!acct) return <AccountNotFound />;
const api = createCourseManagementApi(client); const api = createCourseManagementApi(client);
const categories = await api.listCategories(acct.id); const categories = await api.listCategories(acct.id);
@@ -32,10 +33,7 @@ export default async function CategoriesPage({ params }: PageProps) {
<CmsPageShell account={account} title="Kategorien"> <CmsPageShell account={account} title="Kategorien">
<div className="flex w-full flex-col gap-6"> <div className="flex w-full flex-col gap-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <p className="text-muted-foreground">Kurskategorien verwalten</p>
<h1 className="text-2xl font-bold">Kategorien</h1>
<p className="text-muted-foreground">Kurskategorien verwalten</p>
</div>
<Button> <Button>
<Plus className="mr-2 h-4 w-4" /> <Plus className="mr-2 h-4 w-4" />
Neue Kategorie Neue Kategorie

View File

@@ -8,6 +8,7 @@ import { createCourseManagementApi } from '@kit/course-management/api';
import { CmsPageShell } from '~/components/cms-page-shell'; import { CmsPageShell } from '~/components/cms-page-shell';
import { EmptyState } from '~/components/empty-state'; import { EmptyState } from '~/components/empty-state';
import { AccountNotFound } from '~/components/account-not-found';
interface PageProps { interface PageProps {
params: Promise<{ account: string }>; params: Promise<{ account: string }>;
@@ -23,7 +24,7 @@ export default async function InstructorsPage({ params }: PageProps) {
.eq('slug', account) .eq('slug', account)
.single(); .single();
if (!acct) return <div>Konto nicht gefunden</div>; if (!acct) return <AccountNotFound />;
const api = createCourseManagementApi(client); const api = createCourseManagementApi(client);
const instructors = await api.listInstructors(acct.id); const instructors = await api.listInstructors(acct.id);
@@ -32,10 +33,7 @@ export default async function InstructorsPage({ params }: PageProps) {
<CmsPageShell account={account} title="Dozenten"> <CmsPageShell account={account} title="Dozenten">
<div className="flex w-full flex-col gap-6"> <div className="flex w-full flex-col gap-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <p className="text-muted-foreground">Dozentenpool verwalten</p>
<h1 className="text-2xl font-bold">Dozenten</h1>
<p className="text-muted-foreground">Dozentenpool verwalten</p>
</div>
<Button> <Button>
<Plus className="mr-2 h-4 w-4" /> <Plus className="mr-2 h-4 w-4" />
Neuer Dozent Neuer Dozent

View File

@@ -8,6 +8,7 @@ import { createCourseManagementApi } from '@kit/course-management/api';
import { CmsPageShell } from '~/components/cms-page-shell'; import { CmsPageShell } from '~/components/cms-page-shell';
import { EmptyState } from '~/components/empty-state'; import { EmptyState } from '~/components/empty-state';
import { AccountNotFound } from '~/components/account-not-found';
interface PageProps { interface PageProps {
params: Promise<{ account: string }>; params: Promise<{ account: string }>;
@@ -23,7 +24,7 @@ export default async function LocationsPage({ params }: PageProps) {
.eq('slug', account) .eq('slug', account)
.single(); .single();
if (!acct) return <div>Konto nicht gefunden</div>; if (!acct) return <AccountNotFound />;
const api = createCourseManagementApi(client); const api = createCourseManagementApi(client);
const locations = await api.listLocations(acct.id); const locations = await api.listLocations(acct.id);
@@ -32,10 +33,7 @@ export default async function LocationsPage({ params }: PageProps) {
<CmsPageShell account={account} title="Orte"> <CmsPageShell account={account} title="Orte">
<div className="flex w-full flex-col gap-6"> <div className="flex w-full flex-col gap-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <p className="text-muted-foreground">Kurs- und Veranstaltungsorte verwalten</p>
<h1 className="text-2xl font-bold">Orte</h1>
<p className="text-muted-foreground">Kurs- und Veranstaltungsorte verwalten</p>
</div>
<Button> <Button>
<Plus className="mr-2 h-4 w-4" /> <Plus className="mr-2 h-4 w-4" />
Neuer Ort Neuer Ort

View File

@@ -1,6 +1,7 @@
import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { CreateCourseForm } from '@kit/course-management/components'; import { CreateCourseForm } from '@kit/course-management/components';
import { CmsPageShell } from '~/components/cms-page-shell'; import { CmsPageShell } from '~/components/cms-page-shell';
import { AccountNotFound } from '~/components/account-not-found';
interface Props { params: Promise<{ account: string }> } interface Props { params: Promise<{ account: string }> }
@@ -8,7 +9,7 @@ export default async function NewCoursePage({ params }: Props) {
const { account } = await params; const { account } = await params;
const client = getSupabaseServerClient(); const client = getSupabaseServerClient();
const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single(); const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single();
if (!acct) return <div>Konto nicht gefunden</div>; if (!acct) return <AccountNotFound />;
return ( return (
<CmsPageShell account={account} title="Neuer Kurs" description="Kurs anlegen"> <CmsPageShell account={account} title="Neuer Kurs" description="Kurs anlegen">

View File

@@ -1,6 +1,6 @@
import Link from 'next/link'; import Link from 'next/link';
import { GraduationCap, Plus, Users, Calendar, Euro } from 'lucide-react'; import { ChevronLeft, ChevronRight, GraduationCap, Plus, Users, Calendar, Euro } from 'lucide-react';
import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Badge } from '@kit/ui/badge'; import { Badge } from '@kit/ui/badge';
@@ -12,32 +12,19 @@ import { createCourseManagementApi } from '@kit/course-management/api';
import { CmsPageShell } from '~/components/cms-page-shell'; import { CmsPageShell } from '~/components/cms-page-shell';
import { EmptyState } from '~/components/empty-state'; import { EmptyState } from '~/components/empty-state';
import { StatsCard } from '~/components/stats-card'; import { StatsCard } from '~/components/stats-card';
import { AccountNotFound } from '~/components/account-not-found';
import { COURSE_STATUS_VARIANT, COURSE_STATUS_LABEL } from '~/lib/status-badges';
interface PageProps { interface PageProps {
params: Promise<{ account: string }>; params: Promise<{ account: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
} }
const STATUS_BADGE_VARIANT: Record< const PAGE_SIZE = 25;
string,
'secondary' | 'default' | 'info' | 'outline' | 'destructive'
> = {
planned: 'secondary',
open: 'default',
running: 'info',
completed: 'outline',
cancelled: 'destructive',
};
const STATUS_LABEL: Record<string, string> = { export default async function CoursesPage({ params, searchParams }: PageProps) {
planned: 'Geplant',
open: 'Offen',
running: 'Laufend',
completed: 'Abgeschlossen',
cancelled: 'Abgesagt',
};
export default async function CoursesPage({ params }: PageProps) {
const { account } = await params; const { account } = await params;
const search = await searchParams;
const client = getSupabaseServerClient(); const client = getSupabaseServerClient();
const { data: acct } = await client const { data: acct } = await client
@@ -46,26 +33,26 @@ export default async function CoursesPage({ params }: PageProps) {
.eq('slug', account) .eq('slug', account)
.single(); .single();
if (!acct) return <div>Konto nicht gefunden</div>; if (!acct) return <AccountNotFound />;
const api = createCourseManagementApi(client); const api = createCourseManagementApi(client);
const page = Number(search.page) || 1;
const [courses, stats] = await Promise.all([ const [courses, stats] = await Promise.all([
api.listCourses(acct.id, { page: 1 }), api.listCourses(acct.id, { page, pageSize: PAGE_SIZE }),
api.getStatistics(acct.id), api.getStatistics(acct.id),
]); ]);
const totalPages = Math.ceil(courses.total / PAGE_SIZE);
return ( return (
<CmsPageShell account={account} title="Kurse"> <CmsPageShell account={account} title="Kurse">
<div className="flex w-full flex-col gap-6"> <div className="flex w-full flex-col gap-6">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <p className="text-muted-foreground">
<h1 className="text-2xl font-bold">Kurse</h1> Kursangebot verwalten
<p className="text-muted-foreground"> </p>
Kursangebot verwalten
</p>
</div>
<Link href={`/home/${account}/courses/new`}> <Link href={`/home/${account}/courses/new`}>
<Button> <Button>
@@ -123,7 +110,7 @@ export default async function CoursesPage({ params }: PageProps) {
<th className="p-3 text-left font-medium">Beginn</th> <th className="p-3 text-left font-medium">Beginn</th>
<th className="p-3 text-left font-medium">Ende</th> <th className="p-3 text-left font-medium">Ende</th>
<th className="p-3 text-left font-medium">Status</th> <th className="p-3 text-left font-medium">Status</th>
<th className="p-3 text-right font-medium">Teilnehmer</th> <th className="p-3 text-right font-medium">Kapazität</th>
<th className="p-3 text-right font-medium">Gebühr</th> <th className="p-3 text-right font-medium">Gebühr</th>
</tr> </tr>
</thead> </thead>
@@ -153,13 +140,15 @@ export default async function CoursesPage({ params }: PageProps) {
</td> </td>
<td className="p-3"> <td className="p-3">
<Badge <Badge
variant={STATUS_BADGE_VARIANT[String(course.status)] ?? 'secondary'} variant={COURSE_STATUS_VARIANT[String(course.status)] ?? 'secondary'}
> >
{STATUS_LABEL[String(course.status)] ?? String(course.status)} {COURSE_STATUS_LABEL[String(course.status)] ?? String(course.status)}
</Badge> </Badge>
</td> </td>
<td className="p-3 text-right"> <td className="p-3 text-right">
{String(course.capacity ?? '—')} {course.capacity != null
? String(course.capacity)
: '—'}
</td> </td>
<td className="p-3 text-right"> <td className="p-3 text-right">
{course.fee != null {course.fee != null
@@ -171,6 +160,44 @@ export default async function CoursesPage({ params }: PageProps) {
</tbody> </tbody>
</table> </table>
</div> </div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between border-t px-2 py-4">
<p className="text-sm text-muted-foreground">
Seite {page} von {totalPages} ({courses.total} Einträge)
</p>
<div className="flex items-center gap-2">
{page > 1 ? (
<Link href={`/home/${account}/courses?page=${page - 1}`}>
<Button variant="outline" size="sm">
<ChevronLeft className="mr-1 h-4 w-4" />
Zurück
</Button>
</Link>
) : (
<Button variant="outline" size="sm" disabled>
<ChevronLeft className="mr-1 h-4 w-4" />
Zurück
</Button>
)}
{page < totalPages ? (
<Link href={`/home/${account}/courses?page=${page + 1}`}>
<Button variant="outline" size="sm">
Weiter
<ChevronRight className="ml-1 h-4 w-4" />
</Button>
</Link>
) : (
<Button variant="outline" size="sm" disabled>
Weiter
<ChevronRight className="ml-1 h-4 w-4" />
</Button>
)}
</div>
</div>
)}
</CardContent> </CardContent>
</Card> </Card>
)} )}

View File

@@ -8,6 +8,7 @@ import { createCourseManagementApi } from '@kit/course-management/api';
import { CmsPageShell } from '~/components/cms-page-shell'; import { CmsPageShell } from '~/components/cms-page-shell';
import { StatsCard } from '~/components/stats-card'; import { StatsCard } from '~/components/stats-card';
import { StatsBarChart, StatsPieChart } from '~/components/stats-charts'; import { StatsBarChart, StatsPieChart } from '~/components/stats-charts';
import { AccountNotFound } from '~/components/account-not-found';
interface PageProps { interface PageProps {
params: Promise<{ account: string }>; params: Promise<{ account: string }>;
@@ -18,7 +19,7 @@ export default async function CourseStatisticsPage({ params }: PageProps) {
const client = getSupabaseServerClient(); const client = getSupabaseServerClient();
const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single(); const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single();
if (!acct) return <div>Konto nicht gefunden</div>; if (!acct) return <AccountNotFound />;
const api = createCourseManagementApi(client); const api = createCourseManagementApi(client);
const stats = await api.getStatistics(acct.id); const stats = await api.getStatistics(acct.id);

View File

@@ -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<string, string> = {
'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<GenerateDocumentResult | null>(null);
const [selectedType, setSelectedType] = useState(initialType);
const isComingSoon = COMING_SOON_TYPES.has(selectedType);
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
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 (
<form className="flex flex-col gap-5" onSubmit={handleSubmit}>
{/* Document Type */}
<div className="flex flex-col gap-2">
<Label htmlFor="documentType">Dokumenttyp</Label>
<select
id="documentType"
name="documentType"
value={selectedType}
onChange={(e) => {
setSelectedType(e.target.value);
setResult(null);
}}
className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
>
<option value="member-card">Mitgliedsausweis</option>
<option value="invoice">Rechnung</option>
<option value="labels">Etiketten</option>
<option value="report">Bericht</option>
<option value="letter">Brief</option>
<option value="certificate">Zertifikat</option>
</select>
</div>
{/* Coming soon banner */}
{isComingSoon && (
<div className="flex items-start gap-3 rounded-md border border-amber-200 bg-amber-50 p-4 text-sm text-amber-800 dark:border-amber-800 dark:bg-amber-950/40 dark:text-amber-200">
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
<div>
<p className="font-medium">Demnächst verfügbar</p>
<p className="mt-1 text-amber-700 dark:text-amber-300">
Die Generierung von &ldquo;{DOCUMENT_LABELS[selectedType]}&rdquo;
befindet sich noch in Entwicklung und wird in Kürze verfügbar sein.
</p>
</div>
</div>
)}
{/* Title */}
<div className="flex flex-col gap-2">
<Label htmlFor="title">Titel / Bezeichnung</Label>
<Input
id="title"
name="title"
placeholder={`z.B. ${DOCUMENT_LABELS[selectedType] ?? 'Dokument'} ${new Date().getFullYear()}`}
required
disabled={isPending}
/>
</div>
{/* Format & Orientation */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="flex flex-col gap-2">
<Label htmlFor="format">Format</Label>
<select
id="format"
name="format"
disabled={isPending}
className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:opacity-50"
>
<option value="A4">A4</option>
<option value="A5">A5</option>
<option value="letter">Letter</option>
</select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="orientation">Ausrichtung</Label>
<select
id="orientation"
name="orientation"
disabled={isPending}
className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:opacity-50"
>
<option value="portrait">Hochformat</option>
<option value="landscape">Querformat</option>
</select>
</div>
</div>
{/* Hint */}
<div className="text-muted-foreground rounded-md bg-muted/50 p-4 text-sm">
<p>
<strong>Hinweis:</strong>{' '}
{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.'}
</p>
</div>
{/* Result feedback */}
{result && !result.success && (
<div className="flex items-start gap-3 rounded-md border border-red-200 bg-red-50 p-4 text-sm text-red-800 dark:border-red-800 dark:bg-red-950/40 dark:text-red-200">
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
<div>
<p className="font-medium">Fehler bei der Generierung</p>
<p className="mt-1">{result.error}</p>
</div>
</div>
)}
{result && result.success && (
<div className="flex items-start gap-3 rounded-md border border-green-200 bg-green-50 p-4 text-sm text-green-800 dark:border-green-800 dark:bg-green-950/40 dark:text-green-200">
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0" />
<div>
<p className="font-medium">Dokument erfolgreich erstellt!</p>
<p className="mt-1">
Die Datei &ldquo;{result.fileName}&rdquo; wurde heruntergeladen.
</p>
{result.data && result.mimeType && result.fileName && (
<button
type="button"
className="mt-2 text-green-700 underline hover:text-green-900 dark:text-green-300 dark:hover:text-green-100"
onClick={() =>
downloadFile(result.data!, result.mimeType!, result.fileName!)
}
>
Erneut herunterladen
</button>
)}
</div>
</div>
)}
{/* Submit button */}
<div className="flex justify-end">
<Button type="submit" disabled={isPending || isComingSoon}>
{isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Wird generiert
</>
) : (
<>
<FileDown className="mr-2 h-4 w-4" />
Generieren
</>
)}
</Button>
</div>
</form>
);
}
/**
* 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);
}

View File

@@ -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<GenerateDocumentResult> {
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<string, string> = {
'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<typeof getSupabaseServerClient>,
accountId: string,
accountName: string,
input: GenerateDocumentInput,
): Promise<GenerateDocumentResult> {
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<typeof getSupabaseServerClient>,
accountId: string,
input: GenerateDocumentInput,
): Promise<GenerateDocumentResult> {
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<typeof getSupabaseServerClient>,
accountId: string,
input: GenerateDocumentInput,
): Promise<GenerateDocumentResult> {
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<string, string> = { 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`,
};
}

View File

@@ -1,6 +1,6 @@
import Link from 'next/link'; import Link from 'next/link';
import { ArrowLeft, FileDown } from 'lucide-react'; import { ArrowLeft } from 'lucide-react';
import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Button } from '@kit/ui/button'; import { Button } from '@kit/ui/button';
@@ -12,11 +12,12 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from '@kit/ui/card'; } from '@kit/ui/card';
import { Input } from '@kit/ui/input';
import { Label } from '@kit/ui/label';
import { CmsPageShell } from '~/components/cms-page-shell'; import { CmsPageShell } from '~/components/cms-page-shell';
import { GenerateDocumentForm } from '../_components/generate-document-form';
import { AccountNotFound } from '~/components/account-not-found';
interface PageProps { interface PageProps {
params: Promise<{ account: string }>; params: Promise<{ account: string }>;
searchParams: Promise<{ type?: string }>; searchParams: Promise<{ type?: string }>;
@@ -45,7 +46,7 @@ export default async function GenerateDocumentPage({
.eq('slug', account) .eq('slug', account)
.single(); .single();
if (!acct) return <div>Konto nicht gefunden</div>; if (!acct) return <AccountNotFound />;
const selectedType = type ?? 'member-card'; const selectedType = type ?? 'member-card';
const selectedLabel = DOCUMENT_LABELS[selectedType] ?? 'Dokument'; const selectedLabel = DOCUMENT_LABELS[selectedType] ?? 'Dokument';
@@ -73,82 +74,16 @@ export default async function GenerateDocumentPage({
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<form className="flex flex-col gap-5"> <GenerateDocumentForm
{/* Document Type */} accountSlug={account}
<div className="flex flex-col gap-2"> initialType={selectedType}
<Label htmlFor="documentType">Dokumenttyp</Label> />
<select
id="documentType"
name="documentType"
defaultValue={selectedType}
className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
>
<option value="member-card">Mitgliedsausweis</option>
<option value="invoice">Rechnung</option>
<option value="labels">Etiketten</option>
<option value="report">Bericht</option>
<option value="letter">Brief</option>
<option value="certificate">Zertifikat</option>
</select>
</div>
{/* Title */}
<div className="flex flex-col gap-2">
<Label htmlFor="title">Titel / Bezeichnung</Label>
<Input
id="title"
name="title"
placeholder={`z.B. ${selectedLabel} für Max Mustermann`}
required
/>
</div>
{/* Format */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="flex flex-col gap-2">
<Label htmlFor="format">Format</Label>
<select
id="format"
name="format"
className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
>
<option value="A4">A4</option>
<option value="A5">A5</option>
<option value="letter">Letter</option>
</select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="orientation">Ausrichtung</Label>
<select
id="orientation"
name="orientation"
className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
>
<option value="portrait">Hochformat</option>
<option value="landscape">Querformat</option>
</select>
</div>
</div>
{/* Info */}
<div className="rounded-md bg-muted/50 p-4 text-sm text-muted-foreground">
<p>
<strong>Hinweis:</strong> Die Dokumentgenerierung verwendet
Ihre gespeicherten Vorlagen. Stellen Sie sicher, dass eine
passende Vorlage für den gewählten Dokumenttyp existiert.
</p>
</div>
</form>
</CardContent> </CardContent>
<CardFooter className="flex justify-between"> <CardFooter>
<Link href={`/home/${account}/documents`}> <Link href={`/home/${account}/documents`}>
<Button variant="outline">Abbrechen</Button> <Button variant="outline">Abbrechen</Button>
</Link> </Link>
<Button type="submit">
<FileDown className="mr-2 h-4 w-4" />
Generieren
</Button>
</CardFooter> </CardFooter>
</Card> </Card>
</div> </div>

View File

@@ -14,6 +14,7 @@ import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { CmsPageShell } from '~/components/cms-page-shell'; import { CmsPageShell } from '~/components/cms-page-shell';
import { AccountNotFound } from '~/components/account-not-found';
interface PageProps { interface PageProps {
params: Promise<{ account: string }>; params: Promise<{ account: string }>;
@@ -80,25 +81,16 @@ export default async function DocumentsPage({ params }: PageProps) {
.eq('slug', account) .eq('slug', account)
.single(); .single();
if (!acct) return <div>Konto nicht gefunden</div>; if (!acct) return <AccountNotFound />;
return ( return (
<CmsPageShell account={account} title="Dokumente"> <CmsPageShell account={account} title="Dokumente" description="Dokumente erstellen und verwalten">
<div className="flex w-full flex-col gap-6"> <div className="flex w-full flex-col gap-6">
{/* Header */} {/* Actions */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-end">
<div> <Link href={`/home/${account}/documents/templates`}>
<h1 className="text-2xl font-bold">Dokumente</h1> <Button variant="outline">Vorlagen verwalten</Button>
<p className="text-muted-foreground"> </Link>
Dokumente erstellen und verwalten
</p>
</div>
<div className="flex gap-2">
<Link href={`/home/${account}/documents/templates`}>
<Button variant="outline">Vorlagen</Button>
</Link>
</div>
</div> </div>
{/* Document Type Grid */} {/* Document Type Grid */}

View File

@@ -8,6 +8,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { CmsPageShell } from '~/components/cms-page-shell'; import { CmsPageShell } from '~/components/cms-page-shell';
import { EmptyState } from '~/components/empty-state'; import { EmptyState } from '~/components/empty-state';
import { AccountNotFound } from '~/components/account-not-found';
interface PageProps { interface PageProps {
params: Promise<{ account: string }>; params: Promise<{ account: string }>;
@@ -23,7 +24,7 @@ export default async function DocumentTemplatesPage({ params }: PageProps) {
.eq('slug', account) .eq('slug', account)
.single(); .single();
if (!acct) return <div>Konto nicht gefunden</div>; if (!acct) return <AccountNotFound />;
// Document templates are stored locally for now — placeholder for future DB integration // Document templates are stored locally for now — placeholder for future DB integration
const templates: Array<{ const templates: Array<{

View File

@@ -1,5 +1,6 @@
import { Ticket, Plus } from 'lucide-react'; import { Ticket, Plus } from 'lucide-react';
import { getTranslations } from 'next-intl/server';
import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Button } from '@kit/ui/button'; import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card'; 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 { CmsPageShell } from '~/components/cms-page-shell';
import { EmptyState } from '~/components/empty-state'; import { EmptyState } from '~/components/empty-state';
import { AccountNotFound } from '~/components/account-not-found';
interface PageProps { interface PageProps {
params: Promise<{ account: string }>; params: Promise<{ account: string }>;
@@ -16,6 +18,7 @@ interface PageProps {
export default async function HolidayPassesPage({ params }: PageProps) { export default async function HolidayPassesPage({ params }: PageProps) {
const { account } = await params; const { account } = await params;
const client = getSupabaseServerClient(); const client = getSupabaseServerClient();
const t = await getTranslations('cms.events');
const { data: acct } = await client const { data: acct } = await client
.from('accounts') .from('accounts')
@@ -23,47 +26,47 @@ export default async function HolidayPassesPage({ params }: PageProps) {
.eq('slug', account) .eq('slug', account)
.single(); .single();
if (!acct) return <div>Konto nicht gefunden</div>; if (!acct) return <AccountNotFound />;
const api = createEventManagementApi(client); const api = createEventManagementApi(client);
const passes = await api.listHolidayPasses(acct.id); const passes = await api.listHolidayPasses(acct.id);
return ( return (
<CmsPageShell account={account} title="Ferienpässe"> <CmsPageShell account={account} title={t('holidayPasses')}>
<div className="flex w-full flex-col gap-6"> <div className="flex w-full flex-col gap-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-2xl font-bold">Ferienpässe</h1> <h1 className="text-2xl font-bold">{t('holidayPasses')}</h1>
<p className="text-muted-foreground">Ferienpässe und Ferienprogramme verwalten</p> <p className="text-muted-foreground">{t('holidayPassesDescription')}</p>
</div> </div>
<Button> <Button>
<Plus className="mr-2 h-4 w-4" /> <Plus className="mr-2 h-4 w-4" />
Neuer Ferienpass {t('newHolidayPass')}
</Button> </Button>
</div> </div>
{passes.length === 0 ? ( {passes.length === 0 ? (
<EmptyState <EmptyState
icon={<Ticket className="h-8 w-8" />} icon={<Ticket className="h-8 w-8" />}
title="Keine Ferienpässe vorhanden" title={t('noHolidayPasses')}
description="Erstellen Sie Ihren ersten Ferienpass." description={t('noHolidayPassesDescription')}
actionLabel="Neuer Ferienpass" actionLabel={t('newHolidayPass')}
/> />
) : ( ) : (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Alle Ferienpässe ({passes.length})</CardTitle> <CardTitle>{t('allHolidayPasses')} ({passes.length})</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="rounded-md border"> <div className="rounded-md border">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b bg-muted/50"> <tr className="border-b bg-muted/50">
<th className="p-3 text-left font-medium">Name</th> <th className="p-3 text-left font-medium">{t('name')}</th>
<th className="p-3 text-left font-medium">Jahr</th> <th className="p-3 text-left font-medium">{t('year')}</th>
<th className="p-3 text-right font-medium">Preis</th> <th className="p-3 text-right font-medium">{t('price')}</th>
<th className="p-3 text-left font-medium">Gültig von</th> <th className="p-3 text-left font-medium">{t('validFrom')}</th>
<th className="p-3 text-left font-medium">Gültig bis</th> <th className="p-3 text-left font-medium">{t('validUntil')}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>

View File

@@ -1,17 +1,20 @@
import { getTranslations } from 'next-intl/server';
import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { CreateEventForm } from '@kit/event-management/components'; import { CreateEventForm } from '@kit/event-management/components';
import { CmsPageShell } from '~/components/cms-page-shell'; import { CmsPageShell } from '~/components/cms-page-shell';
import { AccountNotFound } from '~/components/account-not-found';
interface Props { params: Promise<{ account: string }> } interface Props { params: Promise<{ account: string }> }
export default async function NewEventPage({ params }: Props) { export default async function NewEventPage({ params }: Props) {
const { account } = await params; const { account } = await params;
const client = getSupabaseServerClient(); const client = getSupabaseServerClient();
const t = await getTranslations('cms.events');
const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single(); const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single();
if (!acct) return <div>Konto nicht gefunden</div>; if (!acct) return <AccountNotFound />;
return ( return (
<CmsPageShell account={account} title="Neue Veranstaltung" description="Veranstaltung oder Ferienprogramm anlegen"> <CmsPageShell account={account} title={t('newEvent')} description={t('newEventDescription')}>
<CreateEventForm accountId={acct.id} account={account} /> <CreateEventForm accountId={acct.id} account={account} />
</CmsPageShell> </CmsPageShell>
); );

View File

@@ -1,7 +1,8 @@
import Link from 'next/link'; 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 { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Badge } from '@kit/ui/badge'; import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button'; 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 { CmsPageShell } from '~/components/cms-page-shell';
import { EmptyState } from '~/components/empty-state'; import { EmptyState } from '~/components/empty-state';
import { StatsCard } from '~/components/stats-card'; 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 { interface PageProps {
params: Promise<{ account: string }>; params: Promise<{ account: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
} }
const STATUS_BADGE_VARIANT: Record< export default async function EventsPage({ params, searchParams }: PageProps) {
string,
'secondary' | 'default' | 'info' | 'outline' | 'destructive'
> = {
draft: 'secondary',
published: 'default',
registration_open: 'info',
registration_closed: 'outline',
cancelled: 'destructive',
completed: 'outline',
};
const STATUS_LABEL: Record<string, string> = {
draft: 'Entwurf',
published: 'Veröffentlicht',
registration_open: 'Anmeldung offen',
registration_closed: 'Anmeldung geschlossen',
cancelled: 'Abgesagt',
completed: 'Abgeschlossen',
};
export default async function EventsPage({ params }: PageProps) {
const { account } = await params; const { account } = await params;
const search = await searchParams;
const client = getSupabaseServerClient(); const client = getSupabaseServerClient();
const t = await getTranslations('cms.events');
const { data: acct } = await client const { data: acct } = await client
.from('accounts') .from('accounts')
@@ -48,27 +33,45 @@ export default async function EventsPage({ params }: PageProps) {
.eq('slug', account) .eq('slug', account)
.single(); .single();
if (!acct) return <div>Konto nicht gefunden</div>; if (!acct) return <AccountNotFound />;
const page = Number(search.page) || 1;
const api = createEventManagementApi(client); 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, unknown>) => String(e.id));
const registrationCounts = await api.getRegistrationCounts(eventIds);
// Pre-compute stats before rendering
const uniqueLocationCount = new Set(
events.data
.map((e: Record<string, unknown>) => e.location)
.filter(Boolean),
).size;
const totalCapacity = events.data.reduce(
(sum: number, e: Record<string, unknown>) =>
sum + (Number(e.capacity) || 0),
0,
);
return ( return (
<CmsPageShell account={account} title="Veranstaltungen"> <CmsPageShell account={account} title={t('title')}>
<div className="flex w-full flex-col gap-6"> <div className="flex w-full flex-col gap-6">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-2xl font-bold">Veranstaltungen</h1> <h1 className="text-2xl font-bold">{t('title')}</h1>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
Veranstaltungen und Ferienprogramme {t('description')}
</p> </p>
</div> </div>
<Link href={`/home/${account}/events/new`}> <Link href={`/home/${account}/events/new`}>
<Button> <Button>
<Plus className="mr-2 h-4 w-4" /> <Plus className="mr-2 h-4 w-4" />
Neue Veranstaltung {t('newEvent')}
</Button> </Button>
</Link> </Link>
</div> </div>
@@ -76,28 +79,18 @@ export default async function EventsPage({ params }: PageProps) {
{/* Stats */} {/* Stats */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<StatsCard <StatsCard
title="Veranstaltungen" title={t('title')}
value={events.total} value={events.total}
icon={<CalendarDays className="h-5 w-5" />} icon={<CalendarDays className="h-5 w-5" />}
/> />
<StatsCard <StatsCard
title="Orte" title={t('locations')}
value={ value={uniqueLocationCount}
new Set(
events.data
.map((e: Record<string, unknown>) => e.location)
.filter(Boolean),
).size
}
icon={<MapPin className="h-5 w-5" />} icon={<MapPin className="h-5 w-5" />}
/> />
<StatsCard <StatsCard
title="Kapazität gesamt" title={t('totalCapacity')}
value={events.data.reduce( value={totalCapacity}
(sum: number, e: Record<string, unknown>) =>
sum + (Number(e.capacity) || 0),
0,
)}
icon={<Users className="h-5 w-5" />} icon={<Users className="h-5 w-5" />}
/> />
</div> </div>
@@ -106,71 +99,105 @@ export default async function EventsPage({ params }: PageProps) {
{events.data.length === 0 ? ( {events.data.length === 0 ? (
<EmptyState <EmptyState
icon={<CalendarDays className="h-8 w-8" />} icon={<CalendarDays className="h-8 w-8" />}
title="Keine Veranstaltungen vorhanden" title={t('noEvents')}
description="Erstellen Sie Ihre erste Veranstaltung, um loszulegen." description={t('noEventsDescription')}
actionLabel="Neue Veranstaltung" actionLabel={t('newEvent')}
actionHref={`/home/${account}/events/new`} actionHref={`/home/${account}/events/new`}
/> />
) : ( ) : (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Alle Veranstaltungen ({events.total})</CardTitle> <CardTitle>{t('allEvents')} ({events.total})</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="rounded-md border"> <div className="rounded-md border">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b bg-muted/50"> <tr className="border-b bg-muted/50">
<th className="p-3 text-left font-medium">Name</th> <th className="p-3 text-left font-medium">{t('name')}</th>
<th className="p-3 text-left font-medium">Datum</th> <th className="p-3 text-left font-medium">{t('eventDate')}</th>
<th className="p-3 text-left font-medium">Ort</th> <th className="p-3 text-left font-medium">{t('eventLocation')}</th>
<th className="p-3 text-right font-medium">Kapazität</th> <th className="p-3 text-right font-medium">{t('capacity')}</th>
<th className="p-3 text-left font-medium">Status</th> <th className="p-3 text-left font-medium">{t('status')}</th>
<th className="p-3 text-right font-medium">Anmeldungen</th> <th className="p-3 text-right font-medium">{t('registrations')}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{events.data.map((event: Record<string, unknown>) => ( {events.data.map((event: Record<string, unknown>) => {
<tr const eventId = String(event.id);
key={String(event.id)} const regCount = registrationCounts[eventId] ?? 0;
className="border-b hover:bg-muted/30"
> return (
<td className="p-3 font-medium"> <tr
<Link key={eventId}
href={`/home/${account}/events/${String(event.id)}`} className="border-b hover:bg-muted/30"
className="hover:underline" >
> <td className="p-3 font-medium">
{String(event.name)} <Link
</Link> href={`/home/${account}/events/${eventId}`}
</td> className="hover:underline"
<td className="p-3"> >
{event.event_date {String(event.name)}
? new Date(String(event.event_date)).toLocaleDateString('de-DE') </Link>
: '—'} </td>
</td> <td className="p-3">
<td className="p-3"> {event.event_date
{String(event.location ?? '—')} ? new Date(String(event.event_date)).toLocaleDateString('de-DE')
</td> : '—'}
<td className="p-3 text-right"> </td>
{event.capacity != null <td className="p-3">
? String(event.capacity) {String(event.location ?? '—')}
: '—'} </td>
</td> <td className="p-3 text-right">
<td className="p-3"> {event.capacity != null
<Badge ? String(event.capacity)
variant={ : '—'}
STATUS_BADGE_VARIANT[String(event.status)] ?? 'secondary' </td>
} <td className="p-3">
> <Badge
{STATUS_LABEL[String(event.status)] ?? String(event.status)} variant={
</Badge> EVENT_STATUS_VARIANT[String(event.status)] ?? 'secondary'
</td> }
<td className="p-3 text-right"></td> >
</tr> {EVENT_STATUS_LABEL[String(event.status)] ?? String(event.status)}
))} </Badge>
</td>
<td className="p-3 text-right font-medium">
{regCount}
</td>
</tr>
);
})}
</tbody> </tbody>
</table> </table>
</div> </div>
{/* Pagination */}
{events.totalPages > 1 && (
<div className="flex items-center justify-between pt-4">
<span className="text-sm text-muted-foreground">
{t('paginationPage', { page: events.page, totalPages: events.totalPages })}
</span>
<div className="flex gap-2">
{events.page > 1 && (
<Link href={`/home/${account}/events?page=${events.page - 1}`}>
<Button variant="outline" size="sm">
<ChevronLeft className="mr-1 h-4 w-4" />
{t('paginationPrevious')}
</Button>
</Link>
)}
{events.page < events.totalPages && (
<Link href={`/home/${account}/events?page=${events.page + 1}`}>
<Button variant="outline" size="sm">
{t('paginationNext')}
<ChevronRight className="ml-1 h-4 w-4" />
</Button>
</Link>
)}
</div>
</div>
)}
</CardContent> </CardContent>
</Card> </Card>
)} )}

View File

@@ -2,9 +2,9 @@ import Link from 'next/link';
import { CalendarDays, ClipboardList, Users } from 'lucide-react'; import { CalendarDays, ClipboardList, Users } from 'lucide-react';
import { getTranslations } from 'next-intl/server';
import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Badge } from '@kit/ui/badge'; import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { createEventManagementApi } from '@kit/event-management/api'; 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 { CmsPageShell } from '~/components/cms-page-shell';
import { EmptyState } from '~/components/empty-state'; import { EmptyState } from '~/components/empty-state';
import { StatsCard } from '~/components/stats-card'; 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 { interface PageProps {
params: Promise<{ account: string }>; params: Promise<{ account: string }>;
@@ -20,6 +22,7 @@ interface PageProps {
export default async function EventRegistrationsPage({ params }: PageProps) { export default async function EventRegistrationsPage({ params }: PageProps) {
const { account } = await params; const { account } = await params;
const client = getSupabaseServerClient(); const client = getSupabaseServerClient();
const t = await getTranslations('cms.events');
const { data: acct } = await client const { data: acct } = await client
.from('accounts') .from('accounts')
@@ -27,7 +30,7 @@ export default async function EventRegistrationsPage({ params }: PageProps) {
.eq('slug', account) .eq('slug', account)
.single(); .single();
if (!acct) return <div>Konto nicht gefunden</div>; if (!acct) return <AccountNotFound />;
const api = createEventManagementApi(client); const api = createEventManagementApi(client);
const events = await api.listEvents(acct.id, { page: 1 }); const events = await api.listEvents(acct.id, { page: 1 });
@@ -56,30 +59,30 @@ export default async function EventRegistrationsPage({ params }: PageProps) {
); );
return ( return (
<CmsPageShell account={account} title="Anmeldungen"> <CmsPageShell account={account} title={t('registrations')}>
<div className="flex w-full flex-col gap-6"> <div className="flex w-full flex-col gap-6">
{/* Header */} {/* Header */}
<div> <div>
<h1 className="text-2xl font-bold">Anmeldungen</h1> <h1 className="text-2xl font-bold">{t('registrations')}</h1>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
Anmeldungen aller Veranstaltungen im Überblick {t('registrationsOverview')}
</p> </p>
</div> </div>
{/* Stats */} {/* Stats */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<StatsCard <StatsCard
title="Veranstaltungen" title={t('title')}
value={events.total} value={events.total}
icon={<CalendarDays className="h-5 w-5" />} icon={<CalendarDays className="h-5 w-5" />}
/> />
<StatsCard <StatsCard
title="Anmeldungen gesamt" title={t('totalRegistrations')}
value={totalRegistrations} value={totalRegistrations}
icon={<ClipboardList className="h-5 w-5" />} icon={<ClipboardList className="h-5 w-5" />}
/> />
<StatsCard <StatsCard
title="Mit Anmeldungen" title={t('withRegistrations')}
value={eventsWithRegs.length} value={eventsWithRegs.length}
icon={<Users className="h-5 w-5" />} icon={<Users className="h-5 w-5" />}
/> />
@@ -89,16 +92,16 @@ export default async function EventRegistrationsPage({ params }: PageProps) {
{eventsWithRegistrations.length === 0 ? ( {eventsWithRegistrations.length === 0 ? (
<EmptyState <EmptyState
icon={<ClipboardList className="h-8 w-8" />} icon={<ClipboardList className="h-8 w-8" />}
title="Keine Veranstaltungen vorhanden" title={t('noEvents')}
description="Erstellen Sie eine Veranstaltung, um Anmeldungen zu erhalten." description={t('noEventsForRegistrations')}
actionLabel="Neue Veranstaltung" actionLabel={t('newEvent')}
actionHref={`/home/${account}/events/new`} actionHref={`/home/${account}/events/new`}
/> />
) : ( ) : (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle> <CardTitle>
Übersicht nach Veranstaltung ({eventsWithRegistrations.length}) {t('overviewByEvent')} ({eventsWithRegistrations.length})
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@@ -107,15 +110,15 @@ export default async function EventRegistrationsPage({ params }: PageProps) {
<thead> <thead>
<tr className="border-b bg-muted/50"> <tr className="border-b bg-muted/50">
<th className="p-3 text-left font-medium"> <th className="p-3 text-left font-medium">
Veranstaltung {t('event')}
</th> </th>
<th className="p-3 text-left font-medium">Datum</th> <th className="p-3 text-left font-medium">{t('eventDate')}</th>
<th className="p-3 text-left font-medium">Status</th> <th className="p-3 text-left font-medium">{t('status')}</th>
<th className="p-3 text-right font-medium">Kapazität</th> <th className="p-3 text-right font-medium">{t('capacity')}</th>
<th className="p-3 text-right font-medium"> <th className="p-3 text-right font-medium">
Anmeldungen {t('registrations')}
</th> </th>
<th className="p-3 text-right font-medium">Auslastung</th> <th className="p-3 text-right font-medium">{t('utilization')}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -148,7 +151,13 @@ export default async function EventRegistrationsPage({ params }: PageProps) {
: '—'} : '—'}
</td> </td>
<td className="p-3"> <td className="p-3">
<Badge variant="outline">{event.status}</Badge> <Badge
variant={
EVENT_STATUS_VARIANT[event.status] ?? 'secondary'
}
>
{EVENT_STATUS_LABEL[event.status] ?? event.status}
</Badge>
</td> </td>
<td className="p-3 text-right"> <td className="p-3 text-right">
{event.capacity ?? '—'} {event.capacity ?? '—'}

View File

@@ -10,6 +10,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { createFinanceApi } from '@kit/finance/api'; import { createFinanceApi } from '@kit/finance/api';
import { CmsPageShell } from '~/components/cms-page-shell'; import { CmsPageShell } from '~/components/cms-page-shell';
import { AccountNotFound } from '~/components/account-not-found';
interface PageProps { interface PageProps {
params: Promise<{ account: string; id: string }>; params: Promise<{ account: string; id: string }>;
@@ -49,7 +50,7 @@ export default async function InvoiceDetailPage({ params }: PageProps) {
.eq('slug', account) .eq('slug', account)
.single(); .single();
if (!acct) return <div>Konto nicht gefunden</div>; if (!acct) return <AccountNotFound />;
const api = createFinanceApi(client); const api = createFinanceApi(client);
const invoice = await api.getInvoiceWithItems(id); const invoice = await api.getInvoiceWithItems(id);

View File

@@ -1,6 +1,7 @@
import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { CreateInvoiceForm } from '@kit/finance/components'; import { CreateInvoiceForm } from '@kit/finance/components';
import { CmsPageShell } from '~/components/cms-page-shell'; import { CmsPageShell } from '~/components/cms-page-shell';
import { AccountNotFound } from '~/components/account-not-found';
interface Props { params: Promise<{ account: string }> } interface Props { params: Promise<{ account: string }> }
@@ -8,7 +9,7 @@ export default async function NewInvoicePage({ params }: Props) {
const { account } = await params; const { account } = await params;
const client = getSupabaseServerClient(); const client = getSupabaseServerClient();
const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single(); const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single();
if (!acct) return <div>Konto nicht gefunden</div>; if (!acct) return <AccountNotFound />;
return ( return (
<CmsPageShell account={account} title="Neue Rechnung" description="Rechnung mit Positionen erstellen"> <CmsPageShell account={account} title="Neue Rechnung" description="Rechnung mit Positionen erstellen">

View File

@@ -11,30 +11,16 @@ import { createFinanceApi } from '@kit/finance/api';
import { CmsPageShell } from '~/components/cms-page-shell'; import { CmsPageShell } from '~/components/cms-page-shell';
import { EmptyState } from '~/components/empty-state'; 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 { interface PageProps {
params: Promise<{ account: string }>; 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<string, string> = {
draft: 'Entwurf',
sent: 'Versendet',
paid: 'Bezahlt',
overdue: 'Überfällig',
cancelled: 'Storniert',
};
const formatCurrency = (amount: unknown) => const formatCurrency = (amount: unknown) =>
new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format( new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(
Number(amount), Number(amount),
@@ -50,7 +36,7 @@ export default async function InvoicesPage({ params }: PageProps) {
.eq('slug', account) .eq('slug', account)
.single(); .single();
if (!acct) return <div>Konto nicht gefunden</div>; if (!acct) return <AccountNotFound />;
const api = createFinanceApi(client); const api = createFinanceApi(client);
const invoices = await api.listInvoices(acct.id); const invoices = await api.listInvoices(acct.id);
@@ -141,10 +127,10 @@ export default async function InvoicesPage({ params }: PageProps) {
<td className="p-3"> <td className="p-3">
<Badge <Badge
variant={ variant={
STATUS_VARIANT[status] ?? 'secondary' INVOICE_STATUS_VARIANT[status] ?? 'secondary'
} }
> >
{STATUS_LABEL[status] ?? status} {INVOICE_STATUS_LABEL[status] ?? status}
</Badge> </Badge>
</td> </td>
</tr> </tr>

View File

@@ -1,6 +1,6 @@
import Link from 'next/link'; 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 { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Badge } from '@kit/ui/badge'; import { Badge } from '@kit/ui/badge';
@@ -12,49 +12,18 @@ import { createFinanceApi } from '@kit/finance/api';
import { CmsPageShell } from '~/components/cms-page-shell'; import { CmsPageShell } from '~/components/cms-page-shell';
import { EmptyState } from '~/components/empty-state'; import { EmptyState } from '~/components/empty-state';
import { StatsCard } from '~/components/stats-card'; 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 { interface PageProps {
params: Promise<{ account: string }>; 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<string, string> = {
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<string, string> = {
draft: 'Entwurf',
sent: 'Versendet',
paid: 'Bezahlt',
overdue: 'Überfällig',
cancelled: 'Storniert',
};
export default async function FinancePage({ params }: PageProps) { export default async function FinancePage({ params }: PageProps) {
const { account } = await params; const { account } = await params;
const client = getSupabaseServerClient(); const client = getSupabaseServerClient();
@@ -65,7 +34,7 @@ export default async function FinancePage({ params }: PageProps) {
.eq('slug', account) .eq('slug', account)
.single(); .single();
if (!acct) return <div>Konto nicht gefunden</div>; if (!acct) return <AccountNotFound />;
const api = createFinanceApi(client); const api = createFinanceApi(client);
@@ -89,11 +58,27 @@ export default async function FinancePage({ params }: PageProps) {
<CmsPageShell account={account} title="Finanzen"> <CmsPageShell account={account} title="Finanzen">
<div className="flex w-full flex-col gap-6"> <div className="flex w-full flex-col gap-6">
{/* Header */} {/* Header */}
<div> <div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Finanzen</h1> <div>
<p className="text-muted-foreground"> <h1 className="text-2xl font-bold">Finanzen</h1>
SEPA-Einzüge und Rechnungen <p className="text-muted-foreground">
</p> SEPA-Einzüge und Rechnungen
</p>
</div>
<div className="flex gap-2">
<Link href={`/home/${account}/finance/invoices/new`}>
<Button variant="outline">
<Plus className="mr-2 h-4 w-4" />
Neue Rechnung
</Button>
</Link>
<Link href={`/home/${account}/finance/sepa/new`}>
<Button>
<Plus className="mr-2 h-4 w-4" />
Neuer SEPA-Einzug
</Button>
</Link>
</div>
</div> </div>
{/* Stats */} {/* Stats */}
@@ -147,7 +132,7 @@ export default async function FinancePage({ params }: PageProps) {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{batches.slice(0, 5).map((batch: Record<string, unknown>) => ( {batches.map((batch: Record<string, unknown>) => (
<tr <tr
key={String(batch.id)} key={String(batch.id)}
className="border-b hover:bg-muted/30" className="border-b hover:bg-muted/30"
@@ -219,7 +204,7 @@ export default async function FinancePage({ params }: PageProps) {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{invoices.slice(0, 5).map((invoice: Record<string, unknown>) => ( {invoices.map((invoice: Record<string, unknown>) => (
<tr <tr
key={String(invoice.id)} key={String(invoice.id)}
className="border-b hover:bg-muted/30" className="border-b hover:bg-muted/30"

View File

@@ -11,6 +11,7 @@ import { createFinanceApi } from '@kit/finance/api';
import { CmsPageShell } from '~/components/cms-page-shell'; import { CmsPageShell } from '~/components/cms-page-shell';
import { StatsCard } from '~/components/stats-card'; import { StatsCard } from '~/components/stats-card';
import { AccountNotFound } from '~/components/account-not-found';
interface PageProps { interface PageProps {
params: Promise<{ account: string }>; params: Promise<{ account: string }>;
@@ -31,7 +32,7 @@ export default async function PaymentsPage({ params }: PageProps) {
.eq('slug', account) .eq('slug', account)
.single(); .single();
if (!acct) return <div>Konto nicht gefunden</div>; if (!acct) return <AccountNotFound />;
const api = createFinanceApi(client); const api = createFinanceApi(client);

View File

@@ -10,6 +10,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { createFinanceApi } from '@kit/finance/api'; import { createFinanceApi } from '@kit/finance/api';
import { CmsPageShell } from '~/components/cms-page-shell'; import { CmsPageShell } from '~/components/cms-page-shell';
import { AccountNotFound } from '~/components/account-not-found';
interface PageProps { interface PageProps {
params: Promise<{ account: string; batchId: string }>; params: Promise<{ account: string; batchId: string }>;
@@ -64,7 +65,7 @@ export default async function SepaBatchDetailPage({ params }: PageProps) {
.eq('slug', account) .eq('slug', account)
.single(); .single();
if (!acct) return <div>Konto nicht gefunden</div>; if (!acct) return <AccountNotFound />;
const api = createFinanceApi(client); const api = createFinanceApi(client);

View File

@@ -2,6 +2,7 @@ import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { CreateSepaBatchForm } from '@kit/finance/components'; import { CreateSepaBatchForm } from '@kit/finance/components';
import { CmsPageShell } from '~/components/cms-page-shell'; import { CmsPageShell } from '~/components/cms-page-shell';
import { AccountNotFound } from '~/components/account-not-found';
interface Props { interface Props {
params: Promise<{ account: string }>; params: Promise<{ account: string }>;
@@ -17,7 +18,7 @@ export default async function NewSepaBatchPage({ params }: Props) {
.eq('slug', account) .eq('slug', account)
.single(); .single();
if (!acct) return <div>Konto nicht gefunden</div>; if (!acct) return <AccountNotFound />;
return ( return (
<CmsPageShell account={account} title="Neuer SEPA-Einzug" description="SEPA-Lastschrifteinzug erstellen"> <CmsPageShell account={account} title="Neuer SEPA-Einzug" description="SEPA-Lastschrifteinzug erstellen">

View File

@@ -11,30 +11,16 @@ import { createFinanceApi } from '@kit/finance/api';
import { CmsPageShell } from '~/components/cms-page-shell'; import { CmsPageShell } from '~/components/cms-page-shell';
import { EmptyState } from '~/components/empty-state'; 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 { interface PageProps {
params: Promise<{ account: string }>; 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<string, string> = {
draft: 'Entwurf',
ready: 'Bereit',
submitted: 'Eingereicht',
completed: 'Abgeschlossen',
failed: 'Fehlgeschlagen',
};
const formatCurrency = (amount: unknown) => const formatCurrency = (amount: unknown) =>
new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format( new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(
Number(amount), Number(amount),
@@ -50,7 +36,7 @@ export default async function SepaPage({ params }: PageProps) {
.eq('slug', account) .eq('slug', account)
.single(); .single();
if (!acct) return <div>Konto nicht gefunden</div>; if (!acct) return <AccountNotFound />;
const api = createFinanceApi(client); const api = createFinanceApi(client);
const batches = await api.listBatches(acct.id); const batches = await api.listBatches(acct.id);
@@ -115,10 +101,10 @@ export default async function SepaPage({ params }: PageProps) {
<td className="p-3"> <td className="p-3">
<Badge <Badge
variant={ variant={
STATUS_VARIANT[String(batch.status)] ?? 'secondary' BATCH_STATUS_VARIANT[String(batch.status)] ?? 'secondary'
} }
> >
{STATUS_LABEL[String(batch.status)] ?? {BATCH_STATUS_LABEL[String(batch.status)] ??
String(batch.status)} String(batch.status)}
</Badge> </Badge>
</td> </td>

View File

@@ -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<Record<string, string | string[] | undefined>>;
}
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 <AccountNotFound />;
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 (
<CmsPageShell account={account} title="Fischerei - Fangbücher">
<FischereiTabNavigation account={account} activeTab="catch-books" />
<CatchBooksDataTable
data={result.data}
total={result.total}
page={page}
pageSize={25}
account={account}
/>
</CmsPageShell>
);
}

View File

@@ -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<Record<string, string | string[] | undefined>>;
}
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 <AccountNotFound />;
const api = createFischereiApi(client);
const page = Number(search.page) || 1;
const result = await api.listCompetitions(acct.id, {
page,
pageSize: 25,
});
return (
<CmsPageShell account={account} title="Fischerei - Wettbewerbe">
<FischereiTabNavigation account={account} activeTab="competitions" />
<CompetitionsDataTable
data={result.data}
total={result.total}
page={page}
pageSize={25}
account={account}
/>
</CmsPageShell>
);
}

View File

@@ -0,0 +1,5 @@
import { ReactNode } from 'react';
export default function FischereiLayout({ children }: { children: ReactNode }) {
return <>{children}</>;
}

View File

@@ -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<Record<string, string | string[] | undefined>>;
}
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 <AccountNotFound />;
const api = createFischereiApi(client);
const page = Number(search.page) || 1;
const result = await api.listLeases(acct.id, {
page,
pageSize: 25,
});
return (
<CmsPageShell account={account} title="Fischerei - Pachten">
<FischereiTabNavigation account={account} activeTab="leases" />
<div className="flex w-full flex-col gap-6">
<div>
<h1 className="text-2xl font-bold">Pachten</h1>
<p className="text-muted-foreground">
Gewässerpachtverträge verwalten
</p>
</div>
<Card>
<CardHeader>
<CardTitle>Pachten ({result.total})</CardTitle>
</CardHeader>
<CardContent>
{result.data.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12 text-center">
<h3 className="text-lg font-semibold">
Keine Pachten vorhanden
</h3>
<p className="mt-1 max-w-sm text-sm text-muted-foreground">
Erstellen Sie Ihren ersten Pachtvertrag.
</p>
</div>
) : (
<div className="rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="p-3 text-left font-medium">Verpächter</th>
<th className="p-3 text-left font-medium">Gewässer</th>
<th className="p-3 text-left font-medium">Beginn</th>
<th className="p-3 text-left font-medium">Ende</th>
<th className="p-3 text-right font-medium">Jahresbetrag ()</th>
<th className="p-3 text-left font-medium">Zahlungsart</th>
</tr>
</thead>
<tbody>
{result.data.map((lease: Record<string, unknown>) => {
const waters = lease.waters as Record<string, unknown> | null;
const paymentMethod = String(lease.payment_method ?? 'ueberweisung');
return (
<tr key={String(lease.id)} className="border-b hover:bg-muted/30">
<td className="p-3 font-medium">
{String(lease.lessor_name)}
</td>
<td className="p-3">
{waters ? String(waters.name) : '—'}
</td>
<td className="p-3">
{lease.start_date
? new Date(String(lease.start_date)).toLocaleDateString('de-DE')
: '—'}
</td>
<td className="p-3">
{lease.end_date
? new Date(String(lease.end_date)).toLocaleDateString('de-DE')
: 'unbefristet'}
</td>
<td className="p-3 text-right">
{lease.initial_amount != null
? `${Number(lease.initial_amount).toLocaleString('de-DE', { minimumFractionDigits: 2 })} €`
: '—'}
</td>
<td className="p-3">
<Badge variant="outline">
{LEASE_PAYMENT_LABELS[paymentMethod] ?? paymentMethod}
</Badge>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
</div>
</CmsPageShell>
);
}

View File

@@ -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 <AccountNotFound />;
const api = createFischereiApi(client);
const stats = await api.getDashboardStats(acct.id);
return (
<CmsPageShell account={account} title="Fischerei">
<FischereiTabNavigation account={account} activeTab="overview" />
<FischereiDashboard stats={stats} account={account} />
</CmsPageShell>
);
}

View File

@@ -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 <AccountNotFound />;
const api = createFischereiApi(client);
const permits = await api.listPermits(acct.id);
return (
<CmsPageShell account={account} title="Fischerei - Erlaubnisscheine">
<FischereiTabNavigation account={account} activeTab="permits" />
<div className="flex w-full flex-col gap-6">
<div>
<h1 className="text-2xl font-bold">Erlaubnisscheine</h1>
<p className="text-muted-foreground">
Erlaubnisscheine und Gewässerkarten verwalten
</p>
</div>
<Card>
<CardHeader>
<CardTitle>Erlaubnisscheine ({permits.length})</CardTitle>
</CardHeader>
<CardContent>
{permits.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12 text-center">
<h3 className="text-lg font-semibold">
Keine Erlaubnisscheine vorhanden
</h3>
<p className="mt-1 max-w-sm text-sm text-muted-foreground">
Erstellen Sie Ihren ersten Erlaubnisschein.
</p>
</div>
) : (
<div className="rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="p-3 text-left font-medium">Bezeichnung</th>
<th className="p-3 text-left font-medium">Kurzcode</th>
<th className="p-3 text-left font-medium">Hauptgewässer</th>
<th className="p-3 text-right font-medium">Gesamtmenge</th>
<th className="p-3 text-center font-medium">Zum Verkauf</th>
</tr>
</thead>
<tbody>
{permits.map((permit: Record<string, unknown>) => {
const waters = permit.waters as Record<string, unknown> | null;
return (
<tr key={String(permit.id)} className="border-b hover:bg-muted/30">
<td className="p-3 font-medium">{String(permit.name)}</td>
<td className="p-3 text-muted-foreground">
{String(permit.short_code ?? '—')}
</td>
<td className="p-3">
{waters ? String(waters.name) : '—'}
</td>
<td className="p-3 text-right">
{permit.total_quantity != null
? String(permit.total_quantity)
: '—'}
</td>
<td className="p-3 text-center">
{permit.is_for_sale ? '✓' : '—'}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
</div>
</CmsPageShell>
);
}

View File

@@ -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 <AccountNotFound />;
return (
<CmsPageShell account={account} title="Neue Fischart">
<FischereiTabNavigation account={account} activeTab="species" />
<CreateSpeciesForm accountId={acct.id} account={account} />
</CmsPageShell>
);
}

View File

@@ -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<Record<string, string | string[] | undefined>>;
}
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 <AccountNotFound />;
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 (
<CmsPageShell account={account} title="Fischerei - Fischarten">
<FischereiTabNavigation account={account} activeTab="species" />
<SpeciesDataTable
data={result.data}
total={result.total}
page={page}
pageSize={50}
account={account}
/>
</CmsPageShell>
);
}

View File

@@ -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 <AccountNotFound />;
return (
<CmsPageShell account={account} title="Fischerei - Statistiken">
<FischereiTabNavigation account={account} activeTab="statistics" />
<div className="flex w-full flex-col gap-6">
<div>
<h1 className="text-2xl font-bold">Statistiken</h1>
<p className="text-muted-foreground">
Fangstatistiken und Auswertungen
</p>
</div>
<Card>
<CardHeader>
<CardTitle>Fangstatistiken</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12 text-center">
<h3 className="text-lg font-semibold">Noch keine Daten vorhanden</h3>
<p className="mt-1 max-w-sm text-sm text-muted-foreground">
Sobald Fangbücher eingereicht und geprüft werden, erscheinen hier Statistiken und Auswertungen.
</p>
</div>
</CardContent>
</Card>
</div>
</CmsPageShell>
);
}

View File

@@ -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 <AccountNotFound />;
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<string, unknown>) => ({
id: String(w.id),
name: String(w.name),
}));
const species = speciesResult.data.map((s: Record<string, unknown>) => ({
id: String(s.id),
name: String(s.name),
}));
return (
<CmsPageShell account={account} title="Besatz eintragen">
<FischereiTabNavigation account={account} activeTab="stocking" />
<CreateStockingForm
accountId={acct.id}
account={account}
waters={waters}
species={species}
/>
</CmsPageShell>
);
}

View File

@@ -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<Record<string, string | string[] | undefined>>;
}
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 <AccountNotFound />;
const api = createFischereiApi(client);
const page = Number(search.page) || 1;
const result = await api.listStocking(acct.id, {
page,
pageSize: 25,
});
return (
<CmsPageShell account={account} title="Fischerei - Besatz">
<FischereiTabNavigation account={account} activeTab="stocking" />
<StockingDataTable
data={result.data}
total={result.total}
page={page}
pageSize={25}
account={account}
/>
</CmsPageShell>
);
}

View File

@@ -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 <AccountNotFound />;
return (
<CmsPageShell account={account} title="Neues Gewässer">
<FischereiTabNavigation account={account} activeTab="waters" />
<CreateWaterForm accountId={acct.id} account={account} />
</CmsPageShell>
);
}

View File

@@ -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<Record<string, string | string[] | undefined>>;
}
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 <AccountNotFound />;
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 (
<CmsPageShell account={account} title="Fischerei - Gewässer">
<FischereiTabNavigation account={account} activeTab="waters" />
<WatersDataTable
data={result.data}
total={result.total}
page={page}
pageSize={25}
account={account}
/>
</CmsPageShell>
);
}

View File

@@ -1,11 +1,15 @@
import { cache } from 'react';
import { use } from 'react'; import { use } from 'react';
import { cookies } from 'next/headers'; import { cookies } from 'next/headers';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import { Fish, FileSignature, Building2 } from 'lucide-react';
import * as z from 'zod'; import * as z from 'zod';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { TeamAccountWorkspaceContextProvider } from '@kit/team-accounts/components'; import { TeamAccountWorkspaceContextProvider } from '@kit/team-accounts/components';
import { NavigationConfigSchema } from '@kit/ui/navigation-schema';
import { Page, PageMobileNavigation, PageNavigation } from '@kit/ui/page'; import { Page, PageMobileNavigation, PageNavigation } from '@kit/ui/page';
import { SidebarProvider } from '@kit/ui/sidebar'; import { SidebarProvider } from '@kit/ui/sidebar';
@@ -33,21 +37,109 @@ function TeamWorkspaceLayout({ children, params }: TeamWorkspaceLayoutProps) {
return <HeaderLayout account={account}>{children}</HeaderLayout>; return <HeaderLayout account={account}>{children}</HeaderLayout>;
} }
/**
* 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<string, boolean>) ?? {};
});
/**
* 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<typeof NavigationConfigSchema>,
account: string,
features: Record<string, boolean>,
): z.output<typeof NavigationConfigSchema> {
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: <Fish className="w-4" />,
});
}
if (features.meetings) {
featureEntries.push({
label: 'common.routes.meetings',
path: `/home/${account}/meetings`,
Icon: <FileSignature className="w-4" />,
});
}
if (features.verband) {
featureEntries.push({
label: 'common.routes.verband',
path: `/home/${account}/verband`,
Icon: <Building2 className="w-4" />,
});
}
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({ async function SidebarLayout({
account, account,
children, children,
}: React.PropsWithChildren<{ }: React.PropsWithChildren<{
account: string; account: string;
}>) { }>) {
const [data, state] = await Promise.all([ const [data, state, features] = await Promise.all([
loadTeamWorkspace(account), loadTeamWorkspace(account),
getLayoutState(account), getLayoutState(account),
getAccountFeatures(account),
]); ]);
if (!data) { if (!data) {
redirect('/'); redirect('/');
} }
const baseConfig = getTeamAccountSidebarConfig(account);
const config = injectAccountFeatureRoutes(baseConfig, account, features);
const accounts = data.accounts.map(({ name, slug, picture_url }) => ({ const accounts = data.accounts.map(({ name, slug, picture_url }) => ({
label: name, label: name,
value: slug, value: slug,
@@ -64,6 +156,7 @@ async function SidebarLayout({
accountId={data.account.id} accountId={data.account.id}
accounts={accounts} accounts={accounts}
user={data.user} user={data.user}
config={config}
/> />
</PageNavigation> </PageNavigation>
@@ -75,6 +168,7 @@ async function SidebarLayout({
userId={data.user.id} userId={data.user.id}
accounts={accounts} accounts={accounts}
account={account} account={account}
config={config}
/> />
</div> </div>
</PageMobileNavigation> </PageMobileNavigation>
@@ -86,19 +180,25 @@ async function SidebarLayout({
); );
} }
function HeaderLayout({ async function HeaderLayout({
account, account,
children, children,
}: React.PropsWithChildren<{ }: React.PropsWithChildren<{
account: string; 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 ( return (
<TeamAccountWorkspaceContextProvider value={data}> <TeamAccountWorkspaceContextProvider value={data}>
<Page style={'header'}> <Page style={'header'}>
<PageNavigation> <PageNavigation>
<TeamAccountNavigationMenu workspace={data} /> <TeamAccountNavigationMenu workspace={data} config={config} />
</PageNavigation> </PageNavigation>
{children} {children}

View File

@@ -0,0 +1,5 @@
import { ReactNode } from 'react';
export default function MeetingsLayout({ children }: { children: ReactNode }) {
return <>{children}</>;
}

View File

@@ -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 <AccountNotFound />;
const api = createMeetingsApi(client);
const [stats, recentProtocols, overdueTasks] = await Promise.all([
api.getDashboardStats(acct.id),
api.getRecentProtocols(acct.id),
api.getOverdueTasks(acct.id),
]);
return (
<CmsPageShell account={account} title="Sitzungsprotokolle">
<MeetingsTabNavigation account={account} activeTab="overview" />
<MeetingsDashboard
stats={stats}
recentProtocols={recentProtocols}
overdueTasks={overdueTasks}
account={account}
/>
</CmsPageShell>
);
}

View File

@@ -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 <AccountNotFound />;
const api = createMeetingsApi(client);
let protocol;
try {
protocol = await api.getProtocol(protocolId);
} catch {
return (
<CmsPageShell account={account} title="Sitzungsprotokolle">
<div className="text-center py-12">
<h2 className="text-lg font-semibold">Protokoll nicht gefunden</h2>
<Link href={`/home/${account}/meetings/protocols`} className="mt-4 inline-block">
<Button variant="outline">Zurück zur Übersicht</Button>
</Link>
</div>
</CmsPageShell>
);
}
const items = await api.listItems(protocolId);
return (
<CmsPageShell account={account} title="Sitzungsprotokolle">
<MeetingsTabNavigation account={account} activeTab="protocols" />
<div className="space-y-6">
{/* Back + Title */}
<div className="flex items-center gap-4">
<Link href={`/home/${account}/meetings/protocols`}>
<Button variant="ghost" size="sm">
<ArrowLeft className="mr-2 h-4 w-4" />
Zurück
</Button>
</Link>
</div>
{/* Protocol Header */}
<Card>
<CardHeader>
<div className="flex items-start justify-between">
<div>
<CardTitle className="text-xl">{protocol.title}</CardTitle>
<div className="mt-2 flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
<span>
{new Date(protocol.meeting_date).toLocaleDateString('de-DE', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</span>
<span>·</span>
<Badge variant="secondary">
{MEETING_TYPE_LABELS[protocol.meeting_type] ?? protocol.meeting_type}
</Badge>
{protocol.is_published ? (
<Badge variant="default">Veröffentlicht</Badge>
) : (
<Badge variant="outline">Entwurf</Badge>
)}
</div>
</div>
</div>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{protocol.location && (
<div>
<p className="text-sm font-medium text-muted-foreground">Ort</p>
<p className="text-sm">{protocol.location}</p>
</div>
)}
{protocol.attendees && (
<div className="sm:col-span-2">
<p className="text-sm font-medium text-muted-foreground">Teilnehmer</p>
<p className="text-sm whitespace-pre-line">{protocol.attendees}</p>
</div>
)}
{protocol.remarks && (
<div className="sm:col-span-2">
<p className="text-sm font-medium text-muted-foreground">Anmerkungen</p>
<p className="text-sm whitespace-pre-line">{protocol.remarks}</p>
</div>
)}
</CardContent>
</Card>
{/* Items List */}
<ProtocolItemsList
items={items}
protocolId={protocolId}
account={account}
/>
</div>
</CmsPageShell>
);
}

View File

@@ -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 <AccountNotFound />;
return (
<CmsPageShell account={account} title="Sitzungsprotokolle">
<MeetingsTabNavigation account={account} activeTab="protocols" />
<div className="space-y-4">
<div>
<h1 className="text-2xl font-bold">Neues Protokoll erstellen</h1>
<p className="text-muted-foreground">
Erstellen Sie ein neues Sitzungsprotokoll mit Tagesordnungspunkten.
</p>
</div>
<CreateProtocolForm accountId={acct.id} account={account} />
</div>
</CmsPageShell>
);
}

View File

@@ -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<Record<string, string | string[] | undefined>>;
}
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 <AccountNotFound />;
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 (
<CmsPageShell account={account} title="Sitzungsprotokolle">
<MeetingsTabNavigation account={account} activeTab="protocols" />
<ProtocolsDataTable
data={result.data}
total={result.total}
page={result.page}
pageSize={result.pageSize}
account={account}
/>
</CmsPageShell>
);
}

View File

@@ -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<Record<string, string | string[] | undefined>>;
}
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 <AccountNotFound />;
const api = createMeetingsApi(client);
const page = typeof sp.page === 'string' ? parseInt(sp.page, 10) : 1;
const result = await api.listOpenTasks(acct.id, { page });
return (
<CmsPageShell account={account} title="Sitzungsprotokolle">
<MeetingsTabNavigation account={account} activeTab="tasks" />
<div className="space-y-4">
<div>
<h1 className="text-2xl font-bold">Offene Aufgaben</h1>
<p className="text-muted-foreground">
Alle offenen und in Bearbeitung befindlichen Tagesordnungspunkte über alle Protokolle.
</p>
</div>
<OpenTasksView
data={result.data as any}
total={result.total}
page={result.page}
pageSize={result.pageSize}
account={account}
/>
</div>
</CmsPageShell>
);
}

View File

@@ -2,6 +2,7 @@ import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { createMemberManagementApi } from '@kit/member-management/api'; import { createMemberManagementApi } from '@kit/member-management/api';
import { EditMemberForm } from '@kit/member-management/components'; import { EditMemberForm } from '@kit/member-management/components';
import { CmsPageShell } from '~/components/cms-page-shell'; import { CmsPageShell } from '~/components/cms-page-shell';
import { AccountNotFound } from '~/components/account-not-found';
interface Props { interface Props {
params: Promise<{ account: string; memberId: string }>; params: Promise<{ account: string; memberId: string }>;
@@ -11,7 +12,7 @@ export default async function EditMemberPage({ params }: Props) {
const { account, memberId } = await params; const { account, memberId } = await params;
const client = getSupabaseServerClient(); const client = getSupabaseServerClient();
const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single(); const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single();
if (!acct) return <div>Konto nicht gefunden</div>; if (!acct) return <AccountNotFound />;
const api = createMemberManagementApi(client); const api = createMemberManagementApi(client);
const member = await api.getMember(memberId); const member = await api.getMember(memberId);

View File

@@ -2,6 +2,7 @@ import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { createMemberManagementApi } from '@kit/member-management/api'; import { createMemberManagementApi } from '@kit/member-management/api';
import { MemberDetailView } from '@kit/member-management/components'; import { MemberDetailView } from '@kit/member-management/components';
import { CmsPageShell } from '~/components/cms-page-shell'; import { CmsPageShell } from '~/components/cms-page-shell';
import { AccountNotFound } from '~/components/account-not-found';
interface Props { interface Props {
params: Promise<{ account: string; memberId: string }>; params: Promise<{ account: string; memberId: string }>;
@@ -11,7 +12,7 @@ export default async function MemberDetailPage({ params }: Props) {
const { account, memberId } = await params; const { account, memberId } = await params;
const client = getSupabaseServerClient(); const client = getSupabaseServerClient();
const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single(); const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single();
if (!acct) return <div>Konto nicht gefunden</div>; if (!acct) return <AccountNotFound />;
const api = createMemberManagementApi(client); const api = createMemberManagementApi(client);
const member = await api.getMember(memberId); const member = await api.getMember(memberId);

View File

@@ -2,6 +2,7 @@ import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { createMemberManagementApi } from '@kit/member-management/api'; import { createMemberManagementApi } from '@kit/member-management/api';
import { ApplicationWorkflow } from '@kit/member-management/components'; import { ApplicationWorkflow } from '@kit/member-management/components';
import { CmsPageShell } from '~/components/cms-page-shell'; import { CmsPageShell } from '~/components/cms-page-shell';
import { AccountNotFound } from '~/components/account-not-found';
interface Props { interface Props {
params: Promise<{ account: string }>; params: Promise<{ account: string }>;
@@ -11,7 +12,7 @@ export default async function ApplicationsPage({ params }: Props) {
const { account } = await params; const { account } = await params;
const client = getSupabaseServerClient(); const client = getSupabaseServerClient();
const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single(); const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single();
if (!acct) return <div>Konto nicht gefunden</div>; if (!acct) return <AccountNotFound />;
const api = createMemberManagementApi(client); const api = createMemberManagementApi(client);
const applications = await api.listApplications(acct.id); const applications = await api.listApplications(acct.id);

View File

@@ -1,11 +1,12 @@
import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { createMemberManagementApi } from '@kit/member-management/api'; import { createMemberManagementApi } from '@kit/member-management/api';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card'; import { CreditCard } from 'lucide-react';
import { Button } from '@kit/ui/button';
import { Badge } from '@kit/ui/badge';
import { CreditCard, Download } from 'lucide-react';
import { CmsPageShell } from '~/components/cms-page-shell'; import { CmsPageShell } from '~/components/cms-page-shell';
import { EmptyState } from '~/components/empty-state'; 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 { interface Props {
params: Promise<{ account: string }>; params: Promise<{ account: string }>;
@@ -15,67 +16,31 @@ export default async function MemberCardsPage({ params }: Props) {
const { account } = await params; const { account } = await params;
const client = getSupabaseServerClient(); const client = getSupabaseServerClient();
const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single(); const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single();
if (!acct) return <div>Konto nicht gefunden</div>; if (!acct) return <AccountNotFound />;
const api = createMemberManagementApi(client); 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; const members = result.data;
return ( return (
<CmsPageShell account={account} title="Mitgliedsausweise" description="Ausweise erstellen und verwalten"> <CmsPageShell account={account} title="Mitgliedsausweise" description="Ausweise erstellen und verwalten">
<div className="space-y-6"> {members.length === 0 ? (
<div className="flex items-center justify-between"> <EmptyState
<p className="text-sm text-muted-foreground">{members.length} aktive Mitglieder</p> icon={<CreditCard className="h-8 w-8" />}
<Button disabled> title="Keine aktiven Mitglieder"
<Download className="mr-2 h-4 w-4" /> description="Erstellen Sie zuerst Mitglieder, um Ausweise zu generieren."
Alle Ausweise generieren (PDF) actionLabel="Mitglieder verwalten"
</Button> actionHref={`/home/${account}/members-cms`}
</div> />
) : (
{members.length === 0 ? ( <EmptyState
<EmptyState icon={<CreditCard className="h-8 w-8" />}
icon={<CreditCard className="h-8 w-8" />} title="Feature in Entwicklung"
title="Keine aktiven Mitglieder" description={`Die Ausweiserstellung für ${members.length} aktive Mitglieder wird derzeit entwickelt. Diese Funktion wird in einem kommenden Update verfügbar sein.`}
description="Erstellen Sie zuerst Mitglieder, um Ausweise zu generieren." actionLabel="Mitglieder verwalten"
actionLabel="Mitglieder verwalten" actionHref={`/home/${account}/members-cms`}
actionHref={`/home/${account}/members-cms`} />
/> )}
) : (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{members.map((m: Record<string, unknown>) => (
<Card key={String(m.id)}>
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="font-semibold">{String(m.last_name)}, {String(m.first_name)}</p>
<p className="text-xs text-muted-foreground">Nr. {String(m.member_number ?? '—')}</p>
</div>
<Badge variant="default">Aktiv</Badge>
</div>
<div className="mt-3 flex gap-2">
<Button size="sm" variant="outline" disabled>
<CreditCard className="mr-1 h-3 w-3" />
Ausweis
</Button>
</div>
</CardContent>
</Card>
))}
</div>
)}
<Card>
<CardHeader>
<CardTitle>PDF-Generierung</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Die PDF-Generierung erfordert die Installation von <code>@react-pdf/renderer</code>.
Nach der Installation können Mitgliedsausweise einzeln oder als Stapel erstellt werden.
</p>
</CardContent>
</Card>
</div>
</CmsPageShell> </CmsPageShell>
); );
} }

View File

@@ -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 (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger render={<Button size="sm" />}>
<Plus className="mr-1 h-4 w-4" />
Neue Abteilung
</DialogTrigger>
<DialogContent>
<form onSubmit={handleSubmit}>
<DialogHeader>
<DialogTitle>Neue Abteilung</DialogTitle>
<DialogDescription>
Erstellen Sie eine neue Abteilung oder Sparte für Ihren Verein.
</DialogDescription>
</DialogHeader>
<div className="mt-4 space-y-4">
<div className="space-y-2">
<Label htmlFor="dept-name">Name</Label>
<Input
id="dept-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="z. B. Jugendabteilung"
required
minLength={1}
maxLength={128}
/>
</div>
<div className="space-y-2">
<Label htmlFor="dept-description">Beschreibung (optional)</Label>
<Input
id="dept-description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Kurze Beschreibung"
/>
</div>
</div>
<DialogFooter className="mt-4">
<Button type="submit" disabled={isPending || !name.trim()}>
{isPending ? 'Wird erstellt…' : 'Erstellen'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,11 +1,11 @@
import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { createMemberManagementApi } from '@kit/member-management/api'; 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 { CmsPageShell } from '~/components/cms-page-shell';
import { EmptyState } from '~/components/empty-state'; import { EmptyState } from '~/components/empty-state';
import { Users } from 'lucide-react'; import { Users } from 'lucide-react';
import { AccountNotFound } from '~/components/account-not-found';
import { CreateDepartmentDialog } from './create-department-dialog';
interface Props { interface Props {
params: Promise<{ account: string }>; params: Promise<{ account: string }>;
@@ -15,40 +15,45 @@ export default async function DepartmentsPage({ params }: Props) {
const { account } = await params; const { account } = await params;
const client = getSupabaseServerClient(); const client = getSupabaseServerClient();
const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single(); const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single();
if (!acct) return <div>Konto nicht gefunden</div>; if (!acct) return <AccountNotFound />;
const api = createMemberManagementApi(client); const api = createMemberManagementApi(client);
const departments = await api.listDepartments(acct.id); const departments = await api.listDepartments(acct.id);
return ( return (
<CmsPageShell account={account} title="Abteilungen" description="Sparten und Abteilungen verwalten"> <CmsPageShell account={account} title="Abteilungen" description="Sparten und Abteilungen verwalten">
{departments.length === 0 ? ( <div className="space-y-4">
<EmptyState <div className="flex items-center justify-end">
icon={<Users className="h-8 w-8" />} <CreateDepartmentDialog accountId={acct.id} />
title="Keine Abteilungen vorhanden"
description="Erstellen Sie Ihre erste Abteilung."
actionLabel="Neue Abteilung"
/>
) : (
<div className="rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="p-3 text-left font-medium">Name</th>
<th className="p-3 text-left font-medium">Beschreibung</th>
</tr>
</thead>
<tbody>
{departments.map((dept: Record<string, unknown>) => (
<tr key={String(dept.id)} className="border-b hover:bg-muted/30">
<td className="p-3 font-medium">{String(dept.name)}</td>
<td className="p-3 text-muted-foreground">{String(dept.description ?? '—')}</td>
</tr>
))}
</tbody>
</table>
</div> </div>
)}
{departments.length === 0 ? (
<EmptyState
icon={<Users className="h-8 w-8" />}
title="Keine Abteilungen vorhanden"
description="Erstellen Sie Ihre erste Abteilung."
/>
) : (
<div className="rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="p-3 text-left font-medium">Name</th>
<th className="p-3 text-left font-medium">Beschreibung</th>
</tr>
</thead>
<tbody>
{departments.map((dept: Record<string, unknown>) => (
<tr key={String(dept.id)} className="border-b hover:bg-muted/30">
<td className="p-3 font-medium">{String(dept.name)}</td>
<td className="p-3 text-muted-foreground">{String(dept.description ?? '—')}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</CmsPageShell> </CmsPageShell>
); );
} }

View File

@@ -2,6 +2,7 @@ import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { createMemberManagementApi } from '@kit/member-management/api'; import { createMemberManagementApi } from '@kit/member-management/api';
import { DuesCategoryManager } from '@kit/member-management/components'; import { DuesCategoryManager } from '@kit/member-management/components';
import { CmsPageShell } from '~/components/cms-page-shell'; import { CmsPageShell } from '~/components/cms-page-shell';
import { AccountNotFound } from '~/components/account-not-found';
interface Props { interface Props {
params: Promise<{ account: string }>; params: Promise<{ account: string }>;
@@ -11,7 +12,7 @@ export default async function DuesPage({ params }: Props) {
const { account } = await params; const { account } = await params;
const client = getSupabaseServerClient(); const client = getSupabaseServerClient();
const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single(); const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single();
if (!acct) return <div>Konto nicht gefunden</div>; if (!acct) return <AccountNotFound />;
const api = createMemberManagementApi(client); const api = createMemberManagementApi(client);
const categories = await api.listDuesCategories(acct.id); const categories = await api.listDuesCategories(acct.id);

View File

@@ -1,6 +1,7 @@
import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { MemberImportWizard } from '@kit/member-management/components'; import { MemberImportWizard } from '@kit/member-management/components';
import { CmsPageShell } from '~/components/cms-page-shell'; import { CmsPageShell } from '~/components/cms-page-shell';
import { AccountNotFound } from '~/components/account-not-found';
interface Props { interface Props {
params: Promise<{ account: string }>; params: Promise<{ account: string }>;
@@ -10,7 +11,7 @@ export default async function MemberImportPage({ params }: Props) {
const { account } = await params; const { account } = await params;
const client = getSupabaseServerClient(); const client = getSupabaseServerClient();
const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single(); const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single();
if (!acct) return <div>Konto nicht gefunden</div>; if (!acct) return <AccountNotFound />;
return ( return (
<CmsPageShell account={account} title="Mitglieder importieren" description="CSV-Datei importieren"> <CmsPageShell account={account} title="Mitglieder importieren" description="CSV-Datei importieren">

View File

@@ -2,6 +2,7 @@ import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { createMemberManagementApi } from '@kit/member-management/api'; import { createMemberManagementApi } from '@kit/member-management/api';
import { CreateMemberForm } from '@kit/member-management/components'; import { CreateMemberForm } from '@kit/member-management/components';
import { CmsPageShell } from '~/components/cms-page-shell'; import { CmsPageShell } from '~/components/cms-page-shell';
import { AccountNotFound } from '~/components/account-not-found';
interface Props { params: Promise<{ account: string }> } interface Props { params: Promise<{ account: string }> }
@@ -9,7 +10,7 @@ export default async function NewMemberPage({ params }: Props) {
const { account } = await params; const { account } = await params;
const client = getSupabaseServerClient(); const client = getSupabaseServerClient();
const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single(); const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single();
if (!acct) return <div>Konto nicht gefunden</div>; if (!acct) return <AccountNotFound />;
const api = createMemberManagementApi(client); const api = createMemberManagementApi(client);
const duesCategories = await api.listDuesCategories(acct.id); const duesCategories = await api.listDuesCategories(acct.id);

View File

@@ -2,6 +2,9 @@ import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { createMemberManagementApi } from '@kit/member-management/api'; import { createMemberManagementApi } from '@kit/member-management/api';
import { MembersDataTable } from '@kit/member-management/components'; import { MembersDataTable } from '@kit/member-management/components';
import { CmsPageShell } from '~/components/cms-page-shell'; import { CmsPageShell } from '~/components/cms-page-shell';
import { AccountNotFound } from '~/components/account-not-found';
const PAGE_SIZE = 25;
interface Props { interface Props {
params: Promise<{ account: string }>; params: Promise<{ account: string }>;
@@ -13,7 +16,7 @@ export default async function MembersPage({ params, searchParams }: Props) {
const search = await searchParams; const search = await searchParams;
const client = getSupabaseServerClient(); const client = getSupabaseServerClient();
const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single(); const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single();
if (!acct) return <div>Konto nicht gefunden</div>; if (!acct) return <AccountNotFound />;
const api = createMemberManagementApi(client); const api = createMemberManagementApi(client);
const page = Number(search.page) || 1; const page = Number(search.page) || 1;
@@ -21,7 +24,7 @@ export default async function MembersPage({ params, searchParams }: Props) {
search: search.q as string, search: search.q as string,
status: search.status as string, status: search.status as string,
page, page,
pageSize: 25, pageSize: PAGE_SIZE,
}); });
const duesCategories = await api.listDuesCategories(acct.id); const duesCategories = await api.listDuesCategories(acct.id);
@@ -31,7 +34,7 @@ export default async function MembersPage({ params, searchParams }: Props) {
data={result.data} data={result.data}
total={result.total} total={result.total}
page={page} page={page}
pageSize={25} pageSize={PAGE_SIZE}
account={account} account={account}
duesCategories={(duesCategories ?? []).map((c: Record<string, unknown>) => ({ duesCategories={(duesCategories ?? []).map((c: Record<string, unknown>) => ({
id: String(c.id), name: String(c.name), id: String(c.id), name: String(c.name),

View File

@@ -8,6 +8,7 @@ import { createMemberManagementApi } from '@kit/member-management/api';
import { CmsPageShell } from '~/components/cms-page-shell'; import { CmsPageShell } from '~/components/cms-page-shell';
import { StatsCard } from '~/components/stats-card'; import { StatsCard } from '~/components/stats-card';
import { StatsBarChart, StatsPieChart } from '~/components/stats-charts'; import { StatsBarChart, StatsPieChart } from '~/components/stats-charts';
import { AccountNotFound } from '~/components/account-not-found';
interface PageProps { interface PageProps {
params: Promise<{ account: string }>; params: Promise<{ account: string }>;
@@ -23,7 +24,7 @@ export default async function MemberStatisticsPage({ params }: PageProps) {
.eq('slug', account) .eq('slug', account)
.single(); .single();
if (!acct) return <div>Konto nicht gefunden</div>; if (!acct) return <AccountNotFound />;
const api = createMemberManagementApi(client); const api = createMemberManagementApi(client);
const stats = await api.getMemberStatistics(acct.id); const stats = await api.getMemberStatistics(acct.id);

View File

@@ -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: <Fish className="h-5 w-5" />,
},
{
key: 'meetings',
label: 'Sitzungsprotokolle',
description:
'Sitzungsprotokolle, Tagesordnungspunkte und Beschlüsse verwalten',
icon: <FileSignature className="h-5 w-5" />,
},
{
key: 'verband',
label: 'Verbandsverwaltung',
description:
'Mitgliedsvereine, Kontaktpersonen, Beiträge und Statistiken verwalten',
icon: <Building2 className="h-5 w-5" />,
},
];
interface ModuleTogglesProps {
accountId: string;
features: Record<string, boolean>;
}
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 (
<div className="flex flex-col gap-4">
<div>
<h2 className="text-xl font-semibold">Verfügbare Module</h2>
<p className="text-muted-foreground text-sm">
Aktivieren oder deaktivieren Sie Module für Ihren Verein
</p>
</div>
<div className="divide-y rounded-lg border">
{AVAILABLE_MODULES.map((mod) => {
const isEnabled = features[mod.key] === true;
return (
<div
key={mod.key}
className="flex items-center justify-between gap-4 p-4"
>
<div className="flex items-center gap-3">
<div className="text-muted-foreground">{mod.icon}</div>
<div>
<p className="font-medium">{mod.label}</p>
<p className="text-muted-foreground text-sm">
{mod.description}
</p>
</div>
</div>
<Switch
checked={isEnabled}
onCheckedChange={(checked) =>
handleToggle(mod.key, Boolean(checked))
}
disabled={isPending}
/>
</div>
);
})}
</div>
</div>
);
}

View File

@@ -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<string, boolean>) ?? {};
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 };
}

View File

@@ -1,7 +1,14 @@
import Link from 'next/link';
import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { createModuleBuilderApi } from '@kit/module-builder/api'; 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 { interface ModulesPageProps {
params: Promise<{ account: string }>; params: Promise<{ account: string }>;
} }
@@ -19,48 +26,59 @@ export default async function ModulesPage({ params }: ModulesPageProps) {
.single(); .single();
if (!accountData) { if (!accountData) {
return <div>Account not found</div>; return <AccountNotFound />;
} }
// 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<string, boolean>) ?? {};
const modules = await api.modules.listModules(accountData.id); const modules = await api.modules.listModules(accountData.id);
return ( return (
<div className="flex flex-col gap-4"> <CmsPageShell
<div className="flex items-center justify-between"> account={account}
<div> title="Module"
<h1 className="text-2xl font-bold">Module</h1> description="Verwalten Sie Ihre Datenmodule"
<p className="text-muted-foreground"> >
Verwalten Sie Ihre Datenmodule <div className="flex flex-col gap-8">
</p> <ModuleToggles accountId={accountData.id} features={features} />
</div>
</div>
{modules.length === 0 ? ( {modules.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center"> <div className="flex flex-col items-center justify-center py-12 text-center">
<p className="text-muted-foreground"> <p className="text-muted-foreground">
Noch keine Module vorhanden. Erstellen Sie Ihr erstes Modul. Noch keine Module vorhanden. Erstellen Sie Ihr erstes Modul.
</p> </p>
</div> </div>
) : ( ) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{modules.map((module: Record<string, unknown>) => ( {modules.map((module: Record<string, unknown>) => (
<div <Link
key={module.id as string} key={module.id as string}
className="rounded-lg border p-4 hover:bg-accent/50 transition-colors" href={`/home/${account}/modules/${module.id as string}`}
> className="block rounded-lg border p-4 hover:bg-accent/50 transition-colors"
<h3 className="font-semibold">{String(module.display_name)}</h3> >
{module.description ? ( <h3 className="font-semibold">
<p className="text-sm text-muted-foreground mt-1"> {String(module.display_name)}
{String(module.description)} </h3>
</p> {module.description ? (
) : null} <p className="text-sm text-muted-foreground mt-1">
<div className="mt-2 text-xs text-muted-foreground"> {String(module.description)}
Status: {String(module.status)} </p>
</div> ) : null}
</div> <div className="mt-2 text-xs text-muted-foreground">
))} Status: {String(module.status)}
</div> </div>
)} </Link>
</div> ))}
</div>
)}
</div>
</CmsPageShell>
); );
} }

View File

@@ -11,47 +11,18 @@ import { createNewsletterApi } from '@kit/newsletter/api';
import { CmsPageShell } from '~/components/cms-page-shell'; import { CmsPageShell } from '~/components/cms-page-shell';
import { StatsCard } from '~/components/stats-card'; 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 { interface PageProps {
params: Promise<{ account: string; campaignId: string }>; 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<string, string> = {
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<string, string> = {
pending: 'Ausstehend',
sent: 'Gesendet',
failed: 'Fehlgeschlagen',
bounced: 'Zurückgewiesen',
};
export default async function NewsletterDetailPage({ params }: PageProps) { export default async function NewsletterDetailPage({ params }: PageProps) {
const { account, campaignId } = await params; const { account, campaignId } = await params;
const client = getSupabaseServerClient(); const client = getSupabaseServerClient();
@@ -62,7 +33,7 @@ export default async function NewsletterDetailPage({ params }: PageProps) {
.eq('slug', account) .eq('slug', account)
.single(); .single();
if (!acct) return <div>Konto nicht gefunden</div>; if (!acct) return <AccountNotFound />;
const api = createNewsletterApi(client); const api = createNewsletterApi(client);
@@ -102,8 +73,8 @@ export default async function NewsletterDetailPage({ params }: PageProps) {
<CardTitle> <CardTitle>
{String(newsletter.subject ?? '(Kein Betreff)')} {String(newsletter.subject ?? '(Kein Betreff)')}
</CardTitle> </CardTitle>
<Badge variant={STATUS_VARIANT[status] ?? 'secondary'}> <Badge variant={NEWSLETTER_STATUS_VARIANT[status] ?? 'secondary'}>
{STATUS_LABEL[status] ?? status} {NEWSLETTER_STATUS_LABEL[status] ?? status}
</Badge> </Badge>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@@ -175,10 +146,10 @@ export default async function NewsletterDetailPage({ params }: PageProps) {
<td className="p-3"> <td className="p-3">
<Badge <Badge
variant={ variant={
RECIPIENT_STATUS_VARIANT[rStatus] ?? 'secondary' NEWSLETTER_RECIPIENT_STATUS_VARIANT[rStatus] ?? 'secondary'
} }
> >
{RECIPIENT_STATUS_LABEL[rStatus] ?? rStatus} {NEWSLETTER_RECIPIENT_STATUS_LABEL[rStatus] ?? rStatus}
</Badge> </Badge>
</td> </td>
</tr> </tr>

View File

@@ -1,6 +1,7 @@
import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { CreateNewsletterForm } from '@kit/newsletter/components'; import { CreateNewsletterForm } from '@kit/newsletter/components';
import { CmsPageShell } from '~/components/cms-page-shell'; import { CmsPageShell } from '~/components/cms-page-shell';
import { AccountNotFound } from '~/components/account-not-found';
interface Props { params: Promise<{ account: string }> } interface Props { params: Promise<{ account: string }> }
@@ -8,7 +9,7 @@ export default async function NewNewsletterPage({ params }: Props) {
const { account } = await params; const { account } = await params;
const client = getSupabaseServerClient(); const client = getSupabaseServerClient();
const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single(); const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single();
if (!acct) return <div>Konto nicht gefunden</div>; if (!acct) return <AccountNotFound />;
return ( return (
<CmsPageShell account={account} title="Neuer Newsletter" description="Newsletter-Kampagne erstellen"> <CmsPageShell account={account} title="Neuer Newsletter" description="Newsletter-Kampagne erstellen">

View File

@@ -1,6 +1,6 @@
import Link from 'next/link'; 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 { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Badge } from '@kit/ui/badge'; import { Badge } from '@kit/ui/badge';
@@ -12,32 +12,22 @@ import { createNewsletterApi } from '@kit/newsletter/api';
import { CmsPageShell } from '~/components/cms-page-shell'; import { CmsPageShell } from '~/components/cms-page-shell';
import { EmptyState } from '~/components/empty-state'; import { EmptyState } from '~/components/empty-state';
import { StatsCard } from '~/components/stats-card'; 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 { interface PageProps {
params: Promise<{ account: string }>; params: Promise<{ account: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
} }
const STATUS_BADGE_VARIANT: Record< export default async function NewsletterPage({ params, searchParams }: PageProps) {
string,
'secondary' | 'default' | 'info' | 'outline' | 'destructive'
> = {
draft: 'secondary',
scheduled: 'default',
sending: 'info',
sent: 'outline',
failed: 'destructive',
};
const STATUS_LABEL: Record<string, string> = {
draft: 'Entwurf',
scheduled: 'Geplant',
sending: 'Wird gesendet',
sent: 'Gesendet',
failed: 'Fehlgeschlagen',
};
export default async function NewsletterPage({ params }: PageProps) {
const { account } = await params; const { account } = await params;
const search = await searchParams;
const client = getSupabaseServerClient(); const client = getSupabaseServerClient();
const { data: acct } = await client const { data: acct } = await client
@@ -46,21 +36,29 @@ export default async function NewsletterPage({ params }: PageProps) {
.eq('slug', account) .eq('slug', account)
.single(); .single();
if (!acct) return <div>Konto nicht gefunden</div>; if (!acct) return <AccountNotFound />;
const api = createNewsletterApi(client); 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<string, unknown>) => n.status === 'sent', (n: Record<string, unknown>) => n.status === 'sent',
).length; ).length;
const totalRecipients = newsletters.reduce( const totalRecipients = allNewsletters.reduce(
(sum: number, n: Record<string, unknown>) => (sum: number, n: Record<string, unknown>) =>
sum + (Number(n.total_recipients) || 0), sum + (Number(n.total_recipients) || 0),
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 ( return (
<CmsPageShell account={account} title="Newsletter"> <CmsPageShell account={account} title="Newsletter">
<div className="flex w-full flex-col gap-6"> <div className="flex w-full flex-col gap-6">
@@ -85,7 +83,7 @@ export default async function NewsletterPage({ params }: PageProps) {
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<StatsCard <StatsCard
title="Newsletter" title="Newsletter"
value={newsletters.length} value={totalItems}
icon={<Mail className="h-5 w-5" />} icon={<Mail className="h-5 w-5" />}
/> />
<StatsCard <StatsCard
@@ -101,7 +99,7 @@ export default async function NewsletterPage({ params }: PageProps) {
</div> </div>
{/* Table or Empty State */} {/* Table or Empty State */}
{newsletters.length === 0 ? ( {totalItems === 0 ? (
<EmptyState <EmptyState
icon={<Mail className="h-8 w-8" />} icon={<Mail className="h-8 w-8" />}
title="Keine Newsletter vorhanden" title="Keine Newsletter vorhanden"
@@ -112,7 +110,7 @@ export default async function NewsletterPage({ params }: PageProps) {
) : ( ) : (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Alle Newsletter ({newsletters.length})</CardTitle> <CardTitle>Alle Newsletter ({totalItems})</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="rounded-md border"> <div className="rounded-md border">
@@ -143,10 +141,10 @@ export default async function NewsletterPage({ params }: PageProps) {
<td className="p-3"> <td className="p-3">
<Badge <Badge
variant={ variant={
STATUS_BADGE_VARIANT[String(nl.status)] ?? 'secondary' NEWSLETTER_STATUS_VARIANT[String(nl.status)] ?? 'secondary'
} }
> >
{STATUS_LABEL[String(nl.status)] ?? String(nl.status)} {NEWSLETTER_STATUS_LABEL[String(nl.status)] ?? String(nl.status)}
</Badge> </Badge>
</td> </td>
<td className="p-3 text-right"> <td className="p-3 text-right">
@@ -169,6 +167,44 @@ export default async function NewsletterPage({ params }: PageProps) {
</tbody> </tbody>
</table> </table>
</div> </div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between pt-4">
<p className="text-sm text-muted-foreground">
{startIdx + 1}{Math.min(startIdx + PAGE_SIZE, totalItems)} von {totalItems}
</p>
<div className="flex items-center gap-1">
{safePage > 1 ? (
<Link href={`/home/${account}/newsletter?page=${safePage - 1}`}>
<Button variant="outline" size="sm">
<ChevronLeft className="h-4 w-4" />
</Button>
</Link>
) : (
<Button variant="outline" size="sm" disabled>
<ChevronLeft className="h-4 w-4" />
</Button>
)}
<span className="px-3 text-sm font-medium">
{safePage} / {totalPages}
</span>
{safePage < totalPages ? (
<Link href={`/home/${account}/newsletter?page=${safePage + 1}`}>
<Button variant="outline" size="sm">
<ChevronRight className="h-4 w-4" />
</Button>
</Link>
) : (
<Button variant="outline" size="sm" disabled>
<ChevronRight className="h-4 w-4" />
</Button>
)}
</div>
</div>
)}
</CardContent> </CardContent>
</Card> </Card>
)} )}

View File

@@ -11,6 +11,7 @@ import { createNewsletterApi } from '@kit/newsletter/api';
import { CmsPageShell } from '~/components/cms-page-shell'; import { CmsPageShell } from '~/components/cms-page-shell';
import { EmptyState } from '~/components/empty-state'; import { EmptyState } from '~/components/empty-state';
import { AccountNotFound } from '~/components/account-not-found';
interface PageProps { interface PageProps {
params: Promise<{ account: string }>; params: Promise<{ account: string }>;
@@ -26,7 +27,7 @@ export default async function NewsletterTemplatesPage({ params }: PageProps) {
.eq('slug', account) .eq('slug', account)
.single(); .single();
if (!acct) return <div>Konto nicht gefunden</div>; if (!acct) return <AccountNotFound />;
const api = createNewsletterApi(client); const api = createNewsletterApi(client);
const templates = await api.listTemplates(acct.id); const templates = await api.listTemplates(acct.id);

View File

@@ -15,7 +15,7 @@ import {
import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Badge } from '@kit/ui/badge'; import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
import { import {
Card, Card,
CardContent, CardContent,
@@ -32,7 +32,9 @@ import { createBookingManagementApi } from '@kit/booking-management/api';
import { createEventManagementApi } from '@kit/event-management/api'; import { createEventManagementApi } from '@kit/event-management/api';
import { CmsPageShell } from '~/components/cms-page-shell'; import { CmsPageShell } from '~/components/cms-page-shell';
import { EmptyState } from '~/components/empty-state';
import { StatsCard } from '~/components/stats-card'; import { StatsCard } from '~/components/stats-card';
import { AccountNotFound } from '~/components/account-not-found';
interface TeamAccountHomePageProps { interface TeamAccountHomePageProps {
params: Promise<{ account: string }>; params: Promise<{ account: string }>;
@@ -50,7 +52,7 @@ export default async function TeamAccountHomePage({
.eq('slug', account) .eq('slug', account)
.single(); .single();
if (!acct) return <div>Konto nicht gefunden</div>; if (!acct) return <AccountNotFound />;
// Load all stats in parallel with allSettled for resilience // Load all stats in parallel with allSettled for resilience
const [ const [
@@ -157,7 +159,15 @@ export default async function TeamAccountHomePage({
href={`/home/${account}/bookings/${String(booking.id)}`} href={`/home/${account}/bookings/${String(booking.id)}`}
className="text-sm font-medium hover:underline" 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',
})
: '—'}
</Link> </Link>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{booking.check_in {booking.check_in
@@ -213,12 +223,11 @@ export default async function TeamAccountHomePage({
))} ))}
{bookings.data.length === 0 && events.data.length === 0 && ( {bookings.data.length === 0 && events.data.length === 0 && (
<div className="flex flex-col items-center justify-center py-8 text-center"> <EmptyState
<Activity className="h-8 w-8 text-muted-foreground/50" /> icon={<Activity className="h-8 w-8" />}
<p className="mt-2 text-sm text-muted-foreground"> title="Noch keine Aktivitäten"
Noch keine Aktivitäten vorhanden description="Aktuelle Buchungen und Veranstaltungen werden hier angezeigt."
</p> />
</div>
)} )}
</div> </div>
</CardContent> </CardContent>
@@ -231,69 +240,59 @@ export default async function TeamAccountHomePage({
<CardDescription>Häufig verwendete Aktionen</CardDescription> <CardDescription>Häufig verwendete Aktionen</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="flex flex-col gap-2"> <CardContent className="flex flex-col gap-2">
<Link href={`/home/${account}/members-cms/new`}> <Link
<Button href={`/home/${account}/members-cms/new`}
variant="outline" className="inline-flex w-full items-center justify-between gap-2 rounded-lg border border-border bg-background px-4 py-2 text-sm font-medium hover:bg-muted hover:text-foreground transition-all"
className="w-full justify-between" >
> <span className="flex items-center gap-2">
<span className="flex items-center gap-2"> <UserPlus className="h-4 w-4" />
<UserPlus className="h-4 w-4" /> Neues Mitglied
Neues Mitglied </span>
</span> <ArrowRight className="h-4 w-4" />
<ArrowRight className="h-4 w-4" />
</Button>
</Link> </Link>
<Link href={`/home/${account}/courses/new`}> <Link
<Button href={`/home/${account}/courses/new`}
variant="outline" className="inline-flex w-full items-center justify-between gap-2 rounded-lg border border-border bg-background px-4 py-2 text-sm font-medium hover:bg-muted hover:text-foreground transition-all"
className="w-full justify-between" >
> <span className="flex items-center gap-2">
<span className="flex items-center gap-2"> <GraduationCap className="h-4 w-4" />
<GraduationCap className="h-4 w-4" /> Neuer Kurs
Neuer Kurs </span>
</span> <ArrowRight className="h-4 w-4" />
<ArrowRight className="h-4 w-4" />
</Button>
</Link> </Link>
<Link href={`/home/${account}/newsletter/new`}> <Link
<Button href={`/home/${account}/newsletter/new`}
variant="outline" className="inline-flex w-full items-center justify-between gap-2 rounded-lg border border-border bg-background px-4 py-2 text-sm font-medium hover:bg-muted hover:text-foreground transition-all"
className="w-full justify-between" >
> <span className="flex items-center gap-2">
<span className="flex items-center gap-2"> <Mail className="h-4 w-4" />
<Mail className="h-4 w-4" /> Newsletter erstellen
Newsletter erstellen </span>
</span> <ArrowRight className="h-4 w-4" />
<ArrowRight className="h-4 w-4" />
</Button>
</Link> </Link>
<Link href={`/home/${account}/bookings/new`}> <Link
<Button href={`/home/${account}/bookings/new`}
variant="outline" className="inline-flex w-full items-center justify-between gap-2 rounded-lg border border-border bg-background px-4 py-2 text-sm font-medium hover:bg-muted hover:text-foreground transition-all"
className="w-full justify-between" >
> <span className="flex items-center gap-2">
<span className="flex items-center gap-2"> <BedDouble className="h-4 w-4" />
<BedDouble className="h-4 w-4" /> Neue Buchung
Neue Buchung </span>
</span> <ArrowRight className="h-4 w-4" />
<ArrowRight className="h-4 w-4" />
</Button>
</Link> </Link>
<Link href={`/home/${account}/events/new`}> <Link
<Button href={`/home/${account}/events/new`}
variant="outline" className="inline-flex w-full items-center justify-between gap-2 rounded-lg border border-border bg-background px-4 py-2 text-sm font-medium hover:bg-muted hover:text-foreground transition-all"
className="w-full justify-between" >
> <span className="flex items-center gap-2">
<span className="flex items-center gap-2"> <Plus className="h-4 w-4" />
<Plus className="h-4 w-4" /> Neue Veranstaltung
Neue Veranstaltung </span>
</span> <ArrowRight className="h-4 w-4" />
<ArrowRight className="h-4 w-4" />
</Button>
</Link> </Link>
</CardContent> </CardContent>
</Card> </Card>
@@ -317,10 +316,11 @@ export default async function TeamAccountHomePage({
aktiv aktiv
</p> </p>
</div> </div>
<Link href={`/home/${account}/bookings`}> <Link
<Button variant="ghost" size="icon"> href={`/home/${account}/bookings`}
<ArrowRight className="h-4 w-4" /> className="inline-flex h-9 w-9 items-center justify-center rounded-lg text-sm font-medium hover:bg-muted hover:text-foreground transition-all"
</Button> >
<ArrowRight className="h-4 w-4" />
</Link> </Link>
</div> </div>
</CardContent> </CardContent>
@@ -343,10 +343,11 @@ export default async function TeamAccountHomePage({
aktiv aktiv
</p> </p>
</div> </div>
<Link href={`/home/${account}/events`}> <Link
<Button variant="ghost" size="icon"> href={`/home/${account}/events`}
<ArrowRight className="h-4 w-4" /> className="inline-flex h-9 w-9 items-center justify-center rounded-lg text-sm font-medium hover:bg-muted hover:text-foreground transition-all"
</Button> >
<ArrowRight className="h-4 w-4" />
</Link> </Link>
</div> </div>
</CardContent> </CardContent>
@@ -366,10 +367,11 @@ export default async function TeamAccountHomePage({
von {courseStats.totalCourses} insgesamt von {courseStats.totalCourses} insgesamt
</p> </p>
</div> </div>
<Link href={`/home/${account}/courses`}> <Link
<Button variant="ghost" size="icon"> href={`/home/${account}/courses`}
<ArrowRight className="h-4 w-4" /> className="inline-flex h-9 w-9 items-center justify-center rounded-lg text-sm font-medium hover:bg-muted hover:text-foreground transition-all"
</Button> >
<ArrowRight className="h-4 w-4" />
</Link> </Link>
</div> </div>
</CardContent> </CardContent>

View File

@@ -1,6 +1,7 @@
import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { createSiteBuilderApi } from '@kit/site-builder/api'; import { createSiteBuilderApi } from '@kit/site-builder/api';
import { SiteEditor } from '@kit/site-builder/components'; import { SiteEditor } from '@kit/site-builder/components';
import { AccountNotFound } from '~/components/account-not-found';
interface Props { params: Promise<{ account: string; pageId: string }> } 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 { account, pageId } = await params;
const client = getSupabaseServerClient(); const client = getSupabaseServerClient();
const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single(); const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single();
if (!acct) return <div>Konto nicht gefunden</div>; if (!acct) return <AccountNotFound />;
const api = createSiteBuilderApi(client); const api = createSiteBuilderApi(client);
const page = await api.getPage(pageId); const page = await api.getPage(pageId);

View File

@@ -1,6 +1,7 @@
import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { CreatePageForm } from '@kit/site-builder/components'; import { CreatePageForm } from '@kit/site-builder/components';
import { CmsPageShell } from '~/components/cms-page-shell'; import { CmsPageShell } from '~/components/cms-page-shell';
import { AccountNotFound } from '~/components/account-not-found';
interface Props { interface Props {
params: Promise<{ account: string }>; params: Promise<{ account: string }>;
@@ -10,7 +11,7 @@ export default async function NewSitePage({ params }: Props) {
const { account } = await params; const { account } = await params;
const client = getSupabaseServerClient(); const client = getSupabaseServerClient();
const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single(); const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single();
if (!acct) return <div>Konto nicht gefunden</div>; if (!acct) return <AccountNotFound />;
return ( return (
<CmsPageShell account={account} title="Neue Seite" description="Seite für Ihre Vereinswebsite erstellen"> <CmsPageShell account={account} title="Neue Seite" description="Seite für Ihre Vereinswebsite erstellen">

View File

@@ -3,10 +3,12 @@ import { createSiteBuilderApi } from '@kit/site-builder/api';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { Badge } from '@kit/ui/badge'; import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button'; import { Button } from '@kit/ui/button';
import { cn } from '@kit/ui/utils';
import { Plus, Globe, FileText, Settings, ExternalLink } from 'lucide-react'; import { Plus, Globe, FileText, Settings, ExternalLink } from 'lucide-react';
import Link from 'next/link'; import Link from 'next/link';
import { CmsPageShell } from '~/components/cms-page-shell'; import { CmsPageShell } from '~/components/cms-page-shell';
import { EmptyState } from '~/components/empty-state'; import { EmptyState } from '~/components/empty-state';
import { AccountNotFound } from '~/components/account-not-found';
interface Props { params: Promise<{ account: string }> } interface Props { params: Promise<{ account: string }> }
@@ -14,14 +16,15 @@ export default async function SiteBuilderDashboard({ params }: Props) {
const { account } = await params; const { account } = await params;
const client = getSupabaseServerClient(); const client = getSupabaseServerClient();
const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single(); const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single();
if (!acct) return <div>Konto nicht gefunden</div>; if (!acct) return <AccountNotFound />;
const api = createSiteBuilderApi(client); const api = createSiteBuilderApi(client);
const pages = await api.listPages(acct.id); const pages = await api.listPages(acct.id);
const settings = await api.getSiteSettings(acct.id); const settings = await api.getSiteSettings(acct.id);
const posts = await api.listPosts(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<string, unknown>) => p.is_published).length;
return ( return (
<CmsPageShell account={account} title="Website-Baukasten" description="Ihre Vereinswebseite verwalten"> <CmsPageShell account={account} title="Website-Baukasten" description="Ihre Vereinswebseite verwalten">
@@ -34,7 +37,7 @@ export default async function SiteBuilderDashboard({ params }: Props) {
<Link href={`/home/${account}/site-builder/posts`}> <Link href={`/home/${account}/site-builder/posts`}>
<Button variant="outline" size="sm"><FileText className="mr-2 h-4 w-4" />Beiträge ({posts.length})</Button> <Button variant="outline" size="sm"><FileText className="mr-2 h-4 w-4" />Beiträge ({posts.length})</Button>
</Link> </Link>
{settings?.is_public && ( {isOnline && (
<a href={`/club/${account}`} target="_blank" rel="noopener"> <a href={`/club/${account}`} target="_blank" rel="noopener">
<Button variant="outline" size="sm"><ExternalLink className="mr-2 h-4 w-4" />Website ansehen</Button> <Button variant="outline" size="sm"><ExternalLink className="mr-2 h-4 w-4" />Website ansehen</Button>
</a> </a>
@@ -48,7 +51,17 @@ export default async function SiteBuilderDashboard({ params }: Props) {
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<Card><CardContent className="p-6"><p className="text-sm text-muted-foreground">Seiten</p><p className="text-2xl font-bold">{pages.length}</p></CardContent></Card> <Card><CardContent className="p-6"><p className="text-sm text-muted-foreground">Seiten</p><p className="text-2xl font-bold">{pages.length}</p></CardContent></Card>
<Card><CardContent className="p-6"><p className="text-sm text-muted-foreground">Veröffentlicht</p><p className="text-2xl font-bold">{publishedCount}</p></CardContent></Card> <Card><CardContent className="p-6"><p className="text-sm text-muted-foreground">Veröffentlicht</p><p className="text-2xl font-bold">{publishedCount}</p></CardContent></Card>
<Card><CardContent className="p-6"><p className="text-sm text-muted-foreground">Status</p><p className="text-2xl font-bold">{settings?.is_public ? '🟢 Online' : '🔴 Offline'}</p></CardContent></Card> <Card>
<CardContent className="p-6">
<p className="text-sm text-muted-foreground">Status</p>
<p className="text-2xl font-bold">
<span className="flex items-center gap-1.5">
<span className={cn('inline-block h-2 w-2 rounded-full', isOnline ? 'bg-green-500' : 'bg-red-500')} />
<span>{isOnline ? 'Online' : 'Offline'}</span>
</span>
</p>
</CardContent>
</Card>
</div> </div>
{pages.length === 0 ? ( {pages.length === 0 ? (

View File

@@ -1,6 +1,7 @@
import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { CreatePostForm } from '@kit/site-builder/components'; import { CreatePostForm } from '@kit/site-builder/components';
import { CmsPageShell } from '~/components/cms-page-shell'; import { CmsPageShell } from '~/components/cms-page-shell';
import { AccountNotFound } from '~/components/account-not-found';
interface Props { params: Promise<{ account: string }> } interface Props { params: Promise<{ account: string }> }
@@ -8,7 +9,7 @@ export default async function NewPostPage({ params }: Props) {
const { account } = await params; const { account } = await params;
const client = getSupabaseServerClient(); const client = getSupabaseServerClient();
const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single(); const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single();
if (!acct) return <div>Konto nicht gefunden</div>; if (!acct) return <AccountNotFound />;
return ( return (
<CmsPageShell account={account} title="Neuer Beitrag" description="Beitrag erstellen"> <CmsPageShell account={account} title="Neuer Beitrag" description="Beitrag erstellen">

View File

@@ -7,6 +7,7 @@ import { Plus } from 'lucide-react';
import Link from 'next/link'; import Link from 'next/link';
import { CmsPageShell } from '~/components/cms-page-shell'; import { CmsPageShell } from '~/components/cms-page-shell';
import { EmptyState } from '~/components/empty-state'; import { EmptyState } from '~/components/empty-state';
import { AccountNotFound } from '~/components/account-not-found';
interface Props { params: Promise<{ account: string }> } interface Props { params: Promise<{ account: string }> }
@@ -14,7 +15,7 @@ export default async function PostsManagerPage({ params }: Props) {
const { account } = await params; const { account } = await params;
const client = getSupabaseServerClient(); const client = getSupabaseServerClient();
const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single(); const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single();
if (!acct) return <div>Konto nicht gefunden</div>; if (!acct) return <AccountNotFound />;
const api = createSiteBuilderApi(client); const api = createSiteBuilderApi(client);
const posts = await api.listPosts(acct.id); const posts = await api.listPosts(acct.id);

View File

@@ -2,6 +2,7 @@ import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { createSiteBuilderApi } from '@kit/site-builder/api'; import { createSiteBuilderApi } from '@kit/site-builder/api';
import { SiteSettingsForm } from '@kit/site-builder/components'; import { SiteSettingsForm } from '@kit/site-builder/components';
import { CmsPageShell } from '~/components/cms-page-shell'; import { CmsPageShell } from '~/components/cms-page-shell';
import { AccountNotFound } from '~/components/account-not-found';
interface Props { params: Promise<{ account: string }> } interface Props { params: Promise<{ account: string }> }
@@ -9,7 +10,7 @@ export default async function SiteSettingsPage({ params }: Props) {
const { account } = await params; const { account } = await params;
const client = getSupabaseServerClient(); const client = getSupabaseServerClient();
const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single(); const { data: acct } = await client.from('accounts').select('id').eq('slug', account).single();
if (!acct) return <div>Konto nicht gefunden</div>; if (!acct) return <AccountNotFound />;
const api = createSiteBuilderApi(client); const api = createSiteBuilderApi(client);
const settings = await api.getSiteSettings(acct.id); const settings = await api.getSiteSettings(acct.id);

View File

@@ -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 <AccountNotFound />;
const api = createVerbandApi(client);
const detail = await api.getClubDetail(clubId);
return (
<CmsPageShell account={account} title={`Verein ${detail.club.name}`}>
<VerbandTabNavigation account={account} activeTab="clubs" />
<div className="space-y-6">
{/* Club Header */}
<div className="flex items-start justify-between">
<div>
<h1 className="text-2xl font-bold">{detail.club.name}</h1>
{detail.club.short_name && (
<p className="text-muted-foreground">{detail.club.short_name}</p>
)}
<div className="mt-2 flex flex-wrap gap-4 text-sm text-muted-foreground">
{detail.club.city && (
<span>{detail.club.zip} {detail.club.city}</span>
)}
{detail.club.member_count != null && (
<span>{detail.club.member_count} Mitglieder</span>
)}
{detail.club.founded_year && (
<span>Gegr. {detail.club.founded_year}</span>
)}
</div>
</div>
</div>
{/* Contacts */}
<ClubContactsManager clubId={clubId} contacts={detail.contacts} />
{/* Fee Billings */}
<ClubFeeBillingTable billings={detail.billings} clubId={clubId} />
{/* Notes */}
<ClubNotesList notes={detail.notes} clubId={clubId} />
</div>
</CmsPageShell>
);
}

View File

@@ -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 <AccountNotFound />;
const api = createVerbandApi(client);
const types = await api.listTypes(acct.id);
return (
<CmsPageShell account={account} title="Neuer Verein">
<VerbandTabNavigation account={account} activeTab="clubs" />
<CreateClubForm
accountId={acct.id}
account={account}
types={types.map((t) => ({ id: t.id, name: t.name }))}
/>
</CmsPageShell>
);
}

View File

@@ -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<Record<string, string | string[] | undefined>>;
}
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 <AccountNotFound />;
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 (
<CmsPageShell account={account} title="Verbandsverwaltung - Vereine">
<VerbandTabNavigation account={account} activeTab="clubs" />
<ClubsDataTable
data={result.data}
total={result.total}
page={page}
pageSize={25}
account={account}
types={types.map((t) => ({ id: t.id, name: t.name }))}
/>
</CmsPageShell>
);
}

View File

@@ -0,0 +1,5 @@
import { ReactNode } from 'react';
export default function VerbandLayout({ children }: { children: ReactNode }) {
return <>{children}</>;
}

View File

@@ -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 <AccountNotFound />;
const api = createVerbandApi(client);
const stats = await api.getDashboardStats(acct.id);
return (
<CmsPageShell account={account} title="Verbandsverwaltung">
<VerbandTabNavigation account={account} activeTab="overview" />
<VerbandDashboard stats={stats} account={account} />
</CmsPageShell>
);
}

View File

@@ -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<Record<string, unknown>>;
types: Array<Record<string, unknown>>;
feeTypes: Array<Record<string, unknown>>;
}
function SettingsSection({
title,
items,
onAdd,
onUpdate,
onDelete,
isAdding,
isUpdating,
}: {
title: string;
items: Array<Record<string, unknown>>;
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<string | null>(null);
const [editName, setEditName] = useState('');
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="flex items-center gap-2 text-base">
<Settings className="h-4 w-4" />
{title}
</CardTitle>
{!showAdd && (
<Button size="sm" onClick={() => setShowAdd(true)}>
<Plus className="mr-2 h-4 w-4" />
Hinzufügen
</Button>
)}
</CardHeader>
<CardContent>
{showAdd && (
<div className="mb-4 flex gap-2 rounded-lg border p-3">
<Input
placeholder="Name"
value={newName}
onChange={(e) => setNewName(e.target.value)}
className="flex-1"
/>
<Input
placeholder="Beschreibung (optional)"
value={newDesc}
onChange={(e) => setNewDesc(e.target.value)}
className="flex-1"
/>
<Button
size="sm"
disabled={!newName.trim() || isAdding}
onClick={() => {
onAdd(newName.trim(), newDesc.trim() || undefined);
setNewName('');
setNewDesc('');
setShowAdd(false);
}}
>
Erstellen
</Button>
<Button size="sm" variant="outline" onClick={() => setShowAdd(false)}>
Abbrechen
</Button>
</div>
)}
{items.length === 0 ? (
<p className="text-sm text-muted-foreground">Keine Einträge vorhanden.</p>
) : (
<div className="space-y-2">
{items.map((item) => (
<div key={String(item.id)} className="flex items-center justify-between rounded-lg border p-3">
{editingId === String(item.id) ? (
<div className="flex flex-1 gap-2">
<Input
value={editName}
onChange={(e) => setEditName(e.target.value)}
className="flex-1"
/>
<Button
size="sm"
disabled={!editName.trim() || isUpdating}
onClick={() => {
onUpdate(String(item.id), editName.trim());
setEditingId(null);
}}
>
Speichern
</Button>
<Button size="sm" variant="outline" onClick={() => setEditingId(null)}>
Abbrechen
</Button>
</div>
) : (
<>
<div>
<span className="font-medium">{String(item.name)}</span>
{item.description && (
<p className="text-xs text-muted-foreground">{String(item.description)}</p>
)}
</div>
<div className="flex gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => {
setEditingId(String(item.id));
setEditName(String(item.name));
}}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => onDelete(String(item.id))}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</>
)}
</div>
))}
</div>
)}
</CardContent>
</Card>
);
}
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 (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold">Einstellungen</h1>
<p className="text-muted-foreground">
Funktionen, Vereinstypen und Beitragsarten verwalten
</p>
</div>
<SettingsSection
title="Funktionen (Rollen)"
items={roles}
onAdd={(name, description) => execCreateRole({ accountId, name, description, sortOrder: 0 })}
onUpdate={(id, name) => execUpdateRole({ roleId: id, name })}
onDelete={(id) => execDeleteRole({ roleId: id })}
isAdding={isCreatingRole}
isUpdating={isUpdatingRole}
/>
<SettingsSection
title="Vereinstypen"
items={types}
onAdd={(name, description) => execCreateType({ accountId, name, description, sortOrder: 0 })}
onUpdate={(id, name) => execUpdateType({ typeId: id, name })}
onDelete={(id) => execDeleteType({ typeId: id })}
isAdding={isCreatingType}
isUpdating={isUpdatingType}
/>
<SettingsSection
title="Beitragsarten"
items={feeTypes}
onAdd={(name, description) => execCreateFeeType({ accountId, name, description, isActive: true })}
onUpdate={(id, name) => execUpdateFeeType({ feeTypeId: id, name })}
onDelete={(id) => execDeleteFeeType({ feeTypeId: id })}
isAdding={isCreatingFee}
isUpdating={isUpdatingFee}
/>
</div>
);
}

View File

@@ -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 <AccountNotFound />;
const api = createVerbandApi(client);
const [roles, types, feeTypes] = await Promise.all([
api.listRoles(acct.id),
api.listTypes(acct.id),
api.listFeeTypes(acct.id),
]);
return (
<CmsPageShell account={account} title="Verbandsverwaltung - Einstellungen">
<VerbandTabNavigation account={account} activeTab="settings" />
<SettingsContent
accountId={acct.id}
roles={roles}
types={types}
feeTypes={feeTypes}
/>
</CmsPageShell>
);
}

View File

@@ -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 (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold">Statistik</h1>
<p className="text-muted-foreground">
Entwicklung der Mitgliedsvereine und Gesamtmitglieder im Zeitverlauf
</p>
</div>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<BarChart3 className="h-4 w-4" />
Vereinsentwicklung
</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<AreaChart data={PLACEHOLDER_DATA}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="year" />
<YAxis />
<Tooltip />
<Area
type="monotone"
dataKey="vereine"
stroke="hsl(var(--primary))"
fill="hsl(var(--primary))"
fillOpacity={0.1}
name="Vereine"
/>
</AreaChart>
</ResponsiveContainer>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<BarChart3 className="h-4 w-4" />
Mitgliederentwicklung
</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<AreaChart data={PLACEHOLDER_DATA}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="year" />
<YAxis />
<Tooltip />
<Area
type="monotone"
dataKey="mitglieder"
stroke="hsl(var(--chart-2))"
fill="hsl(var(--chart-2))"
fillOpacity={0.1}
name="Mitglieder"
/>
</AreaChart>
</ResponsiveContainer>
</CardContent>
</Card>
</div>
<Card>
<CardContent className="p-6">
<p className="text-sm text-muted-foreground">
Die Statistiken werden automatisch aus den Vereinsdaten und der Verbandshistorie berechnet.
Pflegen Sie die Mitgliederzahlen in den einzelnen Vereinsdetails, um aktuelle Auswertungen zu erhalten.
</p>
</CardContent>
</Card>
</div>
);
}

View File

@@ -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 (
<CmsPageShell account={account} title="Verbandsverwaltung - Statistik">
<VerbandTabNavigation account={account} activeTab="statistics" />
<StatisticsContent />
</CmsPageShell>
);
}

View File

@@ -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 });
}
}

View File

@@ -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 });
}
}

View File

@@ -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 });
}
}

View File

@@ -29,9 +29,10 @@ async function getSupabaseHealthCheck() {
const { data, error } = await client const { data, error } = await client
.from('config') .from('config')
.select('billing_provider') .select('billing_provider')
.single(); .limit(1)
.maybeSingle();
return !error && Boolean(data?.billing_provider); return !error;
} catch { } catch {
return false; return false;
} }

View File

@@ -0,0 +1,22 @@
import Link from 'next/link';
import { AlertTriangle } from 'lucide-react';
import { Button } from '@kit/ui/button';
export function AccountNotFound() {
return (
<div className="flex flex-col items-center justify-center py-24 text-center">
<div className="mb-4 rounded-full bg-destructive/10 p-4">
<AlertTriangle className="h-8 w-8 text-destructive" />
</div>
<h2 className="text-xl font-semibold">Konto nicht gefunden</h2>
<p className="mt-2 max-w-md text-sm text-muted-foreground">
Das angeforderte Konto existiert nicht oder Sie haben keine Berechtigung darauf zuzugreifen.
</p>
<div className="mt-6">
<Link href="/home">
<Button variant="outline">Zum Dashboard</Button>
</Link>
</div>
</div>
);
}

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